From 0d8a1a88a7793086a2982422c609c5bb6f9c0bdc Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 28 Sep 2026 08:41:31 +0200 Subject: [PATCH] Non-transferable OWUs --- .../helcel/owu/activity/IouDetailScreen.kt | 3 ++ .../net/helcel/owu/activity/IssueScreen.kt | 17 +++++++ .../net/helcel/owu/activity/PeerScreen.kt | 9 +++- .../java/net/helcel/owu/activity/Widgets.kt | 5 +++ .../main/java/net/helcel/owu/ledger/Ledger.kt | 6 +++ .../main/java/net/helcel/owu/ledger/Model.kt | 12 +++++ .../java/net/helcel/owu/ledger/Verifier.kt | 6 ++- .../java/net/helcel/owu/peer/PeerEngine.kt | 8 +++- app/src/main/res/values/en.xml | 4 ++ .../java/net/helcel/owu/ledger/LedgerTest.kt | 44 +++++++++++++++++++ docs/SPEC.md | 11 +++-- 11 files changed, 116 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/net/helcel/owu/activity/IouDetailScreen.kt b/app/src/main/java/net/helcel/owu/activity/IouDetailScreen.kt index 331cf78..8795a7f 100644 --- a/app/src/main/java/net/helcel/owu/activity/IouDetailScreen.kt +++ b/app/src/main/java/net/helcel/owu/activity/IouDetailScreen.kt @@ -153,6 +153,9 @@ fun IouDetailScreen(nav: NavHostController, id: String) { (g.label?.let { "$it · " } ?: "") + "%.5f, %.5f · %d m".format(g.latitude, g.longitude, g.radiusM), ) } + if (iou.metadata.nonTransferable) { + Field(stringResource(R.string.field_transfer), stringResource(R.string.non_transferable_desc)) + } // Redeeming is handing it back to whoever wrote it. Your own // promise closes itself on coming home, so this is only ever diff --git a/app/src/main/java/net/helcel/owu/activity/IssueScreen.kt b/app/src/main/java/net/helcel/owu/activity/IssueScreen.kt index 9f6b84e..47e8886 100644 --- a/app/src/main/java/net/helcel/owu/activity/IssueScreen.kt +++ b/app/src/main/java/net/helcel/owu/activity/IssueScreen.kt @@ -88,6 +88,7 @@ fun IssueScreen(nav: NavHostController, templateId: String? = null, asTemplate: var notBefore by remember { mutableStateOf(template?.metadata?.notBefore) } var notAfter by remember { mutableStateOf(template?.metadata?.notAfter) } var hasWindow by remember { mutableStateOf(template?.metadata?.hasWindow == true) } + var nonTransferable by remember { mutableStateOf(template?.metadata?.nonTransferable == true) } // The keyboard walks through the form: every field offers "next" and // moves focus on, and the last one offers "done" and puts the keyboard @@ -119,6 +120,7 @@ fun IssueScreen(nav: NavHostController, templateId: String? = null, asTemplate: geoloc = parsedGeo(), notBefore = notBefore.takeIf { hasWindow }, notAfter = notAfter.takeIf { hasWindow }, + nonTransferable = nonTransferable, ) fun save() { @@ -317,6 +319,21 @@ fun IssueScreen(nav: NavHostController, templateId: String? = null, asTemplate: ) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp).clickable { nonTransferable = !nonTransferable }, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.field_transfer), style = MaterialTheme.typography.subtitle1) + Text( + stringResource(R.string.field_transfer_desc), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + Switch(checked = nonTransferable, onCheckedChange = { nonTransferable = it }) + } + Button(onClick = { save() }, modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) { Text(stringResource(if (TradeIntent.pending != null) R.string.action_save_and_offer else R.string.action_save)) } diff --git a/app/src/main/java/net/helcel/owu/activity/PeerScreen.kt b/app/src/main/java/net/helcel/owu/activity/PeerScreen.kt index 0a78136..ea993b7 100644 --- a/app/src/main/java/net/helcel/owu/activity/PeerScreen.kt +++ b/app/src/main/java/net/helcel/owu/activity/PeerScreen.kt @@ -113,9 +113,14 @@ fun PeerScreen(nav: NavHostController, beaconHex: String) { val settled = ready && !linkError // Everything I could put down, minted ious for this table included: the // picker shows what is already down as chosen rather than hiding it. - val mineHeld = remember(ious) { + // A non-transferable one is left out unless it can go to them: mine to + // give, or theirs coming home. + val mineHeld = remember(ious, peer?.key) { ious.values.filter { - Verifier.verify(it).stateOrNull?.let { s -> s.holder == Repo.me && s.status == Status.ACTIVE } == true + Verifier.verify(it).stateOrNull?.let { s -> + s.holder == Repo.me && s.status == Status.ACTIVE && + (peer == null || it.metadata.allowsTransfer(s, peer.key)) + } == true } } // Putting ious back on the table with the person who owes them *is* diff --git a/app/src/main/java/net/helcel/owu/activity/Widgets.kt b/app/src/main/java/net/helcel/owu/activity/Widgets.kt index 033ff2e..b947a4d 100644 --- a/app/src/main/java/net/helcel/owu/activity/Widgets.kt +++ b/app/src/main/java/net/helcel/owu/activity/Widgets.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.automirrored.filled.Send @@ -572,6 +573,10 @@ fun MetaGates( wrong = outOfPlace, ) } + // Unlike the two above, this one is a bar: the chain refuses it. + if (metadata.nonTransferable) { + GateLine(icon = Icons.Default.Lock, text = stringResource(R.string.non_transferable), wrong = false) + } } @Composable diff --git a/app/src/main/java/net/helcel/owu/ledger/Ledger.kt b/app/src/main/java/net/helcel/owu/ledger/Ledger.kt index 7ce1f6d..3cf4dec 100644 --- a/app/src/main/java/net/helcel/owu/ledger/Ledger.kt +++ b/app/src/main/java/net/helcel/owu/ledger/Ledger.kt @@ -45,6 +45,7 @@ object Ledger { val state = active(iou) if (state.holder != signer.publicKey) throw LedgerException("only the holder can transfer") if (transferee == signer.publicKey) throw LedgerException("cannot transfer to yourself") + if (!iou.metadata.allowsTransfer(state, transferee)) throw LedgerException("this OwU can only go back to who wrote it") val unsigned = Block.Transfer( sequence = state.length, timestamp = timestamp, @@ -95,6 +96,9 @@ object Ledger { val holder = signer.publicKey val theirHolder = theirsStates.first().second.holder if (theirsStates.any { it.second.holder != theirHolder }) throw LedgerException("their side is held by more than one person") + if (mineStates.any { (iou, s) -> !iou.metadata.allowsTransfer(s, theirHolder) } || + theirsStates.any { (iou, s) -> !iou.metadata.allowsTransfer(s, holder) } + ) throw LedgerException("a non-transferable OwU can only go back to who wrote it") val draft = ExchangeAgreement( id = id, timestamp = timestamp, @@ -120,6 +124,8 @@ object Ledger { val state = active(byId.getValue(ref.iouId)) if (state.holder != signer.publicKey) throw LedgerException("only the holder can accept") if (ref.headHash != state.headHash) throw LedgerException("OwU has changed since the proposal") + if (!byId.getValue(ref.iouId).metadata.allowsTransfer(state, proposal.left.holder)) + throw LedgerException("a non-transferable OwU can only go back to who wrote it") } return proposal.copy(right = proposal.right.copy(signature = signer.sign(proposal.signingBytes()))) } diff --git a/app/src/main/java/net/helcel/owu/ledger/Model.kt b/app/src/main/java/net/helcel/owu/ledger/Model.kt index e31220a..64e2f38 100644 --- a/app/src/main/java/net/helcel/owu/ledger/Model.kt +++ b/app/src/main/java/net/helcel/owu/ledger/Model.kt @@ -35,6 +35,8 @@ data class Metadata( val geoloc: GeoLoc? = null, @SerialName("not_before") val notBefore: Long? = null, @SerialName("not_after") val notAfter: Long? = null, + /** Bound to whoever it is first given to: they can only hand it back. */ + @SerialName("non_transferable") val nonTransferable: Boolean = false, ) { // All of it signed with the genesis block, description included: neither // side can edit what was promised. @@ -45,12 +47,22 @@ data class Metadata( "geoloc" to geoloc?.canonical(), "not_before" to notBefore, "not_after" to notAfter, + // Only when set, so every OwU written before the flag keeps its hash. + "non_transferable" to nonTransferable.takeIf { it }, ) fun hash(): String = Hash.sha256Hex(Canonical.bytes(canonical())) val hasWindow: Boolean get() = notBefore != null || notAfter != null + /** + * Whether an OwU in [state] may pass to [to]. Always, unless it is + * non-transferable: then it may leave its debtor's hands, to whoever they + * give it to, and go back to them to be redeemed, and nothing else. + */ + fun allowsTransfer(state: IouState, to: String): Boolean = + !nonTransferable || state.holder == state.debtor || to == state.debtor + /** Where [now] falls relative to the redemption window. */ fun timeGate(now: Long = Ledger.now()): TimeGate = when { notBefore != null && now < notBefore -> TimeGate.NotYet(notBefore) diff --git a/app/src/main/java/net/helcel/owu/ledger/Verifier.kt b/app/src/main/java/net/helcel/owu/ledger/Verifier.kt index 4d89ac8..250a795 100644 --- a/app/src/main/java/net/helcel/owu/ledger/Verifier.kt +++ b/app/src/main/java/net/helcel/owu/ledger/Verifier.kt @@ -52,7 +52,7 @@ object Verifier { state = when (block) { is Block.Issue -> throw Rejected(n, "a second ISSUE") - is Block.Transfer -> transfer(n, iou.id, block, state) + is Block.Transfer -> transfer(n, iou, block, state) is Block.Redeemed -> redeemed(n, block, state) }.copy(length = n + 1, headHash = block.hash(iou.id)) } @@ -69,10 +69,12 @@ object Verifier { * A hand-over. The three rules above the agreement hold whether it is a * gift or half a swap; the rest bind this block to the other chain's. */ - private fun transfer(n: Int, iouId: String, b: Block.Transfer, s: IouState): IouState { + private fun transfer(n: Int, iou: Iou, b: Block.Transfer, s: IouState): IouState { + val iouId = iou.id if (s.status != Status.ACTIVE) throw Rejected(n, "transfer of an OwU that is ${s.status}") if (b.transferor != s.holder) throw Rejected(n, "transferor is not the holder") if (b.transferee == b.transferor) throw Rejected(n, "transfer to self") + if (!iou.metadata.allowsTransfer(s, b.transferee)) throw Rejected(n, "non-transferable OwU passed on") val a = b.agreement ?: return s.copy(holder = b.transferee) val mine = a.side(iouId) ?: throw Rejected(n, "agreement does not name this OwU") diff --git a/app/src/main/java/net/helcel/owu/peer/PeerEngine.kt b/app/src/main/java/net/helcel/owu/peer/PeerEngine.kt index c63d0e7..038aeab 100644 --- a/app/src/main/java/net/helcel/owu/peer/PeerEngine.kt +++ b/app/src/main/java/net/helcel/owu/peer/PeerEngine.kt @@ -216,7 +216,9 @@ class PeerEngine( */ fun put(held: List = emptyList(), mint: List = emptyList()): Step { held.forEach { - if (active(it).holder != me) throw LedgerException("you do not hold that OwU") + val s = active(it) + if (s.holder != me) throw LedgerException("you do not hold that OwU") + if (!it.metadata.allowsTransfer(s, peerKey)) throw LedgerException("that OwU can only go back to who wrote it") } // Minted here, not by the caller, so a bundle that never leaves takes // its fresh ious with it. @@ -259,7 +261,9 @@ class PeerEngine( */ private fun adoptTheirs(ious: List) { ious.forEach { - if (active(it).holder != peerKey) throw IllegalStateException("they offer an OwU they do not hold") + val s = active(it) + if (s.holder != peerKey) throw IllegalStateException("they offer an OwU they do not hold") + if (!it.metadata.allowsTransfer(s, me)) throw IllegalStateException("they offer an OwU that cannot be passed on") } if (ious.map { it.id }.toSet().size != ious.size) throw IllegalStateException("the same OwU twice") theirOffer = ious diff --git a/app/src/main/res/values/en.xml b/app/src/main/res/values/en.xml index 9cadd62..4a10ffa 100644 --- a/app/src/main/res/values/en.xml +++ b/app/src/main/res/values/en.xml @@ -109,6 +109,10 @@ until %1$s expired The window closes before it opens. + Non-transferable + Whoever you give it to can only redeem it, not pass it on. + not transferable + Can be redeemed, not passed on: once given, it only goes back to whoever wrote it. Owed by Held by Say what is owed. diff --git a/app/src/test/java/net/helcel/owu/ledger/LedgerTest.kt b/app/src/test/java/net/helcel/owu/ledger/LedgerTest.kt index b0790c7..31ab68f 100644 --- a/app/src/test/java/net/helcel/owu/ledger/LedgerTest.kt +++ b/app/src/test/java/net/helcel/owu/ledger/LedgerTest.kt @@ -1,5 +1,6 @@ package net.helcel.owu.ledger +import net.helcel.owu.crypto.Canonical import net.helcel.owu.crypto.JvmSigner import kotlin.test.Test import kotlin.test.assertEquals @@ -390,4 +391,47 @@ class LedgerTest { assertEquals(iou, IouJson.decode(IouJson.encode(iou))) assertEquals(46.5197, iou.metadata.geoloc!!.latitude, 1e-6) } + + // --- non-transferable -------------------------------------------------- + + private val bound = hug.copy(nonTransferable = true) + + @Test + fun `a non-transferable OwU goes out from its debtor and only back to them`() { + val given = Ledger.transfer(Ledger.issue(alice, bound), alice, bob.publicKey) + assertEquals(bob.publicKey, valid(given).holder) + assertFailsWith { Ledger.transfer(given, bob, carol.publicKey) } + val home = Ledger.transfer(given, bob, alice.publicKey) + assertEquals(Status.REDEEMED, valid(Ledger.redeem(home, alice)).status) + } + + @Test + fun `a non-transferable OwU signed on anyway is rejected by the verifier`() { + val given = Ledger.transfer(Ledger.issue(alice, bound), alice, bob.publicKey) + val s = valid(given) + val unsigned = Block.Transfer( + sequence = s.length, timestamp = Ledger.now(), parentHash = s.headHash, + transferor = bob.publicKey, transferee = carol.publicKey, + ) + val passed = given.copy(chain = given.chain + unsigned.copy(signature = bob.sign(unsigned.signedBytes(given.id)))) + invalid(passed, "non-transferable") + // and the flag cannot be stripped to get round it + invalid(passed.copy(metadata = bound.copy(nonTransferable = false)), "metadata") + } + + @Test + fun `a non-transferable OwU swaps only back to its debtor`() { + val x = Ledger.issue(alice, creditor = bob.publicKey, metadata = bound) + val y = Ledger.issue(carol, creditor = carol.publicKey, metadata = Metadata("Y")) + assertFailsWith { Ledger.proposeExchange(listOf(x), listOf(y), bob) } + val z = Ledger.issue(alice, creditor = alice.publicKey, metadata = Metadata("Z")) + val a = Ledger.acceptExchange(Ledger.proposeExchange(listOf(x), listOf(z), bob), listOf(z), alice) + assertEquals(alice.publicKey, valid(Ledger.applyExchange(x, a)).holder) + } + + @Test + fun `an OwU without the flag hashes as it always did`() { + assertEquals(false, Canonical.encode(hug.canonical()).contains("non_transferable")) + assertTrue(Canonical.encode(bound.canonical()).contains("\"non_transferable\":true")) + } } diff --git a/docs/SPEC.md b/docs/SPEC.md index 4d026c2..72f0a82 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -25,6 +25,10 @@ An OwU may name a place (point, radius, label) and a redemption window (`not_bef Both are covered by the ISSUE signature, so neither can be edited after the fact. +### Non-transferable + +An OwU may be marked `non_transferable`. It can be given away by its debtor, and handed back to them to be redeemed, and that is all: whoever receives it cannot pass it on, by gift or by swap. **Unlike the gates, this is enforced**, by the verifier (section 2.3), so a chain that passes one on is rejected wherever it goes. The flag is in the signed metadata, so it cannot be removed after issue. In the canonical form it appears only when set, which leaves the hash of every OwU written without it unchanged. + --- ## 2. Cryptography @@ -55,7 +59,8 @@ An OwU is its metadata and a chain of blocks. "label": "Lausanne" }, "not_before": 1790000000, - "not_after": 1792600000 + "not_after": 1792600000, + "non_transferable": true }, "ledger_chain": [ { @@ -104,7 +109,7 @@ A received chain is accepted only if every rule holds; otherwise it is dropped w 1. **Block 0** is `ISSUE`: `sequence == 0`, `parent_hash == 0...0`, `metadata_hash` matches the metadata, signature verifies under `debtor_pub_key`. Debtor and creditor may be the same key: a blank promise (section 3). Initial state: holder = creditor, status = ACTIVE. 2. **For each block n >= 1:** `sequence == n`; `parent_hash` equals the hash of block n-1; no block may follow a `REDEEMED`; the signature verifies under the block's designated signer, **and is in the one accepted encoding** - minimal DER, low _s_ (section 2.4). 3. **Authorization matrix:** - - `TRANSFER`: status is ACTIVE; `transferor_pub_key` is the current holder; transferee != transferor. Holder becomes transferee. With an `agreement`, section 2.6 applies on top of those three rules. + - `TRANSFER`: status is ACTIVE; `transferor_pub_key` is the current holder; transferee != transferor; if the metadata is `non_transferable`, the holder or the transferee is the debtor. Holder becomes transferee. With an `agreement`, section 2.6 applies on top of those rules. - `REDEEMED`: status is ACTIVE, `debtor_pub_key` is the OwU's debtor, **and the debtor is the current holder** - a promise is closed by its maker, once it is back in their hands. Status becomes REDEEMED. Timestamps are not validated against each other; device clocks are not trusted and they are for display only. @@ -162,7 +167,7 @@ Each side's `ious` are sorted by `iou_id` in the signed bytes, so both parties s 2. **Accept:** B, holding every OwU on the right, checks that each is still at the stated head and that B is the named holder, and signs the same core. 3. **Apply:** with both signatures present, a `TRANSFER` carrying the agreement is appended to _every_ chain named. On A's OwUs the block is signed by A, on B's by B - and the block's signature **is** that side's agreement signature, so once both have signed, either party can append every block. Because the agreement is what one signature has to commit to, such a block signs `signingBytes()` rather than its own payload; that is the only case where the two differ. -On top of the three `TRANSFER` rules, the verifier accepts an agreement-bearing block on chain Z only if: the agreement names Z on one side and not on the other; that side's holder is the block's `transferor_pub_key` and the other side's holder is its `transferee_pub_key`; Z's pinned `head_hash` in that side equals the block's `parent_hash`; the block's timestamp equals the agreement's; the other side is not empty; both signatures are present and verify; and the block's signature equals this side's agreement signature. +On top of the `TRANSFER` rules, the verifier accepts an agreement-bearing block on chain Z only if: the agreement names Z on one side and not on the other; that side's holder is the block's `transferor_pub_key` and the other side's holder is its `transferee_pub_key`; Z's pinned `head_hash` in that side equals the block's `parent_hash`; the block's timestamp equals the agreement's; the other side is not empty; both signatures are present and verify; and the block's signature equals this side's agreement signature. Pinning **every** OwU to a head hash is what makes the bundle one deal: if any of them moves first the agreement is void, and no partial swap can land.