5 Commits
13 changed files with 118 additions and 11 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ dependencies {
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:2.4.20'
implementation 'androidx.compose.ui:ui'
@@ -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
@@ -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))
}
@@ -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*
@@ -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
@@ -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())))
}
@@ -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)
@@ -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")
@@ -216,7 +216,9 @@ class PeerEngine(
*/
fun put(held: List<Iou> = emptyList(), mint: List<Metadata> = 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<Iou>) {
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
+4
View File
@@ -109,6 +109,10 @@
<string name="window_until">until %1$s</string>
<string name="window_expired">expired</string>
<string name="issue_bad_window">The window closes before it opens.</string>
<string name="field_transfer">Non-transferable</string>
<string name="field_transfer_desc">Whoever you give it to can only redeem it, not pass it on.</string>
<string name="non_transferable">not transferable</string>
<string name="non_transferable_desc">Can be redeemed, not passed on: once given, it only goes back to whoever wrote it.</string>
<string name="role_debtor">Owed by</string>
<string name="role_holder">Held by</string>
<string name="issue_need_title">Say what is owed.</string>
@@ -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<LedgerException> { 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<LedgerException> { 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"))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '9.3.3' apply false
id 'com.android.library' version '9.3.3' apply false
id 'com.android.library' version '9.4.1' apply false
id 'org.jetbrains.kotlin.android' version '2.4.20' apply false
}
+8 -3
View File
@@ -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.