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, }; }