feat(plantare): PlantareTransport foundation in commons_core

The bilateral signed Plantaré's transport layer — the "formulario
bilateral firmado" from plantare-bilateral.md — as a new interface
alongside the other *Transport contracts, on the shared NostrConnection.

- PlantarePledge: the canonical, byte-stable core (debtor/creditor keys,
  seed label, return kind, dates) plus the two Schnorr stubs. Any edit
  changes the signing hash, so an accept is always over an immutable
  payload.
- PlantareCrypto: BIP-340 signing/verification over SHA-256 of the core,
  reusing the same primitive Nostr uses for events — a pubkey both
  identifies a party and verifies their stub.
- PlantareTransport + NostrPlantareTransport: propose → accept →
  counter-sign (and decline), private end-to-end. Rides the existing
  NIP-17 gift wrap carrying a tagged JSON payload, so the relay sees only
  ciphertext and an ephemeral author. Offline QR/NFC is the same payload
  without a relay.
- Message transport skips Plantaré payloads on the shared kind-1059
  channel (and vice-versa), so moves never surface as chat.

Tests: pure pledge/crypto + full handshake over a hermetic relay,
including no-leak and no-chat-bleed. commons_core 101/101 green.
This commit is contained in:
vjrj 2026-07-14 10:50:38 +02:00
parent 7ae6becd8f
commit 3f622f7bf7
7 changed files with 726 additions and 0 deletions

View file

@ -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';

View file

@ -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<void> close() => _conn.close();
}

View file

@ -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<void> propose(PlantarePledge pledge) =>
_sendPledge(PlantareMessageKind.proposed, pledge);
@override
Future<void> accept(PlantarePledge pledge) =>
_sendPledge(PlantareMessageKind.accepted, pledge);
@override
Future<void> decline({
required String toPubkey,
required String pledgeId,
String reason = '',
}) =>
_sendTo(toPubkey, <String, Object?>{
't': wireTag,
'kind': PlantareMessageKind.declined.name,
'pledgeId': pledgeId,
'reason': reason,
});
@override
Stream<PlantareEnvelope> incoming() => _conn
.subscribe(
nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]),
)
.asyncMap(_unwrap)
.where((e) => e != null)
.cast<PlantareEnvelope>();
/// Collects the incoming Plantaré moves up to EOSE (tests/one-shot).
Future<List<PlantareEnvelope>> incomingUntilEose() async {
final wraps = await _conn.reqOnce(
nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]),
);
final out = <PlantareEnvelope>[];
for (final w in wraps) {
final e = await _unwrap(w);
if (e != null) out.add(e);
}
return out;
}
Future<void> _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, <String, Object?>{
't': wireTag,
'kind': kind.name,
'pledge': pledge.toJson(),
});
}
Future<void> _sendTo(String toPubkey, Map<String, Object?> 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<PlantareEnvelope?> _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<void> close() => _conn.close();
}

View file

@ -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(<String, Object?>{
'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<String, Object?> toJson() => <String, Object?>{
'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<String, Object?> 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<Object?> 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<String> 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<String> 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<bool> 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<bool> 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<int> 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<Object?> get props => [kind, fromPubkey, pledge, pledgeId, reason];
}

View file

@ -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<void> propose(PlantarePledge pledge);
/// Counter-signs a received proposal and sends the fully-signed copy back.
Future<void> 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<void> decline({
required String toPubkey,
required String pledgeId,
String reason,
});
/// Incoming proposals / accepts / declines addressed to this identity, live.
Stream<PlantareEnvelope> incoming();
Future<void> close();
}

View file

@ -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<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
Uint8List(32)..fillRange(0, 32, fill),
);
Future<NostrConnection> 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();
});
}

View file

@ -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<NostrIdentity> 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);
});
}