Non-transferable OWUs

This commit is contained in:
2026-09-28 08:41:31 +02:00
parent 564feaf0f4
commit 0d8a1a88a7
11 changed files with 116 additions and 9 deletions
@@ -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>