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:
parent
bec0f78fa1
commit
2e25e34d11
5 changed files with 529 additions and 3 deletions
|
|
@ -16,6 +16,7 @@ import 'services/message_store.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
import 'services/plantare_service.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
import 'services/profile_store.dart';
|
import 'services/profile_store.dart';
|
||||||
import 'services/saved_offers_store.dart';
|
import 'services/saved_offers_store.dart';
|
||||||
|
|
@ -67,10 +68,14 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
if (notifications != null) await notifications.initialize();
|
if (notifications != null) await notifications.initialize();
|
||||||
final sync =
|
final sync =
|
||||||
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
|
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
|
||||||
// Subscribe the inbox + sync listeners BEFORE the shared connection starts
|
final plantares =
|
||||||
// connecting, so the first session is caught; then bring the connection up.
|
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : 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();
|
inbox?.start();
|
||||||
sync?.start();
|
sync?.start();
|
||||||
|
plantares?.start();
|
||||||
connection?.start();
|
connection?.start();
|
||||||
|
|
||||||
return TaneApp(
|
return TaneApp(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import '../services/recovery_sheet_service.dart';
|
||||||
import '../services/share_catalog_service.dart';
|
import '../services/share_catalog_service.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/offer_outbox.dart';
|
import '../services/offer_outbox.dart';
|
||||||
|
import '../services/plantare_service.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/saved_offers_store.dart';
|
import '../services/saved_offers_store.dart';
|
||||||
|
|
@ -261,6 +262,17 @@ Future<void> configureDependencies() async {
|
||||||
localChanges: database.tableUpdates().map<void>((_) {}),
|
localChanges: database.tableUpdates().map<void>((_) {}),
|
||||||
selfDeviceId: deviceId,
|
selfDeviceId: deviceId,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
// Drives the bilateral signed Plantaré handshake over the shared
|
||||||
|
// connection, reconciling moves into the local ledger. Started in
|
||||||
|
// `Bootstrap`.
|
||||||
|
..registerSingleton<PlantareService>(
|
||||||
|
PlantareService(
|
||||||
|
repo: varietyRepository,
|
||||||
|
selfPubkey: socialService.publicKeyHex,
|
||||||
|
selfSecretKey: socialService.identity.privateKeyHex,
|
||||||
|
connection: connection,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +305,10 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
await getIt<InboxService>().stop();
|
await getIt<InboxService>().stop();
|
||||||
await getIt.unregister<InboxService>();
|
await getIt.unregister<InboxService>();
|
||||||
}
|
}
|
||||||
|
if (getIt.isRegistered<PlantareService>()) {
|
||||||
|
await getIt<PlantareService>().stop();
|
||||||
|
await getIt.unregister<PlantareService>();
|
||||||
|
}
|
||||||
if (getIt.isRegistered<SocialConnection>()) {
|
if (getIt.isRegistered<SocialConnection>()) {
|
||||||
await getIt<SocialConnection>().dispose();
|
await getIt<SocialConnection>().dispose();
|
||||||
await getIt.unregister<SocialConnection>();
|
await getIt.unregister<SocialConnection>();
|
||||||
|
|
@ -351,14 +367,22 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
|
localChanges: getIt<AppDatabase>().tableUpdates().map<void>((_) {}),
|
||||||
selfDeviceId: deviceId,
|
selfDeviceId: deviceId,
|
||||||
);
|
);
|
||||||
|
final plantares = PlantareService(
|
||||||
|
repo: getIt<VarietyRepository>(),
|
||||||
|
selfPubkey: social.publicKeyHex,
|
||||||
|
selfSecretKey: social.identity.privateKeyHex,
|
||||||
|
connection: connection,
|
||||||
|
);
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SocialService>(social)
|
..registerSingleton<SocialService>(social)
|
||||||
..registerSingleton<SocialConnection>(connection)
|
..registerSingleton<SocialConnection>(connection)
|
||||||
..registerSingleton<InboxService>(inbox)
|
..registerSingleton<InboxService>(inbox)
|
||||||
..registerSingleton<SyncService>(sync);
|
..registerSingleton<SyncService>(sync)
|
||||||
|
..registerSingleton<PlantareService>(plantares);
|
||||||
// Subscribe the listeners BEFORE the connection starts connecting.
|
// Subscribe the listeners BEFORE the connection starts connecting.
|
||||||
inbox.start();
|
inbox.start();
|
||||||
sync.start();
|
sync.start();
|
||||||
|
plantares.start();
|
||||||
connection.start();
|
connection.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
281
apps/app_seeds/lib/services/plantare_service.dart
Normal file
281
apps/app_seeds/lib/services/plantare_service.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ class SocialSession {
|
||||||
ratings = NostrRatingTransport(_channel),
|
ratings = NostrRatingTransport(_channel),
|
||||||
reports = NostrReportTransport(_channel),
|
reports = NostrReportTransport(_channel),
|
||||||
profile = NostrProfileTransport(_channel),
|
profile = NostrProfileTransport(_channel),
|
||||||
|
plantares = NostrPlantareTransport(_channel),
|
||||||
sync = NostrSyncTransport(
|
sync = NostrSyncTransport(
|
||||||
_channel,
|
_channel,
|
||||||
namespace: kInventorySyncNamespace,
|
namespace: kInventorySyncNamespace,
|
||||||
|
|
@ -114,6 +115,10 @@ class SocialSession {
|
||||||
final ReportTransport reports;
|
final ReportTransport reports;
|
||||||
final ProfileTransport profile;
|
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.
|
/// Device-to-device inventory sync for THIS identity's own devices.
|
||||||
final SyncTransport sync;
|
final SyncTransport sync;
|
||||||
|
|
||||||
|
|
|
||||||
211
apps/app_seeds/test/services/plantare_service_test.dart
Normal file
211
apps/app_seeds/test/services/plantare_service_test.dart
Normal file
|
|
@ -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 = <PlantarePledge>[];
|
||||||
|
final accepted = <PlantarePledge>[];
|
||||||
|
final declined = <({String toPubkey, String pledgeId, String reason})>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> propose(PlantarePledge pledge) async => proposed.add(pledge);
|
||||||
|
@override
|
||||||
|
Future<void> accept(PlantarePledge pledge) async => accepted.add(pledge);
|
||||||
|
@override
|
||||||
|
Future<void> decline({
|
||||||
|
required String toPubkey,
|
||||||
|
required String pledgeId,
|
||||||
|
String reason = '',
|
||||||
|
}) async =>
|
||||||
|
declined.add((toPubkey: toPubkey, pledgeId: pledgeId, reason: reason));
|
||||||
|
@override
|
||||||
|
Stream<PlantareEnvelope> incoming() => const Stream.empty();
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<NostrIdentity> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue