feat(plantare): PlantareService — orchestrate the signed handshake

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.
This commit is contained in:
vjrj 2026-07-14 11:07:58 +02:00
parent 01fba40ec2
commit b607ddde41
5 changed files with 529 additions and 3 deletions

View file

@ -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<void>.broadcast();
final _pending = <String, PlantarePledge>{};
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<PlantareEnvelope>? _incomingSub;
PlantareTransport? _transport;
bool _started = false;
/// Fires (no payload) after an incoming move is reconciled the UI reloads.
Stream<void> 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<String, PlantarePledge> 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<String> 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<void> 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<void> 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<void> 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<void> _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<void> _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<void> _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<void> 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,
};
}

View file

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