diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 23c5ad8..5d18310 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -21,6 +21,7 @@ export 'src/social/nostr/nostr_sync_transport.dart'; export 'src/social/nostr/nostr_connection.dart'; export 'src/social/nostr/nostr_message_transport.dart'; export 'src/social/nostr/nostr_offer_transport.dart'; +export 'src/social/nostr/nostr_plantare_transport.dart'; export 'src/social/nostr/nostr_profile_transport.dart'; export 'src/social/nostr/nostr_rating_transport.dart'; export 'src/social/nostr/nostr_report_transport.dart'; @@ -28,6 +29,8 @@ export 'src/social/nostr/nostr_trust_transport.dart'; export 'src/social/nostr/relay_pool.dart'; export 'src/social/offer.dart'; export 'src/social/offer_transport.dart'; +export 'src/social/plantare.dart'; +export 'src/social/plantare_transport.dart'; export 'src/social/profile_transport.dart'; export 'src/social/rating.dart'; export 'src/social/rating_transport.dart'; diff --git a/packages/commons_core/lib/src/social/nostr/nostr_message_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_message_transport.dart index 2d5389a..67e0614 100644 --- a/packages/commons_core/lib/src/social/nostr/nostr_message_transport.dart +++ b/packages/commons_core/lib/src/social/nostr/nostr_message_transport.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'package:nostr/nostr.dart' as nostr; import '../message_transport.dart'; import 'nostr_channel.dart'; +import 'nostr_plantare_transport.dart'; /// NIP-17 private-DM backend for [MessageTransport], on the shared /// [NostrConnection]. The gift-wrap onion (rumor kind 14 → seal kind 13 → wrap @@ -53,6 +55,7 @@ class NostrMessageTransport implements MessageTransport { giftWrap: wrap, recipientSecretKey: _conn.privateKeyHex, ); + if (_isPlantare(rumor.content)) return null; // handled by PlantareTransport return PrivateMessage( fromPubkey: rumor.pubkey, text: rumor.content, @@ -63,6 +66,19 @@ class NostrMessageTransport implements MessageTransport { } } + /// A Plantaré move gift-wrapped on the SAME kind-1059 channel is not chat — + /// it's a tagged JSON payload handled by [NostrPlantareTransport]. Skip it so + /// it never surfaces as a message. + static bool _isPlantare(String content) { + try { + final decoded = jsonDecode(content); + return decoded is Map && + decoded['t'] == NostrPlantareTransport.wireTag; + } catch (_) { + return false; // ordinary text + } + } + @override Future close() => _conn.close(); } diff --git a/packages/commons_core/lib/src/social/nostr/nostr_plantare_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_plantare_transport.dart new file mode 100644 index 0000000..b733c3a --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr/nostr_plantare_transport.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../plantare.dart'; +import '../plantare_transport.dart'; +import 'nostr_channel.dart'; + +/// NIP-17 private-DM backend for [PlantareTransport], on the shared channel. +/// +/// A Plantaré move rides the same gift-wrapped, NIP-44-encrypted envelope as a +/// DM (rumor kind 14 → seal kind 13 → wrap kind 1059 signed by an ephemeral +/// key), carrying a canonical JSON payload instead of chat text. This is the +/// least new surface — it reuses the vetted onion and the existing connection — +/// and gives the same metadata privacy (the relay sees only ciphertext and an +/// ephemeral author). The payload is tagged [wireTag] so a plain-chat inbox can +/// tell the two apart and skip these (and vice-versa). +class NostrPlantareTransport implements PlantareTransport { + NostrPlantareTransport(this._conn); + + final NostrChannel _conn; + + static const kindGiftWrap = 1059; + + /// Discriminator on the encrypted payload so a co-tenant chat inbox on the + /// same kind-1059 channel can ignore Plantaré moves. + static const wireTag = 'plantare/v1'; + + @override + Future propose(PlantarePledge pledge) => + _sendPledge(PlantareMessageKind.proposed, pledge); + + @override + Future accept(PlantarePledge pledge) => + _sendPledge(PlantareMessageKind.accepted, pledge); + + @override + Future decline({ + required String toPubkey, + required String pledgeId, + String reason = '', + }) => + _sendTo(toPubkey, { + 't': wireTag, + 'kind': PlantareMessageKind.declined.name, + 'pledgeId': pledgeId, + 'reason': reason, + }); + + @override + Stream incoming() => _conn + .subscribe( + nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]), + ) + .asyncMap(_unwrap) + .where((e) => e != null) + .cast(); + + /// Collects the incoming Plantaré moves up to EOSE (tests/one-shot). + Future> incomingUntilEose() async { + final wraps = await _conn.reqOnce( + nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]), + ); + final out = []; + for (final w in wraps) { + final e = await _unwrap(w); + if (e != null) out.add(e); + } + return out; + } + + Future _sendPledge( + PlantareMessageKind kind, + PlantarePledge pledge, + ) async { + final to = pledge.counterpartyOf(_conn.publicKeyHex); + if (to == null) { + throw ArgumentError('this identity is neither party to the pledge'); + } + await _sendTo(to, { + 't': wireTag, + 'kind': kind.name, + 'pledge': pledge.toJson(), + }); + } + + Future _sendTo(String toPubkey, Map payload) async { + final wrap = await nostr.DirectMessage.create( + message: jsonEncode(payload), + authorSecretKey: _conn.privateKeyHex, + recipientPubkey: toPubkey, + ); + final r = await _conn.publish(wrap); + if (!r.accepted) throw StateError('relay rejected plantare: ${r.message}'); + } + + Future _unwrap(nostr.Event wrap) async { + final nostr.Event rumor; + try { + rumor = await nostr.DirectMessage.parse( + giftWrap: wrap, + recipientSecretKey: _conn.privateKeyHex, + ); + } catch (_) { + return null; // not for us / wrong key / tampered + } + final Object? decoded; + try { + decoded = jsonDecode(rumor.content); + } catch (_) { + return null; // plain chat, not JSON — not ours + } + if (decoded is! Map || decoded['t'] != wireTag) return null; + try { + final kind = PlantareMessageKind.values.byName(decoded['kind'] as String); + switch (kind) { + case PlantareMessageKind.proposed: + case PlantareMessageKind.accepted: + return PlantareEnvelope( + kind: kind, + fromPubkey: rumor.pubkey, + pledge: + PlantarePledge.fromJson((decoded['pledge'] as Map).cast()), + ); + case PlantareMessageKind.declined: + return PlantareEnvelope( + kind: kind, + fromPubkey: rumor.pubkey, + pledgeId: decoded['pledgeId'] as String?, + reason: (decoded['reason'] as String?) ?? '', + ); + } + } catch (_) { + return null; // malformed payload + } + } + + @override + Future close() => _conn.close(); +} diff --git a/packages/commons_core/lib/src/social/plantare.dart b/packages/commons_core/lib/src/social/plantare.dart new file mode 100644 index 0000000..c9579a7 --- /dev/null +++ b/packages/commons_core/lib/src/social/plantare.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:equatable/equatable.dart'; +import 'package:nostr/nostr.dart' show Schnorr; + +/// How a Plantaré is promised back, mirroring the paper form's checkboxes +/// (BAH-Semillero v4.0). [similar] means the default: open-pollinated · non-GMO +/// · organically grown — surface that meaning, not just the word. [workHours] is +/// time-as-currency ("un trabajo de N horas"). [other] is anything else, carried +/// in the free-text description. Generic core type — no seed specifics beyond the +/// name. +enum ReturnKind { similar, workHours, other } + +/// The three moves of the bilateral handshake: a proposer signs and [proposed], +/// the counterparty counter-signs and [accepted], or [declined] it. +enum PlantareMessageKind { proposed, accepted, declined } + +/// One bilateral Plantaré — the digital form of the paper's two matching signed +/// stubs ("partida doble"). Both parties end up holding the *same* record, each +/// signed by both, so either can prove "recibí X, devolveré una cantidad similar +/// antes de la fecha Y". +/// +/// The debtor received the seed and owes a return; the creditor gave it. Which +/// side is which is carried by [debtorKey]/[creditorKey], so no separate +/// direction field is needed. The two signatures cover the *immutable core* — +/// everything except the signatures themselves ([canonicalString]) — so any edit +/// after proposing changes the [pledgeId] and forces a fresh proposal (accept is +/// over an immutable hash). +/// +/// Generic core type: it knows keys, a human seed [label] and a promise; it does +/// NOT know the app's local `Variety`/`Movement` ids (those live app-side). +class PlantarePledge extends Equatable { + const PlantarePledge({ + required this.pledgeId, + required this.debtorKey, + required this.creditorKey, + required this.madeOn, + this.label, + this.owedDescription, + this.returnKind = ReturnKind.similar, + this.workHours, + this.dueBy, + this.debtorSignature, + this.creditorSignature, + }); + + /// Stable id (proposer's UUIDv7). Part of the signed core, so it also pins the + /// exact terms: a changed pledge is a different id. + final String pledgeId; + + /// Pubkey (hex) of who received the seed and owes a return. + final String debtorKey; + + /// Pubkey (hex) of who gave the seed. + final String creditorKey; + + /// When the promise was made. + final DateTime madeOn; + + /// Human name of the seed the promise is about (the counterparty may not have + /// it catalogued, so we carry the label, not a local id). Optional. + final String? label; + + /// What's promised back in the grower's own words — backs [ReturnKind.other] + /// and enriches the others. + final String? owedDescription; + + final ReturnKind returnKind; + + /// Hours for [ReturnKind.workHours] (time-as-currency). Null otherwise. + final double? workHours; + + /// Optional return-by date — a gentle reminder matched to cultivation cycles, + /// not a deadline. + final DateTime? dueBy; + + /// Schnorr signature (hex) of the debtor over [signingHash]. Null until signed. + final String? debtorSignature; + + /// Schnorr signature (hex) of the creditor over [signingHash]. Null until + /// signed. + final String? creditorSignature; + + /// Whether both parties have signed — a fully-closed, provable deal. + bool get isFullySigned => + debtorSignature != null && creditorSignature != null; + + /// The other party to [me] (whichever key isn't mine), or null if [me] is + /// neither — used by the transport to route without a separate recipient arg. + String? counterpartyOf(String me) { + if (me == debtorKey) return creditorKey; + if (me == creditorKey) return debtorKey; + return null; + } + + /// The immutable core, serialized deterministically (sorted keys, no + /// signatures). Both parties sign the hash of exactly this string, so it must + /// be byte-stable across devices and versions. + String canonicalString() => jsonEncode({ + 'v': 1, + 'pledgeId': pledgeId, + 'debtorKey': debtorKey, + 'creditorKey': creditorKey, + 'madeOn': madeOn.millisecondsSinceEpoch, + 'label': label, + 'owedDescription': owedDescription, + 'returnKind': returnKind.name, + 'workHours': workHours, + 'dueBy': dueBy?.millisecondsSinceEpoch, + }); + + PlantarePledge copyWith({ + String? debtorSignature, + String? creditorSignature, + }) => + PlantarePledge( + pledgeId: pledgeId, + debtorKey: debtorKey, + creditorKey: creditorKey, + madeOn: madeOn, + label: label, + owedDescription: owedDescription, + returnKind: returnKind, + workHours: workHours, + dueBy: dueBy, + debtorSignature: debtorSignature ?? this.debtorSignature, + creditorSignature: creditorSignature ?? this.creditorSignature, + ); + + /// Full wire form (the canonical core plus whatever signatures exist so far). + Map toJson() => { + 'pledgeId': pledgeId, + 'debtorKey': debtorKey, + 'creditorKey': creditorKey, + 'madeOn': madeOn.millisecondsSinceEpoch, + 'label': label, + 'owedDescription': owedDescription, + 'returnKind': returnKind.name, + 'workHours': workHours, + 'dueBy': dueBy?.millisecondsSinceEpoch, + 'debtorSignature': debtorSignature, + 'creditorSignature': creditorSignature, + }; + + static PlantarePledge fromJson(Map json) => PlantarePledge( + pledgeId: json['pledgeId']! as String, + debtorKey: json['debtorKey']! as String, + creditorKey: json['creditorKey']! as String, + madeOn: + DateTime.fromMillisecondsSinceEpoch((json['madeOn']! as num).toInt()), + label: json['label'] as String?, + owedDescription: json['owedDescription'] as String?, + returnKind: ReturnKind.values.byName(json['returnKind'] as String), + workHours: (json['workHours'] as num?)?.toDouble(), + dueBy: json['dueBy'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch((json['dueBy']! as num).toInt()), + debtorSignature: json['debtorSignature'] as String?, + creditorSignature: json['creditorSignature'] as String?, + ); + + @override + List get props => [ + pledgeId, + debtorKey, + creditorKey, + madeOn, + label, + owedDescription, + returnKind, + workHours, + dueBy, + debtorSignature, + creditorSignature, + ]; +} + +/// Signing/verification over a [PlantarePledge]'s canonical core. Each signature +/// is a BIP-340 Schnorr signature (the same primitive Nostr uses for events) +/// over the SHA-256 of [PlantarePledge.canonicalString], so a Nostr pubkey both +/// identifies a party and verifies their stub. +abstract final class PlantareCrypto { + /// The 32-byte (hex) message both parties sign: SHA-256 of the canonical core. + static Future signingHash(PlantarePledge pledge) async { + final digest = + await Sha256().hash(utf8.encode(pledge.canonicalString())); + return _toHex(digest.bytes); + } + + /// Signs [pledge]'s core with [secretKeyHex]; returns the hex signature. The + /// caller stores it on the debtor or creditor slot depending on which key it + /// belongs to. + static Future sign( + PlantarePledge pledge, + String secretKeyHex, + ) async => + Schnorr.sign(secretKey: secretKeyHex, message: await signingHash(pledge)); + + /// Verifies [signature] was made over [pledge]'s core by [publicKeyHex]. + static Future verify( + PlantarePledge pledge, + String publicKeyHex, + String signature, + ) async { + try { + return Schnorr.verify( + publicKey: publicKeyHex, + message: await signingHash(pledge), + signature: signature, + ); + } catch (_) { + return false; // malformed key/sig → not valid + } + } + + /// Whether both stubs verify against their respective keys — a provably closed + /// deal, the strong anchor for a rating. + static Future verifyBoth(PlantarePledge pledge) async { + final d = pledge.debtorSignature; + final c = pledge.creditorSignature; + if (d == null || c == null) return false; + return await verify(pledge, pledge.debtorKey, d) && + await verify(pledge, pledge.creditorKey, c); + } + + static String _toHex(List bytes) { + final sb = StringBuffer(); + for (final b in bytes) { + sb.write(b.toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); + } +} + +/// One decrypted Plantaré move handed to the app: a [proposed] or [accepted] +/// pledge, or a [declined] notice. [fromPubkey] is the authenticated sender +/// (from the inner rumor, not the gift-wrap's ephemeral key). +class PlantareEnvelope extends Equatable { + const PlantareEnvelope({ + required this.kind, + required this.fromPubkey, + this.pledge, + this.pledgeId, + this.reason = '', + }); + + final PlantareMessageKind kind; + final String fromPubkey; + + /// The pledge for [PlantareMessageKind.proposed]/[accepted]; null for a + /// decline, which carries only [pledgeId] and [reason]. + final PlantarePledge? pledge; + + /// The declined pledge's id (decline carries no full pledge). + final String? pledgeId; + final String reason; + + /// The pledge id this envelope concerns, from whichever field holds it. + String? get id => pledge?.pledgeId ?? pledgeId; + + @override + List get props => [kind, fromPubkey, pledge, pledgeId, reason]; +} diff --git a/packages/commons_core/lib/src/social/plantare_transport.dart b/packages/commons_core/lib/src/social/plantare_transport.dart new file mode 100644 index 0000000..8f4d417 --- /dev/null +++ b/packages/commons_core/lib/src/social/plantare_transport.dart @@ -0,0 +1,37 @@ +import 'plantare.dart'; + +/// The bilateral-Plantaré contract over the shared connection: propose a signed +/// stub, counter-sign it, or decline. Unlike ratings/certifications (public +/// signed events), a Plantaré is PRIVATE to the two parties, so — like DMs and +/// sync — it is encrypted end-to-end. The recipient is inferred from the +/// pledge's debtor/creditor keys (whichever isn't this identity), so there is no +/// separate address argument. +/// +/// Handshake: propose → accept → counter-sign. A proposes (A's signature only, +/// encrypted to B); B reviews and accepts (adds B's signature), sending the +/// doubly-signed copy back; both persist the identical, fully-signed row. +/// +/// Channel-agnostic: the default backend rides the existing NIP-17 encrypted DM, +/// but the same canonical payload works device-to-device over QR/NFC (offline +/// must fully work; relays only enrich). +abstract interface class PlantareTransport { + /// Sends a signed proposal (my signature only) to the pledge's counterparty. + Future propose(PlantarePledge pledge); + + /// Counter-signs a received proposal and sends the fully-signed copy back. + Future accept(PlantarePledge pledge); + + /// Declines a received proposal, notifying its proposer [toPubkey], optionally + /// with a short [reason]. (A decline carries no pledge, so unlike propose/ + /// accept the recipient can't be inferred and is passed explicitly.) + Future decline({ + required String toPubkey, + required String pledgeId, + String reason, + }); + + /// Incoming proposals / accepts / declines addressed to this identity, live. + Stream incoming(); + + Future close(); +} diff --git a/packages/commons_core/test/social/nostr_plantare_transport_test.dart b/packages/commons_core/test/social/nostr_plantare_transport_test.dart new file mode 100644 index 0000000..e41614e --- /dev/null +++ b/packages/commons_core/test/social/nostr_plantare_transport_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +import '../support/mini_relay.dart'; + +/// The bilateral handshake over a hermetic in-process relay: A proposes a signed +/// stub encrypted to B, B counter-signs and returns the doubly-signed copy, and +/// the payload never leaks to the relay or into the plain-chat inbox. +void main() { + late MiniRelay relay; + setUp(() async => relay = await MiniRelay.start()); + tearDown(() async => relay.stop()); + + Future idFor(int fill) => NostrKeyDerivation.deriveFromSeed( + Uint8List(32)..fillRange(0, 32, fill), + ); + Future connFor(NostrIdentity id) => + NostrConnection.connect(relay.url, identity: id); + + PlantarePledge proposalFrom(NostrIdentity creditor, NostrIdentity debtor) => + PlantarePledge( + pledgeId: '018f-plantare-1', + debtorKey: debtor.publicKeyHex, + creditorKey: creditor.publicKeyHex, + madeOn: DateTime.fromMillisecondsSinceEpoch(1700000000000), + label: 'Tomate rosa de Barbastro', + owedDescription: 'un puñado la próxima temporada', + ); + + test('propose → accept: both hold the same doubly-signed pledge', () async { + // Creditor (gave the seed) proposes; debtor (owes a return) accepts. + final creditor = await idFor(1); + final debtor = await idFor(2); + final creditorT = NostrPlantareTransport(await connFor(creditor)); + final debtorT = NostrPlantareTransport(await connFor(debtor)); + + var pledge = proposalFrom(creditor, debtor); + pledge = pledge.copyWith( + creditorSignature: + await PlantareCrypto.sign(pledge, creditor.privateKeyHex), + ); + await creditorT.propose(pledge); + + // Debtor receives the proposal, verifies the creditor's stub, counter-signs. + final incoming = await debtorT.incomingUntilEose(); + expect(incoming, hasLength(1)); + expect(incoming.single.kind, PlantareMessageKind.proposed); + expect(incoming.single.fromPubkey, creditor.publicKeyHex); + final received = incoming.single.pledge!; + expect( + await PlantareCrypto.verify( + received, creditor.publicKeyHex, received.creditorSignature!), + isTrue); + + final countersigned = received.copyWith( + debtorSignature: await PlantareCrypto.sign(received, debtor.privateKeyHex), + ); + await debtorT.accept(countersigned); + + // Creditor gets the fully-signed copy back — a provably closed deal. + final back = await creditorT.incomingUntilEose(); + expect(back, hasLength(1)); + expect(back.single.kind, PlantareMessageKind.accepted); + expect(await PlantareCrypto.verifyBoth(back.single.pledge!), isTrue); + + await creditorT.close(); + await debtorT.close(); + }); + + test('decline notifies the proposer with a reason', () async { + final creditor = await idFor(1); + final debtor = await idFor(2); + final creditorT = NostrPlantareTransport(await connFor(creditor)); + final debtorT = NostrPlantareTransport(await connFor(debtor)); + + await debtorT.decline( + toPubkey: creditor.publicKeyHex, + pledgeId: '018f-plantare-1', + reason: 'ya no me caben', + ); + + final got = await creditorT.incomingUntilEose(); + expect(got, hasLength(1)); + expect(got.single.kind, PlantareMessageKind.declined); + expect(got.single.pledgeId, '018f-plantare-1'); + expect(got.single.reason, 'ya no me caben'); + expect(got.single.pledge, isNull); + + await creditorT.close(); + await debtorT.close(); + }); + + test('the relay sees only ciphertext, never the terms', () async { + final creditor = await idFor(1); + final debtor = await idFor(2); + final creditorT = NostrPlantareTransport(await connFor(creditor)); + + var pledge = proposalFrom(creditor, debtor); + pledge = pledge.copyWith( + creditorSignature: + await PlantareCrypto.sign(pledge, creditor.privateKeyHex), + ); + await creditorT.propose(pledge); + + final wire = jsonEncode(relay.eventsOfKind(1059)); + expect(wire, isNot(contains('Tomate rosa'))); + expect(wire, isNot(contains('plantare/v1'))); + // Gift-wrap author is ephemeral, not the creditor. + expect(relay.eventsOfKind(1059).single['pubkey'], + isNot(creditor.publicKeyHex)); + + await creditorT.close(); + }); + + test('a stranger cannot read a proposal addressed to someone else', () async { + final creditor = await idFor(1); + final debtor = await idFor(2); + final stranger = await idFor(9); + final creditorT = NostrPlantareTransport(await connFor(creditor)); + final strangerT = NostrPlantareTransport(await connFor(stranger)); + + var pledge = proposalFrom(creditor, debtor); + pledge = pledge.copyWith( + creditorSignature: + await PlantareCrypto.sign(pledge, creditor.privateKeyHex), + ); + await creditorT.propose(pledge); + + expect(await strangerT.incomingUntilEose(), isEmpty); + + await creditorT.close(); + await strangerT.close(); + }); + + test('plain chat and Plantaré moves do not bleed into each other', () async { + final creditor = await idFor(1); + final debtor = await idFor(2); + final creditorPlantare = NostrPlantareTransport(await connFor(creditor)); + final debtorPlantare = NostrPlantareTransport(await connFor(debtor)); + final creditorChat = NostrMessageTransport(await connFor(creditor)); + final debtorChat = NostrMessageTransport(await connFor(debtor)); + + // A Plantaré proposal… + var pledge = proposalFrom(creditor, debtor); + pledge = pledge.copyWith( + creditorSignature: + await PlantareCrypto.sign(pledge, creditor.privateKeyHex), + ); + await creditorPlantare.propose(pledge); + // …and an ordinary chat line, both on the same kind-1059 channel. + await creditorChat.send(toPubkey: debtor.publicKeyHex, text: 'hola!'); + + // The chat inbox sees only the chat line. + final chat = await debtorChat.inboxUntilEose(); + expect(chat, hasLength(1)); + expect(chat.single.text, 'hola!'); + + // The Plantaré inbox sees only the proposal. + final moves = await debtorPlantare.incomingUntilEose(); + expect(moves, hasLength(1)); + expect(moves.single.kind, PlantareMessageKind.proposed); + + await creditorPlantare.close(); + await debtorPlantare.close(); + await creditorChat.close(); + await debtorChat.close(); + }); +} diff --git a/packages/commons_core/test/social/plantare_test.dart b/packages/commons_core/test/social/plantare_test.dart new file mode 100644 index 0000000..504260e --- /dev/null +++ b/packages/commons_core/test/social/plantare_test.dart @@ -0,0 +1,94 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +/// The pure model + crypto of the bilateral Plantaré: a canonical, byte-stable +/// core; two Schnorr stubs that verify against their keys; and any edit changing +/// the signing hash (so an accept is over an immutable payload). +void main() { + Future idFor(int fill) => NostrKeyDerivation.deriveFromSeed( + Uint8List(32)..fillRange(0, 32, fill), + ); + + PlantarePledge pledgeFor(NostrIdentity debtor, NostrIdentity creditor) => + PlantarePledge( + pledgeId: '018f-plantare-1', + debtorKey: debtor.publicKeyHex, + creditorKey: creditor.publicKeyHex, + madeOn: DateTime.fromMillisecondsSinceEpoch(1700000000000), + label: 'Tomate rosa de Barbastro', + owedDescription: 'un puñado la próxima temporada', + dueBy: DateTime.fromMillisecondsSinceEpoch(1730000000000), + ); + + test('the canonical core is stable and excludes the signatures', () async { + final debtor = await idFor(1); + final creditor = await idFor(2); + final bare = pledgeFor(debtor, creditor); + final signed = bare.copyWith(debtorSignature: 'deadbeef' * 8); + + // Signing hash ignores the signatures — both sides sign the same message. + expect(await PlantareCrypto.signingHash(bare), + await PlantareCrypto.signingHash(signed)); + }); + + test('each stub verifies against its own key, not the other', () async { + final debtor = await idFor(1); + final creditor = await idFor(2); + final pledge = pledgeFor(debtor, creditor); + + final dSig = await PlantareCrypto.sign(pledge, debtor.privateKeyHex); + final cSig = await PlantareCrypto.sign(pledge, creditor.privateKeyHex); + + expect(await PlantareCrypto.verify(pledge, debtor.publicKeyHex, dSig), isTrue); + expect( + await PlantareCrypto.verify(pledge, creditor.publicKeyHex, cSig), isTrue); + // Cross-checking a stub against the wrong key fails. + expect( + await PlantareCrypto.verify(pledge, creditor.publicKeyHex, dSig), isFalse); + + final closed = + pledge.copyWith(debtorSignature: dSig, creditorSignature: cSig); + expect(await PlantareCrypto.verifyBoth(closed), isTrue); + }); + + test('editing the terms after signing invalidates the stub', () async { + final debtor = await idFor(1); + final creditor = await idFor(2); + final pledge = pledgeFor(debtor, creditor); + final dSig = await PlantareCrypto.sign(pledge, debtor.privateKeyHex); + + // Same object, different terms → different hash → old signature is void. + final edited = PlantarePledge( + pledgeId: pledge.pledgeId, + debtorKey: pledge.debtorKey, + creditorKey: pledge.creditorKey, + madeOn: pledge.madeOn, + label: pledge.label, + owedDescription: 'DOS puñados', // changed + ); + expect(await PlantareCrypto.verify(edited, debtor.publicKeyHex, dSig), isFalse); + }); + + test('wire JSON round-trips every field including signatures', () async { + final debtor = await idFor(1); + final creditor = await idFor(2); + final pledge = pledgeFor(debtor, creditor).copyWith( + debtorSignature: 'a' * 128, + creditorSignature: 'b' * 128, + ); + final back = PlantarePledge.fromJson(pledge.toJson()); + expect(back, pledge); + }); + + test('counterpartyOf resolves the other key, null for a stranger', () async { + final debtor = await idFor(1); + final creditor = await idFor(2); + final stranger = await idFor(9); + final pledge = pledgeFor(debtor, creditor); + expect(pledge.counterpartyOf(debtor.publicKeyHex), creditor.publicKeyHex); + expect(pledge.counterpartyOf(creditor.publicKeyHex), debtor.publicKeyHex); + expect(pledge.counterpartyOf(stranger.publicKeyHex), isNull); + }); +}