568 lines
18 KiB
TypeScript
568 lines
18 KiB
TypeScript
/**
|
|
* tradeStatus pure-logic smoke runner.
|
|
*
|
|
* Exercises advancePhase + phaseForVerify + the pure mutators
|
|
* without touching the Svelte-store wrapper, so tsx can run
|
|
* it without resolving 'svelte/store'.
|
|
*
|
|
* Coverage targets:
|
|
* - Phase monotonic advancement
|
|
* - paid_verified stickiness against later mismatch / unverifiable
|
|
* - phaseForVerify mapping for every VerifyResult variant
|
|
* - recordAddressSharedPure round-trip
|
|
* - recordFundsSentPure preserves expectedMemo from prior address pill
|
|
* - recordVerificationPure transitions phase + clears mismatch on verified
|
|
* - Future-phase preservation (released/disputed/completed don't regress)
|
|
* - Unknown phase handling (defensive)
|
|
*/
|
|
|
|
import {
|
|
advancePhase,
|
|
phaseForVerify,
|
|
recordAddressSharedPure,
|
|
recordFundsSentPure,
|
|
recordVerificationPure,
|
|
type TradePhase,
|
|
type TradeState
|
|
} from '../../web/src/lib/trades/tradeStatusPure.ts';
|
|
import type { VerifyResult } from '../../web/src/lib/chat/blurtVerify.ts';
|
|
|
|
let failures = 0;
|
|
let scenarios = 0;
|
|
|
|
function scenario(name: string, fn: () => void): void {
|
|
scenarios++;
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
} catch (err) {
|
|
failures++;
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
function assertEqual(actual: unknown, expected: unknown, label: string): void {
|
|
const a = JSON.stringify(actual);
|
|
const e = JSON.stringify(expected);
|
|
if (a !== e) throw new Error(`${label}: expected ${e}, got ${a}`);
|
|
}
|
|
|
|
function assertTrue(cond: boolean, label: string): void {
|
|
if (!cond) throw new Error(`${label}: expected true`);
|
|
}
|
|
|
|
const empty: ReadonlyMap<string, TradeState> = new Map();
|
|
|
|
// ─── advancePhase ─────────────────────────────────────────────────
|
|
|
|
scenario('advancePhase: undefined → candidate', () => {
|
|
assertEqual(advancePhase(undefined, 'address_shared'), 'address_shared', 'a');
|
|
assertEqual(advancePhase(undefined, 'paid'), 'paid', 'b');
|
|
});
|
|
|
|
scenario('advancePhase: forward progress', () => {
|
|
assertEqual(advancePhase('address_shared', 'paid'), 'paid', 'shared → paid');
|
|
assertEqual(advancePhase('paid', 'paid_verified'), 'paid_verified', 'paid → verified');
|
|
assertEqual(advancePhase('paid', 'paid_mismatch'), 'paid_mismatch', 'paid → mismatch');
|
|
});
|
|
|
|
scenario('advancePhase: no regression', () => {
|
|
assertEqual(advancePhase('paid', 'address_shared'), 'paid', 'paid stays');
|
|
assertEqual(advancePhase('paid_verified', 'paid'), 'paid_verified', 'verified stays');
|
|
});
|
|
|
|
scenario('advancePhase: paid_verified is sticky against mismatch', () => {
|
|
assertEqual(
|
|
advancePhase('paid_verified', 'paid_mismatch'),
|
|
'paid_verified',
|
|
'sticky vs mismatch'
|
|
);
|
|
});
|
|
|
|
scenario('advancePhase: paid_verified is sticky against unverifiable', () => {
|
|
assertEqual(
|
|
advancePhase('paid_verified', 'paid_unverifiable'),
|
|
'paid_verified',
|
|
'sticky vs unverifiable'
|
|
);
|
|
});
|
|
|
|
scenario('advancePhase: paid_mismatch can still advance to released/etc', () => {
|
|
// A mismatched payment may later be released by mediation.
|
|
assertEqual(advancePhase('paid_mismatch', 'released'), 'released', 'mismatch → released');
|
|
});
|
|
|
|
scenario('advancePhase: unknown candidate returns current', () => {
|
|
// Forward-compat: a future protocol phase we don't recognize
|
|
// in this client shouldn't crash. Fall back to current.
|
|
const r = advancePhase('paid', 'cosmic_settlement' as TradePhase);
|
|
assertEqual(r, 'paid', 'unknown stays current');
|
|
});
|
|
|
|
// ─── phaseForVerify ───────────────────────────────────────────────
|
|
|
|
scenario('phaseForVerify: verified → paid_verified', () => {
|
|
assertEqual(phaseForVerify({ kind: 'verified' }), 'paid_verified', 'v');
|
|
});
|
|
|
|
scenario('phaseForVerify: mismatch → paid_mismatch', () => {
|
|
assertEqual(phaseForVerify({ kind: 'mismatch', field: 'amount' }), 'paid_mismatch', 'm');
|
|
});
|
|
|
|
scenario('phaseForVerify: not_found → paid_unverifiable', () => {
|
|
assertEqual(phaseForVerify({ kind: 'not_found' }), 'paid_unverifiable', 'nf');
|
|
});
|
|
|
|
scenario('phaseForVerify: wrong_op → paid_unverifiable', () => {
|
|
assertEqual(phaseForVerify({ kind: 'wrong_op' }), 'paid_unverifiable', 'wo');
|
|
});
|
|
|
|
scenario('phaseForVerify: rpc_error → paid_unverifiable', () => {
|
|
assertEqual(phaseForVerify({ kind: 'rpc_error', message: 'down' }), 'paid_unverifiable', 're');
|
|
});
|
|
|
|
// Phase F.5 audit fix (F-42) — forward-compat for a future
|
|
// VerifyResult kind that this client doesn't recognize. Should
|
|
// fall through to paid_unverifiable rather than crash or return
|
|
// an undefined phase.
|
|
scenario('phaseForVerify: unknown future kind → paid_unverifiable', () => {
|
|
const futureResult = { kind: 'rate_limited' } as unknown as Parameters<typeof phaseForVerify>[0];
|
|
assertEqual(phaseForVerify(futureResult), 'paid_unverifiable', 'fwd-compat');
|
|
});
|
|
|
|
// Phase F.5 audit fix (F-42) — forward-compat for a future
|
|
// VerifyResult kind that this client doesn't recognize. Should
|
|
// fall through to paid_unverifiable rather than crash or return
|
|
// an undefined phase.
|
|
scenario('phaseForVerify: unknown future kind → paid_unverifiable', () => {
|
|
const futureResult = { kind: 'rate_limited' } as unknown as Parameters<typeof phaseForVerify>[0];
|
|
assertEqual(phaseForVerify(futureResult), 'paid_unverifiable', 'fwd-compat');
|
|
});
|
|
|
|
// ─── recordAddressSharedPure ──────────────────────────────────────
|
|
|
|
scenario('recordAddressSharedPure: creates entry', () => {
|
|
const next = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedAmount: 1700,
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = next.get('ord-1');
|
|
assertTrue(entry !== undefined, 'entry exists');
|
|
assertEqual(entry?.phase, 'address_shared', 'phase');
|
|
assertEqual(entry?.expectedMemo, 'abc12345', 'memo');
|
|
assertEqual(entry?.expectedAmount, 1700, 'amount');
|
|
assertEqual(entry?.peer, 'bob', 'peer');
|
|
});
|
|
|
|
scenario('recordAddressSharedPure: preserves addressSharedAt on re-record', () => {
|
|
const t0 = new Date('2026-01-01T00:00:00Z');
|
|
const t1 = new Date('2026-01-01T01:00:00Z');
|
|
const a = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
now: t0,
|
|
direction: 'outgoing'
|
|
});
|
|
const b = recordAddressSharedPure(a, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
now: t1,
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = b.get('ord-1');
|
|
assertEqual(entry?.addressSharedAt?.toISOString(), t0.toISOString(), 'preserved');
|
|
assertEqual(entry?.updatedAt?.toISOString(), t1.toISOString(), 'updatedAt advances');
|
|
});
|
|
|
|
// ─── recordFundsSentPure ──────────────────────────────────────────
|
|
|
|
scenario('recordFundsSentPure: preserves expectedMemo from prior address', () => {
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedAmount: 1700,
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordFundsSentPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
claimedMemo: 'WRONG999',
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.expectedMemo, 'abc12345', 'expected preserved');
|
|
assertEqual(entry?.claimedMemo, 'WRONG999', 'claimed recorded');
|
|
assertEqual(entry?.phase, 'paid', 'phase advanced');
|
|
});
|
|
|
|
scenario('recordFundsSentPure: BLURT sets verifyResult to pending', () => {
|
|
const m = recordFundsSentPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
assertEqual(m.get('ord-1')?.verifyResult, 'pending', 'pending');
|
|
});
|
|
|
|
scenario('recordFundsSentPure: BTC has no verifyResult', () => {
|
|
const m = recordFundsSentPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'btc',
|
|
txid: 'a'.repeat(64),
|
|
amount: 0.5,
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.verifyResult, undefined, 'no verify for BTC');
|
|
});
|
|
|
|
scenario('recordFundsSentPure: creates entry even without prior address pill', () => {
|
|
const m = recordFundsSentPure(empty, {
|
|
orderPermlink: 'ord-orphan',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
amount: 100,
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = m.get('ord-orphan');
|
|
assertTrue(entry !== undefined, 'created');
|
|
assertEqual(entry?.phase, 'paid', 'phase');
|
|
assertEqual(entry?.expectedAmount, 100, 'amount captured from funds_sent');
|
|
});
|
|
|
|
// ─── recordVerificationPure ───────────────────────────────────────
|
|
|
|
scenario('recordVerificationPure: first-wins between sibling terminals', () => {
|
|
let m = recordFundsSentPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'mismatch', field: 'memo' }
|
|
});
|
|
assertEqual(m.get('ord-1')?.mismatchField, 'memo', 'mismatch field set');
|
|
|
|
// A subsequent verified result should NOT downgrade an
|
|
// existing mismatch. Sibling terminal states are first-wins
|
|
// to prevent UI flicker and protect the user from a stale
|
|
// RPC re-resolve overwriting a real mismatch. Both the
|
|
// phase AND the mismatchField stay intact.
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'verified' }
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.phase, 'paid_mismatch', 'first wins (mismatch holds)');
|
|
assertEqual(entry?.mismatchField, 'memo', 'field preserved with phase');
|
|
});
|
|
|
|
scenario('recordVerificationPure: verified is sticky against later mismatch', () => {
|
|
let m = recordFundsSentPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'verified' }
|
|
});
|
|
// A subsequent stale RPC error or re-render shouldn't
|
|
// downgrade.
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'rpc_error', message: 'late' }
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.phase, 'paid_verified', 'sticky verified');
|
|
});
|
|
|
|
scenario('recordVerificationPure: defensive entry creation', () => {
|
|
// Verification result for a permlink we haven't seen — record
|
|
// minimally rather than dropping.
|
|
const m = recordVerificationPure(empty, {
|
|
orderPermlink: 'ord-orphan',
|
|
verifyResult: { kind: 'verified' }
|
|
});
|
|
assertTrue(m.get('ord-orphan') !== undefined, 'created');
|
|
assertEqual(m.get('ord-orphan')?.phase, 'paid_verified', 'phase');
|
|
});
|
|
|
|
// ─── End-to-end happy path ────────────────────────────────────────
|
|
|
|
scenario('e2e happy path: address → paid → verified', () => {
|
|
let m: ReadonlyMap<string, TradeState> = empty;
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedAmount: 1700,
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
assertEqual(m.get('ord-1')?.phase, 'address_shared', 'p1');
|
|
|
|
m = recordFundsSentPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
claimedMemo: 'abc12345',
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
assertEqual(m.get('ord-1')?.phase, 'paid', 'p2');
|
|
assertEqual(m.get('ord-1')?.verifyResult, 'pending', 'pending');
|
|
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'verified' }
|
|
});
|
|
assertEqual(m.get('ord-1')?.phase, 'paid_verified', 'p3');
|
|
});
|
|
|
|
scenario('e2e mismatch path: address → paid → mismatch', () => {
|
|
let m: ReadonlyMap<string, TradeState> = empty;
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordFundsSentPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
claimedMemo: 'fake0000',
|
|
amount: 1700,
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordVerificationPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
verifyResult: { kind: 'mismatch', field: 'memo' }
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.phase, 'paid_mismatch', 'phase');
|
|
assertEqual(entry?.mismatchField, 'memo', 'field');
|
|
});
|
|
|
|
// ─── F-40 lock-on-engagement semantics ────────────────────────────
|
|
|
|
scenario('F-40: outgoing locks engagedPeer', () => {
|
|
const m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
assertEqual(m.get('ord-1')?.engagedPeer, 'bob', 'engaged');
|
|
});
|
|
|
|
scenario('F-40: incoming on a tentative (no-engagement) entry succeeds', () => {
|
|
const m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'mallory',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'poison01',
|
|
direction: 'incoming'
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.peer, 'mallory', 'peer set');
|
|
assertEqual(entry?.engagedPeer, undefined, 'no engagement');
|
|
assertEqual(entry?.expectedMemo, 'poison01', 'memo set');
|
|
});
|
|
|
|
scenario('F-40: incoming from non-engaged peer is DROPPED after engagement', () => {
|
|
// User engages with Bob (sends address pill OUT to Bob).
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
const beforePoison = m.get('ord-1');
|
|
|
|
// Mallory sends a poisoning incoming address-shared.
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'mallory',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'poison01',
|
|
direction: 'incoming'
|
|
});
|
|
|
|
const afterPoison = m.get('ord-1');
|
|
// Entry unchanged — Mallory's update was dropped.
|
|
assertEqual(afterPoison?.peer, 'bob', 'peer preserved');
|
|
assertEqual(afterPoison?.expectedMemo, 'abc12345', 'memo preserved');
|
|
assertEqual(afterPoison?.updatedAt, beforePoison?.updatedAt, 'updatedAt unchanged');
|
|
});
|
|
|
|
scenario('F-40: incoming from engaged peer is APPLIED after engagement', () => {
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
// Bob's reply (incoming funds-sent) — should apply, advance phase.
|
|
m = recordFundsSentPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
claimedMemo: 'abc12345',
|
|
amount: 1700,
|
|
direction: 'incoming'
|
|
});
|
|
const entry = m.get('ord-1');
|
|
assertEqual(entry?.phase, 'paid', 'advanced to paid');
|
|
assertEqual(entry?.engagedPeer, 'bob', 'engagement preserved');
|
|
assertEqual(entry?.expectedMemo, 'abc12345', 'memo from outgoing preserved');
|
|
});
|
|
|
|
scenario(
|
|
'F-40: incoming poison BEFORE engagement, then outgoing engagement → still drops subsequent poison',
|
|
() => {
|
|
// Mallory poisons a tentative entry first.
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'mallory',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'poison01',
|
|
direction: 'incoming'
|
|
});
|
|
// User engages with Bob (sends OUT to Bob).
|
|
// This OVERWRITES the tentative entry — peer/memo/etc. get
|
|
// updated to bob's values, and engagedPeer is set to bob.
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
let entry = m.get('ord-1');
|
|
assertEqual(entry?.engagedPeer, 'bob', 'engaged with bob');
|
|
assertEqual(entry?.expectedMemo, 'abc12345', 'memo replaced by outgoing');
|
|
assertEqual(entry?.peer, 'bob', 'peer replaced');
|
|
|
|
// Mallory tries again post-engagement — dropped.
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'mallory',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'poison02',
|
|
direction: 'incoming'
|
|
});
|
|
entry = m.get('ord-1');
|
|
assertEqual(entry?.expectedMemo, 'abc12345', 'still bob memo');
|
|
assertEqual(entry?.peer, 'bob', 'peer still bob');
|
|
}
|
|
);
|
|
|
|
scenario('F-40: engagement is sticky — outgoing to different peer does NOT re-engage', () => {
|
|
// User somehow sends an outgoing payload to Charlie referencing
|
|
// the same orderPermlink that's already engaged with Bob (UI bug
|
|
// or buggy code path). Engagement should not flip; the entry
|
|
// stays locked to Bob. This is defense-in-depth behavior — UI
|
|
// should prevent reaching this state, but the mutator guards.
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
direction: 'outgoing'
|
|
});
|
|
m = recordAddressSharedPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'charlie',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
direction: 'outgoing'
|
|
});
|
|
const entry = m.get('ord-1');
|
|
// Outgoing always APPLIES — entry's peer reflects the latest
|
|
// outgoing send. But engagedPeer stays at the FIRST engaged
|
|
// peer (bob) per `existing?.engagedPeer ?? args.peer`.
|
|
assertEqual(entry?.peer, 'charlie', 'peer reflects latest outgoing');
|
|
assertEqual(entry?.engagedPeer, 'bob', 'engagement sticky to first');
|
|
});
|
|
|
|
scenario('F-40: incoming funds_sent from non-engaged peer dropped', () => {
|
|
let m = recordAddressSharedPure(empty, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'bob',
|
|
method: 'blurt',
|
|
address: 'alice',
|
|
expectedMemo: 'abc12345',
|
|
direction: 'outgoing'
|
|
});
|
|
const before = m.get('ord-1');
|
|
m = recordFundsSentPure(m, {
|
|
orderPermlink: 'ord-1',
|
|
peer: 'mallory',
|
|
method: 'blurt',
|
|
txid: 'a'.repeat(40),
|
|
amount: 1700,
|
|
direction: 'incoming'
|
|
});
|
|
const after = m.get('ord-1');
|
|
// Mallory's funds-sent dropped — phase still address_shared.
|
|
assertEqual(after?.phase, 'address_shared', 'phase unchanged');
|
|
assertEqual(after?.peer, 'bob', 'peer unchanged');
|
|
assertEqual(after?.txid, undefined, 'no txid added');
|
|
assertEqual(after?.updatedAt, before?.updatedAt, 'updatedAt unchanged');
|
|
});
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|