From b607ddde41f8670a119b289080c65615a757a956 Mon Sep 17 00:00:00 2001 From: vjrj Date: Tue, 14 Jul 2026 11:07:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(plantare):=20PlantareService=20=E2=80=94?= =?UTF-8?q?=20orchestrate=20the=20signed=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-layer driver that turns local intent into a signed proposal, counter-signs or declines incoming ones, and reconciles every move into the local ledger. Cryptography and wire format stay in commons_core; this owns persistence and orchestration. - propose(): builds + self-signs a PlantarePledge, records the local row (remoteState=proposed, my stub) and sends it. Direction maps to debtor/creditor: iReturn = I received and owe, owedToMe = I gave. - accept(): counter-signs the exact in-hand proposal (verified) and sends the doubly-signed copy back, closing the row. - decline(): notifies the proposer and marks the row declined. - ingest(): verifies stubs before storing — a bad-signature proposal or a half-signed "accept" is dropped; proposals not addressed to me are ignored. Pending proposals held in memory keyed by pledge id (relay redelivers on reconnect), so accept signs the verified payload. Wired into DI + Bootstrap alongside the inbox/sync listeners and torn down/rebuilt on identity switch. SocialSession now exposes the transport. Tests: full propose→accept close, decline, and the three drop paths with a records-only transport + real repos. services/data green. --- apps/app_seeds/lib/bootstrap.dart | 9 +- apps/app_seeds/lib/di/injector.dart | 26 +- .../lib/services/plantare_service.dart | 281 ++++++++++++++++++ .../lib/services/social_service.dart | 5 + .../test/services/plantare_service_test.dart | 211 +++++++++++++ 5 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 apps/app_seeds/lib/services/plantare_service.dart create mode 100644 apps/app_seeds/test/services/plantare_service_test.dart diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index f6b5050..81aa9d9 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -16,6 +16,7 @@ import 'services/message_store.dart'; import 'services/notification_service.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; +import 'services/plantare_service.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; @@ -67,10 +68,14 @@ class _BootstrapState extends State { if (notifications != null) await notifications.initialize(); final sync = getIt.isRegistered() ? getIt() : null; - // Subscribe the inbox + sync listeners BEFORE the shared connection starts - // connecting, so the first session is caught; then bring the connection up. + final plantares = + getIt.isRegistered() ? getIt() : null; + // Subscribe the inbox + sync + plantaré listeners BEFORE the shared + // connection starts connecting, so the first session is caught; then bring + // the connection up. inbox?.start(); sync?.start(); + plantares?.start(); connection?.start(); return TaneApp( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 0bf659d..b88976f 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -37,6 +37,7 @@ import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; import '../services/message_store.dart'; import '../services/offer_outbox.dart'; +import '../services/plantare_service.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/saved_offers_store.dart'; @@ -261,6 +262,17 @@ Future configureDependencies() async { localChanges: database.tableUpdates().map((_) {}), selfDeviceId: deviceId, ), + ) + // Drives the bilateral signed Plantaré handshake over the shared + // connection, reconciling moves into the local ledger. Started in + // `Bootstrap`. + ..registerSingleton( + PlantareService( + repo: varietyRepository, + selfPubkey: socialService.publicKeyHex, + selfSecretKey: socialService.identity.privateKeyHex, + connection: connection, + ), ); } @@ -293,6 +305,10 @@ Future switchSocialAccount(int account) async { await getIt().stop(); await getIt.unregister(); } + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } if (getIt.isRegistered()) { await getIt().dispose(); await getIt.unregister(); @@ -351,14 +367,22 @@ Future switchSocialAccount(int account) async { localChanges: getIt().tableUpdates().map((_) {}), selfDeviceId: deviceId, ); + final plantares = PlantareService( + repo: getIt(), + selfPubkey: social.publicKeyHex, + selfSecretKey: social.identity.privateKeyHex, + connection: connection, + ); getIt ..registerSingleton(social) ..registerSingleton(connection) ..registerSingleton(inbox) - ..registerSingleton(sync); + ..registerSingleton(sync) + ..registerSingleton(plantares); // Subscribe the listeners BEFORE the connection starts connecting. inbox.start(); sync.start(); + plantares.start(); connection.start(); } diff --git a/apps/app_seeds/lib/services/plantare_service.dart b/apps/app_seeds/lib/services/plantare_service.dart new file mode 100644 index 0000000..c8dbd5a --- /dev/null +++ b/apps/app_seeds/lib/services/plantare_service.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import 'social_connection.dart'; +import 'social_service.dart' show SocialSession; + +/// Drives the bilateral SIGNED Plantaré (plantare-bilateral.md) at the app +/// layer: turns local intent into a signed proposal, counter-signs or declines +/// incoming ones, and reconciles every move into the local ledger +/// ([VarietyRepository]). The cryptography and wire format live in +/// `commons_core`; this class owns the app-side orchestration and persistence. +/// +/// Local-first: a proposal is recorded locally the moment it's made, and sent +/// over the shared connection when online (best-effort — a durable outbox/retry +/// is a follow-up). Incoming proposals are held in memory keyed by pledge id so +/// an accept counter-signs the exact, verified payload; the relay redelivers +/// them on reconnect (NIP-17), so this survives an app restart without a +/// separate store. +class PlantareService { + PlantareService({ + required VarietyRepository repo, + required String selfPubkey, + required String selfSecretKey, + SocialConnection? connection, + }) : _repo = repo, + _self = selfPubkey, + _secret = selfSecretKey, + _connection = connection; + + final VarietyRepository _repo; + final String _self; + final String _secret; + final SocialConnection? _connection; + + final _changes = StreamController.broadcast(); + final _pending = {}; + + StreamSubscription? _sessionsSub; + StreamSubscription? _incomingSub; + PlantareTransport? _transport; + bool _started = false; + + /// Fires (no payload) after an incoming move is reconciled — the UI reloads. + Stream get changes => _changes.stream; + + /// The proposals received and awaiting this identity's decision, keyed by + /// pledge id (in-memory; refilled from the relay on reconnect). + Map get pendingProposals => + Map.unmodifiable(_pending); + + /// Begins listening for incoming moves over the shared connection. + void start() { + if (_started || _connection == null) return; + _started = true; + _sessionsSub = _connection.sessions.listen(_onSession); + final current = _connection.current; + if (current != null) _onSession(current); + } + + void _onSession(SocialSession? session) { + unawaited(_incomingSub?.cancel()); + _incomingSub = null; + _transport = session?.plantares; + final incoming = session?.plantares.incoming(); + if (incoming != null) { + _incomingSub = incoming.listen(ingest, onError: (_) {}); + } + } + + /// Test seam: bind a transport without a live connection. + @visibleForTesting + void bindTransport(PlantareTransport transport) => _transport = transport; + + /// Proposes a Plantaré to [counterpartyKey]. [direction] is this identity's + /// side: [PlantareDirection.iReturn] = I received and owe (I'm the debtor); + /// [PlantareDirection.owedToMe] = I gave (I'm the creditor). Records the local + /// row immediately (remoteState=proposed, my stub) and sends the signed + /// proposal. Returns the shared pledge id. + Future propose({ + required PlantareDirection direction, + required String counterpartyKey, + String? varietyId, + required String label, + String? counterpartyName, + String? owedDescription, + DateTime? dueBy, + PlantareReturnKind returnKind = PlantareReturnKind.similar, + double? workHours, + String? movementId, + }) async { + final iAmDebtor = direction == PlantareDirection.iReturn; + final now = DateTime.now(); + final pledgeId = _repo.idGen.newId(); + var pledge = PlantarePledge( + pledgeId: pledgeId, + debtorKey: iAmDebtor ? _self : counterpartyKey, + creditorKey: iAmDebtor ? counterpartyKey : _self, + madeOn: now, + label: label, + owedDescription: owedDescription, + returnKind: _toCoreReturnKind(returnKind), + workHours: workHours, + dueBy: dueBy, + ); + final mySig = await PlantareCrypto.sign(pledge, _secret); + pledge = pledge.copyWith( + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + ); + + await _repo.createPlantare( + id: pledgeId, + direction: direction, + varietyId: varietyId, + counterparty: counterpartyName ?? counterpartyKey, + owedDescription: owedDescription, + madeOn: now.millisecondsSinceEpoch, + dueBy: dueBy?.millisecondsSinceEpoch, + pledgeId: pledgeId, + debtorKey: pledge.debtorKey, + creditorKey: pledge.creditorKey, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + movementId: movementId, + remoteState: PlantareRemoteState.proposed, + returnKind: returnKind, + workHours: workHours, + ); + + await _transport?.propose(pledge); + return pledgeId; + } + + /// Counter-signs a received proposal [pledgeId] and sends the fully-signed + /// copy back, flipping the local row to accepted. Throws if the proposal isn't + /// in hand (e.g. never received, or the relay hasn't redelivered it yet). + Future accept(String pledgeId) async { + final proposed = _pending[pledgeId]; + if (proposed == null) { + throw StateError('no pending proposal $pledgeId to accept'); + } + final iAmDebtor = proposed.debtorKey == _self; + final mySig = await PlantareCrypto.sign(proposed, _secret); + final full = proposed.copyWith( + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + ); + await _transport?.accept(full); + await _repo.applyPlantareSignatures( + pledgeId: pledgeId, + debtorSignature: iAmDebtor ? mySig : null, + creditorSignature: iAmDebtor ? null : mySig, + remoteState: PlantareRemoteState.accepted, + ); + _pending.remove(pledgeId); + _emit(); + } + + /// Declines a received proposal [pledgeId], notifying its proposer. + Future decline(String pledgeId, {String reason = ''}) async { + final proposed = _pending[pledgeId]; + final proposer = proposed?.counterpartyOf(_self); + if (proposer != null) { + await _transport?.decline( + toPubkey: proposer, + pledgeId: pledgeId, + reason: reason, + ); + } + await _repo.setPlantareRemoteState(pledgeId, PlantareRemoteState.declined); + _pending.remove(pledgeId); + _emit(); + } + + /// Reconciles one incoming move into the local ledger. Testable seam (no + /// relay). Unverifiable/tampered moves are dropped. + @visibleForTesting + Future ingest(PlantareEnvelope envelope) async { + switch (envelope.kind) { + case PlantareMessageKind.proposed: + await _ingestProposed(envelope); + case PlantareMessageKind.accepted: + await _ingestAccepted(envelope); + case PlantareMessageKind.declined: + await _ingestDeclined(envelope); + } + } + + Future _ingestProposed(PlantareEnvelope envelope) async { + final pledge = envelope.pledge; + if (pledge == null) return; + // The proposer signs their own stub; verify it before storing. + final proposerIsDebtor = pledge.debtorKey == envelope.fromPubkey; + final theirSig = + proposerIsDebtor ? pledge.debtorSignature : pledge.creditorSignature; + if (theirSig == null || + !await PlantareCrypto.verify(pledge, envelope.fromPubkey, theirSig)) { + return; // unsigned or tampered — drop + } + // My side of the deal is the mirror of the proposer's. + final iAmDebtor = pledge.debtorKey == _self; + if (!iAmDebtor && pledge.creditorKey != _self) return; // not addressed to me + + _pending[pledge.pledgeId] = pledge; + if (await _repo.plantareByPledgeId(pledge.pledgeId) == null) { + await _repo.createPlantare( + direction: + iAmDebtor ? PlantareDirection.iReturn : PlantareDirection.owedToMe, + counterparty: envelope.fromPubkey, + owedDescription: pledge.owedDescription, + madeOn: pledge.madeOn.millisecondsSinceEpoch, + dueBy: pledge.dueBy?.millisecondsSinceEpoch, + pledgeId: pledge.pledgeId, + debtorKey: pledge.debtorKey, + creditorKey: pledge.creditorKey, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + remoteState: PlantareRemoteState.proposed, + returnKind: _fromCoreReturnKind(pledge.returnKind), + workHours: pledge.workHours, + ); + } + _emit(); + } + + Future _ingestAccepted(PlantareEnvelope envelope) async { + final pledge = envelope.pledge; + if (pledge == null) return; + // An accept must carry BOTH valid stubs — a provably closed deal. + if (!await PlantareCrypto.verifyBoth(pledge)) return; + if (pledge.debtorKey != _self && pledge.creditorKey != _self) return; + if (await _repo.plantareByPledgeId(pledge.pledgeId) == null) return; + await _repo.applyPlantareSignatures( + pledgeId: pledge.pledgeId, + debtorSignature: pledge.debtorSignature, + creditorSignature: pledge.creditorSignature, + remoteState: PlantareRemoteState.accepted, + ); + _pending.remove(pledge.pledgeId); + _emit(); + } + + Future _ingestDeclined(PlantareEnvelope envelope) async { + final id = envelope.pledgeId; + if (id == null) return; + if (await _repo.plantareByPledgeId(id) == null) return; + await _repo.setPlantareRemoteState(id, PlantareRemoteState.declined); + _pending.remove(id); + _emit(); + } + + void _emit() { + if (!_changes.isClosed) _changes.add(null); + } + + Future stop() async { + _started = false; + await _sessionsSub?.cancel(); + _sessionsSub = null; + await _incomingSub?.cancel(); + _incomingSub = null; + _transport = null; + if (!_changes.isClosed) await _changes.close(); + } + + static ReturnKind _toCoreReturnKind(PlantareReturnKind k) => switch (k) { + PlantareReturnKind.similar => ReturnKind.similar, + PlantareReturnKind.workHours => ReturnKind.workHours, + PlantareReturnKind.other => ReturnKind.other, + }; + + static PlantareReturnKind _fromCoreReturnKind(ReturnKind k) => switch (k) { + ReturnKind.similar => PlantareReturnKind.similar, + ReturnKind.workHours => PlantareReturnKind.workHours, + ReturnKind.other => PlantareReturnKind.other, + }; +} diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index 339be8c..f258fc5 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -99,6 +99,7 @@ class SocialSession { ratings = NostrRatingTransport(_channel), reports = NostrReportTransport(_channel), profile = NostrProfileTransport(_channel), + plantares = NostrPlantareTransport(_channel), sync = NostrSyncTransport( _channel, namespace: kInventorySyncNamespace, @@ -114,6 +115,10 @@ class SocialSession { final ReportTransport reports; final ProfileTransport profile; + /// Bilateral signed Plantaré handshake (propose/accept/decline), private + /// end-to-end over the shared connection. + final PlantareTransport plantares; + /// Device-to-device inventory sync for THIS identity's own devices. final SyncTransport sync; diff --git a/apps/app_seeds/test/services/plantare_service_test.dart b/apps/app_seeds/test/services/plantare_service_test.dart new file mode 100644 index 0000000..4b69b67 --- /dev/null +++ b/apps/app_seeds/test/services/plantare_service_test.dart @@ -0,0 +1,211 @@ +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/services/plantare_service.dart'; + +import '../support/test_support.dart'; + +/// A records-only transport: captures what a service sends so the test can hand +/// the resulting move to the other party's [PlantareService.ingest]. No relay. +class _CapturingTransport implements PlantareTransport { + final proposed = []; + final accepted = []; + final declined = <({String toPubkey, String pledgeId, String reason})>[]; + + @override + Future propose(PlantarePledge pledge) async => proposed.add(pledge); + @override + Future accept(PlantarePledge pledge) async => accepted.add(pledge); + @override + Future decline({ + required String toPubkey, + required String pledgeId, + String reason = '', + }) async => + declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason)); + @override + Stream incoming() => const Stream.empty(); + @override + Future close() async {} +} + +void main() { + Future idFor(int fill) => NostrKeyDerivation.deriveFromSeed( + Uint8List(32)..fillRange(0, 32, fill), + ); + + late AppDatabase dbA; + late AppDatabase dbB; + late VarietyRepository repoA; + late VarietyRepository repoB; + late NostrIdentity creditor; // gives seed, proposes + late NostrIdentity debtor; // receives seed, owes a return + late PlantareService svcA; // creditor's service + late PlantareService svcB; // debtor's service + late _CapturingTransport txA; + late _CapturingTransport txB; + + setUp(() async { + dbA = newTestDatabase(); + dbB = newTestDatabase(); + repoA = newTestRepository(dbA); + repoB = newTestRepository(dbB); + creditor = await idFor(1); + debtor = await idFor(2); + txA = _CapturingTransport(); + txB = _CapturingTransport(); + svcA = PlantareService( + repo: repoA, + selfPubkey: creditor.publicKeyHex, + selfSecretKey: creditor.privateKeyHex, + )..bindTransport(txA); + svcB = PlantareService( + repo: repoB, + selfPubkey: debtor.publicKeyHex, + selfSecretKey: debtor.privateKeyHex, + )..bindTransport(txB); + }); + + tearDown(() async { + await svcA.stop(); + await svcB.stop(); + await dbA.close(); + await dbB.close(); + }); + + PlantareEnvelope envelope( + PlantareMessageKind kind, + String from, + PlantarePledge pledge, + ) => + PlantareEnvelope(kind: kind, fromPubkey: from, pledge: pledge); + + test('full handshake: propose → accept, both end fully signed & accepted', + () async { + // Creditor proposes (their view: owedToMe). + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + counterpartyName: 'Ana', + label: 'Tomate rosa', + owedDescription: 'un puñado', + ); + expect(txA.proposed, hasLength(1)); + + // Creditor's local row: proposed, creditor-signed only. + final aRow = await repoA.plantareByPledgeId(pledgeId); + expect(aRow!.remoteState, PlantareRemoteState.proposed); + expect(aRow.creditorSignature, isNotNull); + expect(aRow.debtorSignature, isNull); + + // Debtor receives the proposal. + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + final bRow = await repoB.plantareByPledgeId(pledgeId); + expect(bRow, isNotNull); + expect(bRow!.direction, PlantareDirection.iReturn); // mirror view + expect(bRow.remoteState, PlantareRemoteState.proposed); + expect(svcB.pendingProposals, contains(pledgeId)); + + // Debtor accepts → counter-signs and sends back. + await svcB.accept(pledgeId); + expect(txB.accepted, hasLength(1)); + final closed = txB.accepted.single; + expect(await PlantareCrypto.verifyBoth(closed), isTrue); + final bAfter = await repoB.plantareByPledgeId(pledgeId); + expect(bAfter!.remoteState, PlantareRemoteState.accepted); + expect(bAfter.debtorSignature, isNotNull); + expect(svcB.pendingProposals, isEmpty); + + // Creditor receives the accept → their row closes too. + await svcA.ingest( + envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, closed), + ); + final aAfter = await repoA.plantareByPledgeId(pledgeId); + expect(aAfter!.remoteState, PlantareRemoteState.accepted); + expect(aAfter.debtorSignature, isNotNull); + expect(aAfter.creditorSignature, isNotNull); + }); + + test('decline notifies the proposer and marks the row declined', () async { + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Maíz', + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + + await svcB.decline(pledgeId, reason: 'ya no me caben'); + expect(txB.declined.single.toPubkey, creditor.publicKeyHex); + expect(txB.declined.single.reason, 'ya no me caben'); + final bRow = await repoB.plantareByPledgeId(pledgeId); + expect(bRow!.remoteState, PlantareRemoteState.declined); + + // Creditor sees the decline. + await svcA.ingest(PlantareEnvelope( + kind: PlantareMessageKind.declined, + fromPubkey: debtor.publicKeyHex, + pledgeId: pledgeId, + )); + expect( + (await repoA.plantareByPledgeId(pledgeId))!.remoteState, + PlantareRemoteState.declined); + }); + + test('a proposal with a bad signature is dropped, not stored', () async { + // Build a proposal but corrupt the creditor's stub. + final good = txA; + await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Judía', + ); + final tampered = good.proposed.single.copyWith( + creditorSignature: 'f' * 128, // invalid + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, tampered), + ); + expect(await repoB.watchPlantares().first, isEmpty); + expect(svcB.pendingProposals, isEmpty); + }); + + test('an accept must carry both valid stubs or it is ignored', () async { + final pledgeId = await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: debtor.publicKeyHex, + label: 'Calabaza', + ); + // Half-signed "accept" (only the creditor's original stub) → ignored. + await svcA.ingest( + envelope(PlantareMessageKind.accepted, debtor.publicKeyHex, + txA.proposed.single), + ); + expect( + (await repoA.plantareByPledgeId(pledgeId))!.remoteState, + PlantareRemoteState.proposed); + }); + + test('a proposal addressed to someone else is not stored', () async { + final stranger = await idFor(9); + await svcA.propose( + direction: PlantareDirection.owedToMe, + counterpartyKey: stranger.publicKeyHex, // not the debtor + label: 'Pepino', + ); + await svcB.ingest( + envelope(PlantareMessageKind.proposed, creditor.publicKeyHex, + txA.proposed.single), + ); + expect(await repoB.watchPlantares().first, isEmpty); + }); +}