Non-transferable OWUs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user