From 031b119b8d97570b1247cbe37a5e42032333ef84 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 15:45:40 +0200 Subject: [PATCH 1/6] feat(settlement): add canonical request, policy fixture, receipt, and classifier Implements B02 for the Coder B lane as pure adapter logic with no network, no durable state, and no credential. Canonical request (B02.1, B02.2). One intent produces byte-stable calldata, a keccak256 payload fingerprint, a provider idempotency key, and a reference ID. Serialization writes an explicit field order rather than relying on JSON.stringify over an object literal, whose key order depends on construction order; two workers building the same obligation must fingerprint identically or the provider key stops collapsing duplicates. Addresses are lowercased so checksummed and non-checksummed spellings cannot fingerprint apart. A golden vector pins the exact bytes, because changing any of them changes every in-flight idempotency key. The 24-hour provider window is documented as supplemental: OneShot durable state remains the authority past it. Only the direct transfer path is built. B01.3 recorded the Arc Memo forwarded call NOT_SUPPORTED, so no memo calldata builder exists to be reached by mistake. Policy fixture (B02.3). Expresses the required Privy policy as rules over the documented condition fields, terminated by an unconditional default deny. assessPolicySoundness catches the two ways such a policy silently stops protecting anything: losing its terminal deny, or dropping a constrained dimension. A keccak256 digest lets readiness detect drift, since the policy lives in Privy configuration outside this repository and can be edited without a commit. The digest ignores recipient ordering so an operator relisting the same addresses does not read as drift. Receipt verification (B02.4). Confirmation requires a final receipt and exactly one Transfer matching sender, recipient, and amount, emitted by the configured token. status: 1 alone is not confirmation, and tests cover the cases that would otherwise pass: no logs, a redirected recipient, an amount off by one atomic unit, a Transfer from an impostor contract, and two matching transfers, which would mean more value moved than was authorized. Outcome classifier (B02.5). Doubt is structural. DEFINITELY_NOT_SUBMITTED is granted only for narrow pre-flight proofs where nothing was broadcast; every ambiguous signal and every unrecognized response shape falls through to POSSIBLY_SUBMITTED, including a response kind from a future provider version. A receipt that fails to prove settlement is POSSIBLY_SUBMITTED, never DEFINITELY_NOT_SUBMITTED: absence of proof is not proof of absence. --- packages/arc-adapter/src/index.ts | 2 + packages/arc-adapter/src/outcome.ts | 133 ++++++++++++ packages/arc-adapter/src/receipt.ts | 159 +++++++++++++++ packages/arc-adapter/test/outcome.test.ts | 84 ++++++++ packages/arc-adapter/test/receipt.test.ts | 148 ++++++++++++++ packages/privy-adapter/src/index.ts | 2 + packages/privy-adapter/src/policy-fixture.ts | 190 ++++++++++++++++++ packages/privy-adapter/src/request.ts | 177 ++++++++++++++++ .../privy-adapter/test/policy-fixture.test.ts | 141 +++++++++++++ packages/privy-adapter/test/request.test.ts | 154 ++++++++++++++ 10 files changed, 1190 insertions(+) create mode 100644 packages/arc-adapter/src/outcome.ts create mode 100644 packages/arc-adapter/src/receipt.ts create mode 100644 packages/arc-adapter/test/outcome.test.ts create mode 100644 packages/arc-adapter/test/receipt.test.ts create mode 100644 packages/privy-adapter/src/policy-fixture.ts create mode 100644 packages/privy-adapter/src/request.ts create mode 100644 packages/privy-adapter/test/policy-fixture.test.ts create mode 100644 packages/privy-adapter/test/request.test.ts diff --git a/packages/arc-adapter/src/index.ts b/packages/arc-adapter/src/index.ts index 2630b35..092dbfd 100644 --- a/packages/arc-adapter/src/index.ts +++ b/packages/arc-adapter/src/index.ts @@ -3,3 +3,5 @@ export * from './money.js'; export * from './redaction.js'; export * from './config.js'; export * from './readiness.js'; +export * from './receipt.js'; +export * from './outcome.js'; diff --git a/packages/arc-adapter/src/outcome.ts b/packages/arc-adapter/src/outcome.ts new file mode 100644 index 0000000..ca932e2 --- /dev/null +++ b/packages/arc-adapter/src/outcome.ts @@ -0,0 +1,133 @@ +/** + * Submission outcome classifier (B02.5). + * + * Maps a provider or RPC response to one of the three SettlementPort results + * in `milestones/CONTRACTS.md` section 5. Pure: no network, no durable state, + * no clock. + * + * The whole module exists to make one bias structural: **doubt means + * `POSSIBLY_SUBMITTED`**. `DEFINITELY_NOT_SUBMITTED` is a strong claim that no + * external effect occurred, and it is the only result that permits a fresh + * attempt without reconciliation. It is therefore granted only for narrow, + * documented proofs, and every unrecognized shape falls through to + * `POSSIBLY_SUBMITTED`. + * + * `.agent/SECURITY_INVARIANTS.md`: a timeout, crash, disconnect, lost response, + * or provider error after possible submission creates `UNKNOWN`. + */ + +export type SubmissionOutcome = + /** Verified final receipt and exactly matching Transfer evidence. */ + | 'CONFIRMED' + /** Narrow documented proof that no broadcast or external effect occurred. */ + | 'DEFINITELY_NOT_SUBMITTED' + /** Any doubt at all. Maps to durable UNKNOWN and requires reconciliation. */ + | 'POSSIBLY_SUBMITTED'; + +/** + * Failure shapes that prove the request never reached the network. + * + * Each is a pre-flight rejection: the provider refused the request before + * broadcasting anything, so no transaction can exist. Adding to this list + * widens the set of situations that permit a retry, so entries need real + * documented proof. + */ +export type PreSubmissionProof = + /** Privy policy denied the action. Nothing was signed. */ + | 'POLICY_DENIED' + /** Request failed schema validation at the provider before signing. */ + | 'REQUEST_VALIDATION_FAILED' + /** Local scope check refused the transaction before it was ever sent. */ + | 'LOCAL_SCOPE_DENIED' + /** Authorization was rejected as expired or invalid before signing. */ + | 'AUTHORIZATION_INVALID'; + +/** + * Ambiguous shapes. Listed for documentation and exhaustiveness; every one of + * them classifies as `POSSIBLY_SUBMITTED`. + */ +export type AmbiguousSignal = + | 'TIMEOUT' + | 'CONNECTION_RESET' + | 'LOST_RESPONSE' + | 'TRUNCATED_RESPONSE' + | 'MALFORMED_RESPONSE' + | 'PROVIDER_5XX' + | 'RATE_LIMITED' + | 'PROCESS_CRASH' + | 'UNKNOWN_ERROR'; + +export type ProviderResponse = + /** A receipt was obtained and independently verified as confirmed. */ + | { readonly kind: 'VERIFIED_RECEIPT'; readonly confirmed: boolean } + /** The provider proved it never submitted. */ + | { readonly kind: 'PRE_SUBMISSION_FAILURE'; readonly proof: PreSubmissionProof } + /** Something went wrong and we cannot prove what. */ + | { readonly kind: 'AMBIGUOUS'; readonly signal: AmbiguousSignal } + /** A shape this build does not recognize. */ + | { readonly kind: 'UNRECOGNIZED'; readonly detail: string }; + +export interface Classification { + readonly outcome: SubmissionOutcome; + /** Sanitized explanation suitable for an operator timeline. */ + readonly reason: string; +} + +/** + * Classify a provider response. + * + * Note the asymmetry in the `VERIFIED_RECEIPT` case: a confirmed receipt gives + * `CONFIRMED`, but an unconfirmed one does NOT give + * `DEFINITELY_NOT_SUBMITTED`. Failing to prove a settlement happened is not + * proof that it did not; the transaction may be pending, or the receipt may be + * for an attempt whose Transfer we could not match yet. + */ +export function classifyOutcome(response: ProviderResponse): Classification { + switch (response.kind) { + case 'VERIFIED_RECEIPT': + return response.confirmed + ? { + outcome: 'CONFIRMED', + reason: 'Final receipt and exactly the expected Transfer were verified.', + } + : { + outcome: 'POSSIBLY_SUBMITTED', + reason: + 'A receipt was obtained but did not prove the expected settlement. ' + + 'Absence of proof is not proof of absence; reconcile before retrying.', + }; + + case 'PRE_SUBMISSION_FAILURE': + return { + outcome: 'DEFINITELY_NOT_SUBMITTED', + reason: `The request was refused before broadcast (${response.proof}); no external effect occurred.`, + }; + + case 'AMBIGUOUS': + return { + outcome: 'POSSIBLY_SUBMITTED', + reason: `The outcome is unknown after ${response.signal}; the transaction may have been broadcast.`, + }; + + case 'UNRECOGNIZED': + // An unrecognized response must never widen retry permission. This is + // the fail-closed default the module exists for. + return { + outcome: 'POSSIBLY_SUBMITTED', + reason: 'The provider response was not recognized, so submission cannot be ruled out.', + }; + + default: { + const unreachable: never = response; + return { + outcome: 'POSSIBLY_SUBMITTED', + reason: `Unhandled response shape: ${String(unreachable)}`, + }; + } + } +} + +/** Only this outcome permits a fresh attempt without reconciliation. */ +export function permitsImmediateRetry(outcome: SubmissionOutcome): boolean { + return outcome === 'DEFINITELY_NOT_SUBMITTED'; +} diff --git a/packages/arc-adapter/src/receipt.ts b/packages/arc-adapter/src/receipt.ts new file mode 100644 index 0000000..10125e7 --- /dev/null +++ b/packages/arc-adapter/src/receipt.ts @@ -0,0 +1,159 @@ +/** + * Arc receipt verification (B02.4). + * + * Confirms a settlement only from an exact final receipt plus exactly the + * expected ERC-20 Transfer log. + * + * The rule that matters: `status: 1` alone is NOT confirmation. A transaction + * can succeed while transferring nothing we asked for, or while emitting a + * Transfer to somewhere else. Confirmation requires the receipt to prove the + * specific movement of the specific amount to the specific recipient. + * + * `milestones/CONTRACTS.md`: "Arc receipt plus expected ERC-20 Transfer + * evidence establishes committed settlement." + */ + +/** keccak256("Transfer(address,address,uint256)"). */ +export const TRANSFER_EVENT_TOPIC = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +export interface ReceiptLog { + readonly address: string; + /** topic0 is the event signature; topic1/topic2 are indexed from/to. */ + readonly topics: readonly string[]; + /** ABI-encoded non-indexed data. For Transfer this is the amount word. */ + readonly data: string; + readonly logIndex: number; +} + +export interface TransactionReceipt { + readonly transactionHash: string; + readonly chainId: number; + /** The wallet that sent the transaction. */ + readonly from: string; + /** The contract called. For a direct transfer this is the token. */ + readonly to: string; + /** 1 success, 0 revert. */ + readonly status: 0 | 1; + readonly blockNumber: bigint; + readonly blockHash: string; + readonly logs: readonly ReceiptLog[]; +} + +/** Exactly what the receipt must prove. */ +export interface ExpectedSettlement { + readonly chainId: number; + readonly walletAddress: string; + readonly tokenContract: string; + readonly recipient: string; + readonly amountAtomic: bigint; +} + +export type ReceiptVerdict = + /** Final receipt and exactly the expected Transfer. Terminal success. */ + | { readonly result: 'CONFIRMED'; readonly transferLogIndex: number } + /** Final revert. Terminal failure; no value moved. */ + | { readonly result: 'FINAL_REVERT'; readonly detail: string } + /** + * The receipt exists but does not prove the expected settlement. Never + * treated as failure: something happened on-chain and it must be reconciled. + */ + | { readonly result: 'NOT_CONFIRMED'; readonly detail: string }; + +function sameAddress(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase(); +} + +/** Decode a 32-byte address topic into an address. */ +function addressFromTopic(topic: string): string { + return `0x${topic.slice(-40)}`.toLowerCase(); +} + +/** Decode a 32-byte word as an unsigned integer. */ +function amountFromData(data: string): bigint | undefined { + const normalized = data.trim().toLowerCase(); + if (!/^0x[0-9a-f]{64}$/.test(normalized)) return undefined; + return BigInt(normalized); +} + +/** + * Verify a receipt against the expected settlement. + * + * Every identity is checked before the logs are inspected, so a receipt for a + * different chain, wallet, or transaction can never be matched by log content + * alone. + */ +export function verifyReceipt( + receipt: TransactionReceipt, + expected: ExpectedSettlement, +): ReceiptVerdict { + if (receipt.chainId !== expected.chainId) { + return { + result: 'NOT_CONFIRMED', + detail: `Receipt is from chain ${receipt.chainId}, expected ${expected.chainId}.`, + }; + } + + if (!sameAddress(receipt.from, expected.walletAddress)) { + return { + result: 'NOT_CONFIRMED', + detail: 'Receipt was not sent by the configured execution wallet.', + }; + } + + if (receipt.status === 0) { + // A revert moved no value. This is the one case that is safely terminal + // and permits the policy to schedule a fresh attempt. + return { result: 'FINAL_REVERT', detail: 'Transaction reverted; no value moved.' }; + } + + if (!sameAddress(receipt.to, expected.tokenContract)) { + return { + result: 'NOT_CONFIRMED', + detail: 'Receipt did not call the configured USDC contract.', + }; + } + + // Only Transfer logs emitted by the configured token count. A Transfer from + // some other contract proves nothing about our USDC balance. + const candidates = receipt.logs.filter( + (log) => + sameAddress(log.address, expected.tokenContract) && + log.topics[0]?.toLowerCase() === TRANSFER_EVENT_TOPIC, + ); + + const matches = candidates.filter((log) => { + const from = log.topics[1]; + const to = log.topics[2]; + if (from === undefined || to === undefined) return false; + if (!sameAddress(addressFromTopic(from), expected.walletAddress)) return false; + if (!sameAddress(addressFromTopic(to), expected.recipient)) return false; + return amountFromData(log.data) === expected.amountAtomic; + }); + + if (matches.length === 0) { + // Success status with no matching Transfer is explicitly NOT confirmation. + return { + result: 'NOT_CONFIRMED', + detail: + 'Receipt status is success but it contains no Transfer matching the ' + + 'expected sender, recipient, and amount.', + }; + } + + if (matches.length > 1) { + // Two identical transfers in one transaction means more value moved than + // the obligation authorized. Refusing to confirm forces reconciliation. + return { + result: 'NOT_CONFIRMED', + detail: `Receipt contains ${matches.length} matching Transfer logs; expected exactly one.`, + }; + } + + const match = matches[0]; + if (match === undefined) { + return { result: 'NOT_CONFIRMED', detail: 'Matching Transfer log could not be read.' }; + } + + return { result: 'CONFIRMED', transferLogIndex: match.logIndex }; +} diff --git a/packages/arc-adapter/test/outcome.test.ts b/packages/arc-adapter/test/outcome.test.ts new file mode 100644 index 0000000..45725e8 --- /dev/null +++ b/packages/arc-adapter/test/outcome.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyOutcome, + permitsImmediateRetry, + type AmbiguousSignal, + type PreSubmissionProof, + type ProviderResponse, +} from '../src/outcome.js'; + +describe('confirmed', () => { + it('classifies a verified receipt as CONFIRMED', () => { + expect(classifyOutcome({ kind: 'VERIFIED_RECEIPT', confirmed: true }).outcome).toBe( + 'CONFIRMED', + ); + }); + + it('does not treat an unconfirmed receipt as proof of non-submission', () => { + // Absence of proof is not proof of absence. This must be + // POSSIBLY_SUBMITTED, never DEFINITELY_NOT_SUBMITTED. + expect(classifyOutcome({ kind: 'VERIFIED_RECEIPT', confirmed: false }).outcome).toBe( + 'POSSIBLY_SUBMITTED', + ); + }); +}); + +describe('definitely not submitted', () => { + it.each([ + 'POLICY_DENIED', + 'REQUEST_VALIDATION_FAILED', + 'LOCAL_SCOPE_DENIED', + 'AUTHORIZATION_INVALID', + ])('grants DEFINITELY_NOT_SUBMITTED for pre-flight proof %s', (proof) => { + expect(classifyOutcome({ kind: 'PRE_SUBMISSION_FAILURE', proof }).outcome).toBe( + 'DEFINITELY_NOT_SUBMITTED', + ); + }); + + it('is the only outcome permitting an immediate retry', () => { + expect(permitsImmediateRetry('DEFINITELY_NOT_SUBMITTED')).toBe(true); + expect(permitsImmediateRetry('POSSIBLY_SUBMITTED')).toBe(false); + expect(permitsImmediateRetry('CONFIRMED')).toBe(false); + }); +}); + +describe('every ambiguous signal fails closed', () => { + it.each([ + 'TIMEOUT', + 'CONNECTION_RESET', + 'LOST_RESPONSE', + 'TRUNCATED_RESPONSE', + 'MALFORMED_RESPONSE', + 'PROVIDER_5XX', + 'RATE_LIMITED', + 'PROCESS_CRASH', + 'UNKNOWN_ERROR', + ])('classifies %s as POSSIBLY_SUBMITTED', (signal) => { + expect(classifyOutcome({ kind: 'AMBIGUOUS', signal }).outcome).toBe('POSSIBLY_SUBMITTED'); + }); + + it('never lets an ambiguous signal permit a retry', () => { + const signals: AmbiguousSignal[] = ['TIMEOUT', 'LOST_RESPONSE', 'PROCESS_CRASH']; + for (const signal of signals) { + const { outcome } = classifyOutcome({ kind: 'AMBIGUOUS', signal }); + expect(permitsImmediateRetry(outcome)).toBe(false); + } + }); +}); + +describe('unrecognized responses', () => { + it('classifies an unrecognized shape as POSSIBLY_SUBMITTED', () => { + expect(classifyOutcome({ kind: 'UNRECOGNIZED', detail: 'new provider field' }).outcome).toBe( + 'POSSIBLY_SUBMITTED', + ); + }); + + it('fails closed on a response shape from the future', () => { + // Simulates a provider adding a response kind this build predates. It must + // not widen retry permission. + const fromTheFuture = { kind: 'SOMETHING_NEW' } as unknown as ProviderResponse; + const { outcome } = classifyOutcome(fromTheFuture); + expect(outcome).toBe('POSSIBLY_SUBMITTED'); + expect(permitsImmediateRetry(outcome)).toBe(false); + }); +}); diff --git a/packages/arc-adapter/test/receipt.test.ts b/packages/arc-adapter/test/receipt.test.ts new file mode 100644 index 0000000..463d4c5 --- /dev/null +++ b/packages/arc-adapter/test/receipt.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { + TRANSFER_EVENT_TOPIC, + verifyReceipt, + type ExpectedSettlement, + type ReceiptLog, + type TransactionReceipt, +} from '../src/receipt.js'; + +const WALLET = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const USDC = '0x3600000000000000000000000000000000000000'; + +const EXPECTED: ExpectedSettlement = { + chainId: 5042002, + walletAddress: WALLET, + tokenContract: USDC, + recipient: RECIPIENT, + amountAtomic: 1_250_000n, +}; + +function topic(address: string): string { + return `0x${'0'.repeat(24)}${address.slice(2)}`; +} + +function word(value: bigint): string { + return `0x${value.toString(16).padStart(64, '0')}`; +} + +function transferLog(overrides: Partial = {}): ReceiptLog { + return { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(RECIPIENT)], + data: word(1_250_000n), + logIndex: 3, + ...overrides, + }; +} + +function receipt(overrides: Partial = {}): TransactionReceipt { + return { + transactionHash: `0x${'c'.repeat(64)}`, + chainId: 5042002, + from: WALLET, + to: USDC, + status: 1, + blockNumber: 100n, + blockHash: `0x${'d'.repeat(64)}`, + logs: [transferLog()], + ...overrides, + }; +} + +describe('confirmation', () => { + it('confirms an exact receipt and Transfer', () => { + expect(verifyReceipt(receipt(), EXPECTED)).toEqual({ + result: 'CONFIRMED', + transferLogIndex: 3, + }); + }); + + it('confirms regardless of address casing', () => { + const upper = receipt({ from: WALLET.toUpperCase().replace('0X', '0x') }); + expect(verifyReceipt(upper, EXPECTED).result).toBe('CONFIRMED'); + }); + + it('ignores unrelated logs alongside the expected Transfer', () => { + const noisy = receipt({ + logs: [ + { address: OTHER, topics: ['0xdeadbeef'], data: '0x', logIndex: 0 }, + transferLog(), + ], + }); + expect(verifyReceipt(noisy, EXPECTED).result).toBe('CONFIRMED'); + }); +}); + +describe('final revert', () => { + it('treats status 0 as terminal failure', () => { + expect(verifyReceipt(receipt({ status: 0, logs: [] }), EXPECTED).result).toBe('FINAL_REVERT'); + }); + + it('treats status 0 as revert even if a Transfer log is present', () => { + // A reverted transaction's logs are discarded on chain; trusting them + // would confirm a settlement that never happened. + expect(verifyReceipt(receipt({ status: 0 }), EXPECTED).result).toBe('FINAL_REVERT'); + }); +}); + +describe('success status is not confirmation', () => { + it('does not confirm a successful receipt with no logs', () => { + // The single most important case in this file. + const verdict = verifyReceipt(receipt({ logs: [] }), EXPECTED); + expect(verdict.result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm when the Transfer went to someone else', () => { + const redirected = receipt({ + logs: [transferLog({ topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(OTHER)] })], + }); + expect(verifyReceipt(redirected, EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm when the amount differs by one atomic unit', () => { + const short = receipt({ logs: [transferLog({ data: word(1_249_999n) })] }); + expect(verifyReceipt(short, EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm a Transfer emitted by a different token contract', () => { + // An attacker-deployed token can emit an identical-looking Transfer event. + const impostor = receipt({ logs: [transferLog({ address: OTHER })] }); + expect(verifyReceipt(impostor, EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm when the sender is not the execution wallet', () => { + expect(verifyReceipt(receipt({ from: OTHER }), EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm a receipt from a different chain', () => { + expect(verifyReceipt(receipt({ chainId: 1 }), EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm when the transaction called a different contract', () => { + expect(verifyReceipt(receipt({ to: OTHER }), EXPECTED).result).toBe('NOT_CONFIRMED'); + }); + + it('does not confirm two matching Transfers', () => { + // Two identical transfers means more value moved than was authorized. + const doubled = receipt({ logs: [transferLog(), transferLog({ logIndex: 4 })] }); + const verdict = verifyReceipt(doubled, EXPECTED); + expect(verdict.result).toBe('NOT_CONFIRMED'); + if (verdict.result === 'NOT_CONFIRMED') { + expect(verdict.detail).toMatch(/2 matching Transfer/); + } + }); + + it('does not confirm a malformed amount word', () => { + expect(verifyReceipt(receipt({ logs: [transferLog({ data: '0x1' })] }), EXPECTED).result).toBe( + 'NOT_CONFIRMED', + ); + }); + + it('does not confirm a log missing its indexed topics', () => { + const truncated = receipt({ logs: [transferLog({ topics: [TRANSFER_EVENT_TOPIC] })] }); + expect(verifyReceipt(truncated, EXPECTED).result).toBe('NOT_CONFIRMED'); + }); +}); diff --git a/packages/privy-adapter/src/index.ts b/packages/privy-adapter/src/index.ts index 94c157a..582923f 100644 --- a/packages/privy-adapter/src/index.ts +++ b/packages/privy-adapter/src/index.ts @@ -1,2 +1,4 @@ export * from './policy.js'; export * from './scope.js'; +export * from './request.js'; +export * from './policy-fixture.js'; diff --git a/packages/privy-adapter/src/policy-fixture.ts b/packages/privy-adapter/src/policy-fixture.ts new file mode 100644 index 0000000..44023d7 --- /dev/null +++ b/packages/privy-adapter/src/policy-fixture.ts @@ -0,0 +1,190 @@ +/** + * Expected Privy policy definition and its fingerprint (B02.3). + * + * Expresses the policy OneShot requires on the execution wallet, in the shape + * Privy's engine uses: rules over conditions, evaluated in order, with a final + * default-deny. + * + * Two properties matter: + * + * 1. **Default deny.** The last rule denies everything. A dimension nobody + * thought to constrain is refused rather than allowed. + * 2. **Fingerprint.** The policy lives in Privy's configuration, outside this + * repository, where it can be edited without a commit. The digest lets the + * readiness probe detect drift between the deployed policy and the one this + * build expects. + */ + +import { keccak256, toHex } from 'viem'; + +export type PolicyEffect = 'ALLOW' | 'DENY'; + +/** Field sources Privy exposes, verified 2026-09-07 against its documentation. */ +export type FieldSource = 'ethereum_transaction' | 'ethereum_calldata'; + +export interface PolicyCondition { + readonly fieldSource: FieldSource; + /** `to`, `value`, `chain_id`, or `function.param` for calldata. */ + readonly field: string; + readonly operator: 'eq' | 'in' | 'lte'; + readonly value: string | readonly string[]; +} + +export interface PolicyRule { + readonly name: string; + readonly effect: PolicyEffect; + readonly conditions: readonly PolicyCondition[]; +} + +export interface PolicyDefinition { + readonly version: 'settlement-policy-v1'; + readonly rules: readonly PolicyRule[]; +} + +export interface PolicyInputs { + readonly chainId: number; + readonly tokenContract: `0x${string}`; + readonly recipientAllowlist: readonly `0x${string}`[]; + /** Maximum atomic units for a single settlement. */ + readonly amountCapAtomic: bigint; +} + +/** + * Build the expected policy. + * + * The single ALLOW rule requires every condition to hold at once: right chain, + * right token contract, zero native value, the transfer method, an allowlisted + * recipient, and an amount at or under the cap. Anything failing one condition + * falls through to the default DENY. + */ +export function buildExpectedPolicy(inputs: PolicyInputs): PolicyDefinition { + return { + version: 'settlement-policy-v1', + rules: [ + { + name: 'allow-constrained-usdc-settlement', + effect: 'ALLOW', + conditions: [ + { + fieldSource: 'ethereum_transaction', + field: 'chain_id', + operator: 'eq', + value: String(inputs.chainId), + }, + { + fieldSource: 'ethereum_transaction', + field: 'to', + operator: 'eq', + value: inputs.tokenContract.toLowerCase(), + }, + { + // Settlement moves ERC-20 USDC. Native value riding along would be + // a second, unbounded transfer of the gas asset. + fieldSource: 'ethereum_transaction', + field: 'value', + operator: 'eq', + value: '0', + }, + { + fieldSource: 'ethereum_calldata', + field: 'transfer', + operator: 'eq', + value: 'transfer', + }, + { + fieldSource: 'ethereum_calldata', + field: 'transfer.to', + operator: 'in', + value: inputs.recipientAllowlist.map((address) => address.toLowerCase()), + }, + { + fieldSource: 'ethereum_calldata', + field: 'transfer.amount', + operator: 'lte', + value: inputs.amountCapAtomic.toString(10), + }, + ], + }, + { + // Must remain last. Anything not explicitly allowed above is refused. + name: 'default-deny', + effect: 'DENY', + conditions: [], + }, + ], + }; +} + +/** The dimensions the ALLOW rule must constrain for the policy to be sound. */ +export const REQUIRED_POLICY_FIELDS: readonly string[] = [ + 'chain_id', + 'to', + 'value', + 'transfer', + 'transfer.to', + 'transfer.amount', +]; + +/** + * Deterministic digest of a policy definition. + * + * Readiness compares this against the digest of the deployed policy. A + * mismatch means the remote policy drifted from what this build assumes, which + * must block settlement rather than be discovered during a payment. + */ +export function policyDigest(policy: PolicyDefinition): `0x${string}` { + const canonical = JSON.stringify([ + policy.version, + policy.rules.map((rule) => [ + rule.name, + rule.effect, + rule.conditions.map((condition) => [ + condition.fieldSource, + condition.field, + condition.operator, + // Array.isArray widens to any[], so narrow on the declared union + // instead: a list value is order-insensitive, a scalar is not. + typeof condition.value === 'string' ? condition.value : [...condition.value].sort(), + ]), + ]), + ]); + return keccak256(toHex(canonical)); +} + +export type PolicySoundness = + | { readonly sound: true } + | { readonly sound: false; readonly reason: string }; + +/** + * Check that a policy is structurally safe before it is trusted. + * + * Catches the two ways a policy silently stops protecting anything: losing its + * terminal default-deny, or dropping a constrained dimension. + */ +export function assessPolicySoundness(policy: PolicyDefinition): PolicySoundness { + const last = policy.rules.at(-1); + if (!last || last.effect !== 'DENY' || last.conditions.length > 0) { + return { + sound: false, + reason: 'The final rule must be an unconditional default deny.', + }; + } + + const allowRules = policy.rules.filter((rule) => rule.effect === 'ALLOW'); + if (allowRules.length === 0) { + return { sound: false, reason: 'The policy allows nothing and cannot settle.' }; + } + + for (const rule of allowRules) { + const fields = new Set(rule.conditions.map((condition) => condition.field)); + const missing = REQUIRED_POLICY_FIELDS.filter((field) => !fields.has(field)); + if (missing.length > 0) { + return { + sound: false, + reason: `ALLOW rule "${rule.name}" leaves ${missing.join(', ')} unconstrained.`, + }; + } + } + + return { sound: true }; +} diff --git a/packages/privy-adapter/src/request.ts b/packages/privy-adapter/src/request.ts new file mode 100644 index 0000000..13db5d5 --- /dev/null +++ b/packages/privy-adapter/src/request.ts @@ -0,0 +1,177 @@ +/** + * Canonical settlement request identity (B02.1, B02.2). + * + * One Business Intent must produce one byte-stable request. Everything here is + * deterministic: the same intent yields the same calldata, the same body + * fingerprint, the same idempotency key, and the same reference ID on every + * process, every worker, and every restart. + * + * That determinism is the point. `.agent/SECURITY_INVARIANTS.md` requires a + * stable identity across retries, and the provider idempotency key is only + * useful if independent workers derive the identical key for the identical + * obligation. + */ + +import { keccak256, toHex } from 'viem'; +import { buildSettlementTransaction, type ExpectedScope } from './scope.js'; + +/** The immutable inputs that define one settlement obligation. */ +export interface SettlementIntent { + /** Caller-supplied stable identity. Survives retries and restarts. */ + readonly businessIntentId: string; + readonly chainId: number; + readonly tokenContract: `0x${string}`; + readonly recipient: `0x${string}`; + /** Atomic units at the ERC-20 six-decimal precision. */ + readonly amountAtomic: bigint; +} + +export interface CanonicalRequest { + readonly businessIntentId: string; + /** Deterministic serialization the fingerprint is computed over. */ + readonly canonicalBody: string; + /** keccak256 of the canonical body. Detects any payload divergence. */ + readonly payloadFingerprint: `0x${string}`; + /** Stable key sent to Privy so a replay collapses provider-side too. */ + readonly idempotencyKey: `0x${string}`; + /** Stable lookup identity for evidence recovery. */ + readonly referenceId: string; + readonly chainId: number; + readonly to: `0x${string}`; + readonly value: bigint; + readonly data: `0x${string}`; +} + +export class RequestError extends Error { + constructor( + message: string, + readonly code: + | 'EMPTY_INTENT_ID' + | 'INTENT_ID_TOO_LONG' + | 'AMOUNT_NOT_POSITIVE' + | 'AMOUNT_TOO_LARGE' + | 'IDEMPOTENCY_KEY_REUSED', + ) { + super(message); + this.name = 'RequestError'; + } +} + +/** `milestones/CONTRACTS.md`: the intent id is an opaque, length-bounded string. */ +const MAX_INTENT_ID_LENGTH = 128; + +/** uint256 ceiling. An amount at or above this cannot be encoded. */ +const MAX_UINT256 = (1n << 256n) - 1n; + +/** + * Serialize an intent deterministically. + * + * Field order is fixed and written by hand rather than taken from + * `JSON.stringify` over an object literal, because key order there depends on + * construction order. Two workers building the same intent differently would + * otherwise produce different fingerprints for the same obligation. + * + * The address fields are lowercased so that checksummed and non-checksummed + * spellings of one address cannot fingerprint differently. + */ +export function canonicalizeIntent(intent: SettlementIntent): string { + return JSON.stringify([ + ['businessIntentId', intent.businessIntentId], + ['chainId', intent.chainId], + ['tokenContract', intent.tokenContract.toLowerCase()], + ['recipient', intent.recipient.toLowerCase()], + ['amountAtomic', intent.amountAtomic.toString(10)], + ]); +} + +function validate(intent: SettlementIntent): void { + if (intent.businessIntentId.trim() === '') { + throw new RequestError('business_intent_id must not be empty.', 'EMPTY_INTENT_ID'); + } + if (intent.businessIntentId.length > MAX_INTENT_ID_LENGTH) { + throw new RequestError( + `business_intent_id exceeds ${MAX_INTENT_ID_LENGTH} characters.`, + 'INTENT_ID_TOO_LONG', + ); + } + if (intent.amountAtomic <= 0n) { + throw new RequestError('Settlement amount must be greater than zero.', 'AMOUNT_NOT_POSITIVE'); + } + if (intent.amountAtomic > MAX_UINT256) { + throw new RequestError('Settlement amount exceeds uint256.', 'AMOUNT_TOO_LARGE'); + } +} + +/** + * Build the canonical request for an intent. + * + * Uses the direct ERC-20 transfer path. The Arc Memo forwarded call is not + * built: B01.3 recorded it `NOT_SUPPORTED` because Privy policy cannot + * constrain the forwarded recipient and amount. See + * `.agent/research/20260907-b01-arc-privy-verification.md`. + */ +export function buildCanonicalRequest(intent: SettlementIntent): CanonicalRequest { + validate(intent); + + const scope: ExpectedScope = { + chainId: intent.chainId, + tokenContract: intent.tokenContract, + recipient: intent.recipient, + amountAtomic: intent.amountAtomic, + }; + const transaction = buildSettlementTransaction(scope); + + const canonicalBody = canonicalizeIntent(intent); + const payloadFingerprint = keccak256(toHex(canonicalBody)); + + return { + businessIntentId: intent.businessIntentId, + canonicalBody, + payloadFingerprint, + // Derived from the fingerprint, so the same obligation always produces the + // same provider key and a duplicate submission collapses at Privy too. + idempotencyKey: payloadFingerprint, + referenceId: `oneshot-${intent.businessIntentId}`, + chainId: transaction.chainId, + to: transaction.to, + value: transaction.value, + data: transaction.data, + }; +} + +/** + * Refuse reuse of one idempotency key with a different body. + * + * This is the `INTENT_PAYLOAD_CONFLICT` rule from `milestones/CONTRACTS.md` + * section 3 at the adapter boundary. Sending a changed body under a previously + * used key is how a second, different payment gets authorized under the + * identity of the first. + * + * Because the key here is the fingerprint itself, a differing body yields a + * differing key and this can only trigger on a caller-supplied mismatch. It is + * checked anyway: the key derivation is an implementation choice that could + * change, and this invariant must outlive it. + */ +export function assertIdempotencyKeyBinding( + request: CanonicalRequest, + previouslySeen: { readonly idempotencyKey: string; readonly payloadFingerprint: string }, +): void { + if ( + previouslySeen.idempotencyKey === request.idempotencyKey && + previouslySeen.payloadFingerprint !== request.payloadFingerprint + ) { + throw new RequestError( + 'The same idempotency key was reused with a different payload fingerprint.', + 'IDEMPOTENCY_KEY_REUSED', + ); + } +} + +/** + * Provider idempotency window, documented as supplemental only (B02.2). + * + * Privy's key deduplicates for a bounded period. OneShot's durable state is the + * authority for at-most-once settlement and remains so past this window; the + * provider key is defence in depth, never the lock. + */ +export const PROVIDER_IDEMPOTENCY_WINDOW_HOURS = 24; diff --git a/packages/privy-adapter/test/policy-fixture.test.ts b/packages/privy-adapter/test/policy-fixture.test.ts new file mode 100644 index 0000000..3318a56 --- /dev/null +++ b/packages/privy-adapter/test/policy-fixture.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { + REQUIRED_POLICY_FIELDS, + assessPolicySoundness, + buildExpectedPolicy, + policyDigest, + type PolicyDefinition, + type PolicyInputs, +} from '../src/policy-fixture.js'; + +const INPUTS: PolicyInputs = { + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000', + recipientAllowlist: ['0x1111111111111111111111111111111111111111'], + amountCapAtomic: 1_000_000n, +}; + +const POLICY = buildExpectedPolicy(INPUTS); + +describe('policy shape', () => { + it('constrains every required dimension in the allow rule', () => { + const allow = POLICY.rules.find((rule) => rule.effect === 'ALLOW'); + const fields = allow?.conditions.map((condition) => condition.field) ?? []; + for (const required of REQUIRED_POLICY_FIELDS) { + expect(fields).toContain(required); + } + }); + + it('ends with an unconditional default deny', () => { + const last = POLICY.rules.at(-1); + expect(last?.effect).toBe('DENY'); + expect(last?.conditions).toEqual([]); + }); + + it('pins native value to zero', () => { + const allow = POLICY.rules.find((rule) => rule.effect === 'ALLOW'); + const value = allow?.conditions.find((condition) => condition.field === 'value'); + expect(value).toMatchObject({ operator: 'eq', value: '0' }); + }); + + it('caps the amount rather than pinning it', () => { + // A settlement may be any amount at or under the human-approved cap. + const allow = POLICY.rules.find((rule) => rule.effect === 'ALLOW'); + const amount = allow?.conditions.find((c) => c.field === 'transfer.amount'); + expect(amount?.operator).toBe('lte'); + }); +}); + +describe('soundness', () => { + it('accepts the built policy', () => { + expect(assessPolicySoundness(POLICY)).toEqual({ sound: true }); + }); + + it('rejects a policy whose default deny was removed', () => { + // The classic silent failure: everything still looks allowed, and + // everything unlisted becomes permitted. + const broken: PolicyDefinition = { + ...POLICY, + rules: POLICY.rules.filter((rule) => rule.effect !== 'DENY'), + }; + expect(assessPolicySoundness(broken)).toMatchObject({ sound: false }); + }); + + it('rejects a policy whose deny rule is no longer last', () => { + const reordered: PolicyDefinition = { ...POLICY, rules: [...POLICY.rules].reverse() }; + expect(assessPolicySoundness(reordered)).toMatchObject({ sound: false }); + }); + + it('rejects a default deny that carries conditions', () => { + // A conditional deny is not a default deny. + const conditional: PolicyDefinition = { + ...POLICY, + rules: [ + ...POLICY.rules.slice(0, -1), + { + name: 'default-deny', + effect: 'DENY', + conditions: [ + { fieldSource: 'ethereum_transaction', field: 'to', operator: 'eq', value: '0x0' }, + ], + }, + ], + }; + expect(assessPolicySoundness(conditional)).toMatchObject({ sound: false }); + }); + + it.each(REQUIRED_POLICY_FIELDS)('rejects a policy missing the %s constraint', (field) => { + const weakened: PolicyDefinition = { + ...POLICY, + rules: POLICY.rules.map((rule) => + rule.effect === 'ALLOW' + ? { ...rule, conditions: rule.conditions.filter((c) => c.field !== field) } + : rule, + ), + }; + const result = assessPolicySoundness(weakened); + expect(result.sound).toBe(false); + if (!result.sound) expect(result.reason).toContain(field); + }); + + it('rejects a policy that allows nothing', () => { + const denyOnly: PolicyDefinition = { + ...POLICY, + rules: POLICY.rules.filter((rule) => rule.effect === 'DENY'), + }; + expect(assessPolicySoundness(denyOnly)).toMatchObject({ sound: false }); + }); +}); + +describe('digest', () => { + it('is stable for identical inputs', () => { + expect(policyDigest(buildExpectedPolicy(INPUTS))).toBe(policyDigest(POLICY)); + }); + + it('is independent of recipient allowlist ordering', () => { + // Two operators listing the same recipients in different order describe + // the same policy and must not read as drift. + const a = buildExpectedPolicy({ + ...INPUTS, + recipientAllowlist: ['0x1111111111111111111111111111111111111111', '0x2222222222222222222222222222222222222222'], + }); + const b = buildExpectedPolicy({ + ...INPUTS, + recipientAllowlist: ['0x2222222222222222222222222222222222222222', '0x1111111111111111111111111111111111111111'], + }); + expect(policyDigest(a)).toBe(policyDigest(b)); + }); + + it.each<[string, Partial]>([ + ['a different chain', { chainId: 1 }], + ['a different token', { tokenContract: '0x4600000000000000000000000000000000000000' }], + ['a raised cap', { amountCapAtomic: 2_000_000n }], + ['an extra recipient', { recipientAllowlist: ['0x1111111111111111111111111111111111111111', '0x3333333333333333333333333333333333333333'] }], + ])('changes when the policy changes: %s', (_label, override) => { + // Each of these is a real widening of what the wallet may do, so readiness + // must see drift rather than silently accept the deployed policy. + expect(policyDigest(buildExpectedPolicy({ ...INPUTS, ...override }))).not.toBe( + policyDigest(POLICY), + ); + }); +}); diff --git a/packages/privy-adapter/test/request.test.ts b/packages/privy-adapter/test/request.test.ts new file mode 100644 index 0000000..93ea01c --- /dev/null +++ b/packages/privy-adapter/test/request.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + PROVIDER_IDEMPOTENCY_WINDOW_HOURS, + RequestError, + assertIdempotencyKeyBinding, + buildCanonicalRequest, + canonicalizeIntent, + type SettlementIntent, +} from '../src/request.js'; + +const INTENT: SettlementIntent = { + businessIntentId: '018f-example-stable-id', + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000', + recipient: '0x1111111111111111111111111111111111111111', + amountAtomic: 1_250_000n, +}; + +describe('determinism', () => { + it('produces byte-identical output for the same intent', () => { + const a = buildCanonicalRequest(INTENT); + const b = buildCanonicalRequest({ ...INTENT }); + expect(a).toEqual(b); + }); + + it('is a golden vector, stable across builds', () => { + // Pinning the exact bytes. If a refactor changes any of these, every + // in-flight idempotency key changes with it, so this must fail loudly. + const request = buildCanonicalRequest(INTENT); + expect(request.canonicalBody).toBe( + '[["businessIntentId","018f-example-stable-id"],["chainId",5042002],' + + '["tokenContract","0x3600000000000000000000000000000000000000"],' + + '["recipient","0x1111111111111111111111111111111111111111"],' + + '["amountAtomic","1250000"]]', + ); + expect(request.data).toBe( + '0xa9059cbb' + + '0000000000000000000000001111111111111111111111111111111111111111' + + '00000000000000000000000000000000000000000000000000000000001312d0', + ); + expect(request.value).toBe(0n); + expect(request.referenceId).toBe('oneshot-018f-example-stable-id'); + }); + + it('does not depend on the order fields were written', () => { + // Guards against JSON.stringify key-order dependence: two workers building + // the same obligation must fingerprint identically. + const reordered: SettlementIntent = { + amountAtomic: INTENT.amountAtomic, + recipient: INTENT.recipient, + tokenContract: INTENT.tokenContract, + chainId: INTENT.chainId, + businessIntentId: INTENT.businessIntentId, + }; + expect(canonicalizeIntent(reordered)).toBe(canonicalizeIntent(INTENT)); + }); + + it('fingerprints checksummed and lowercase addresses identically', () => { + const checksummed: SettlementIntent = { + ...INTENT, + recipient: INTENT.recipient.toUpperCase().replace('0X', '0x') as `0x${string}`, + }; + expect(buildCanonicalRequest(checksummed).payloadFingerprint).toBe( + buildCanonicalRequest(INTENT).payloadFingerprint, + ); + }); +}); + +describe('fingerprint sensitivity', () => { + it.each<[string, Partial]>([ + ['a different recipient', { recipient: '0x2222222222222222222222222222222222222222' }], + ['a different amount', { amountAtomic: 1_250_001n }], + ['a different chain', { chainId: 1 }], + ['a different token', { tokenContract: '0x4600000000000000000000000000000000000000' }], + ['a different intent id', { businessIntentId: 'other-id' }], + ])('changes the fingerprint for %s', (_label, override) => { + expect(buildCanonicalRequest({ ...INTENT, ...override }).payloadFingerprint).not.toBe( + buildCanonicalRequest(INTENT).payloadFingerprint, + ); + }); + + it('derives the idempotency key from the fingerprint', () => { + const request = buildCanonicalRequest(INTENT); + expect(request.idempotencyKey).toBe(request.payloadFingerprint); + }); +}); + +describe('validation', () => { + it.each<[string, Partial, string]>([ + ['an empty intent id', { businessIntentId: '' }, 'EMPTY_INTENT_ID'], + ['a whitespace intent id', { businessIntentId: ' ' }, 'EMPTY_INTENT_ID'], + ['an overlong intent id', { businessIntentId: 'x'.repeat(129) }, 'INTENT_ID_TOO_LONG'], + ['a zero amount', { amountAtomic: 0n }, 'AMOUNT_NOT_POSITIVE'], + ['a negative amount', { amountAtomic: -1n }, 'AMOUNT_NOT_POSITIVE'], + ])('rejects %s', (_label, override, code) => { + expect(() => buildCanonicalRequest({ ...INTENT, ...override })).toThrow( + expect.objectContaining({ code }), + ); + }); + + it('rejects an amount above uint256', () => { + expect(() => buildCanonicalRequest({ ...INTENT, amountAtomic: 1n << 256n })).toThrow( + RequestError, + ); + }); + + it('accepts an amount beyond float safety exactly', () => { + const large = { ...INTENT, amountAtomic: 9_007_199_254_740_993n }; + expect(buildCanonicalRequest(large).canonicalBody).toContain('9007199254740993'); + }); +}); + +describe('idempotency key binding', () => { + it('accepts a replay of the identical request', () => { + const request = buildCanonicalRequest(INTENT); + expect(() => { + assertIdempotencyKeyBinding(request, { + idempotencyKey: request.idempotencyKey, + payloadFingerprint: request.payloadFingerprint, + }); + }).not.toThrow(); + }); + + it('refuses the same key carrying a different payload', () => { + // The INTENT_PAYLOAD_CONFLICT rule at the adapter boundary: reusing a key + // with a changed body is how a second, different payment gets authorized + // under the identity of the first. + const request = buildCanonicalRequest(INTENT); + expect(() => { + assertIdempotencyKeyBinding(request, { + idempotencyKey: request.idempotencyKey, + payloadFingerprint: '0xdifferentfingerprint', + }); + }).toThrow(expect.objectContaining({ code: 'IDEMPOTENCY_KEY_REUSED' })); + }); + + it('ignores an unrelated key', () => { + const request = buildCanonicalRequest(INTENT); + expect(() => { + assertIdempotencyKeyBinding(request, { + idempotencyKey: '0xsomeotherkey', + payloadFingerprint: '0xdifferentfingerprint', + }); + }).not.toThrow(); + }); +}); + +describe('provider window', () => { + it('documents the window as supplemental only', () => { + // OneShot durable state is the authority for at-most-once settlement and + // remains so past this window. The provider key is defence in depth. + expect(PROVIDER_IDEMPOTENCY_WINDOW_HOURS).toBe(24); + }); +}); From e6a15ee33177495125b1623238d3f2fe35492253 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 16:15:20 +0200 Subject: [PATCH 2/6] feat(testkit): add offline settlement harness and fixture capture Implements B03 offline closure for the Coder B lane. Live execution is not performed and is recorded as LIVE_NOT_RUN in docs/settlement/LIVE_EVIDENCE.md, which the packet explicitly permits. Harness (B03.2). Runs the whole adapter workflow against simulated providers with no network and no credential. Request identity is persisted before the provider call rather than after: the window between that write and the provider returning is exactly where a crash produces UNKNOWN, and the record is what makes it recoverable. Negative suite (B03.3). Every denial family asserts against a broadcast counter, not just the returned enum, because a denial that returned the right value while still broadcasting has not denied anything. Duplicate delivery, ten sequential retries, and ten parallel workers sharing durable state each produce exactly one broadcast. An ambiguous outcome does not grant a fresh submission right, so the dangerous retry after a possible payment cannot happen. Ambiguous scenarios deliberately increment the broadcast counter: counting them as non-broadcasts would understate exposure. Fixture capture (B03.5). Allowlists the fields needed to reproduce a decision so a new provider field cannot silently start appearing in committed fixtures, then redacts what survives and asserts no secrets at capture time. Fixes a redaction bug the round-trip test caught. The rule treating any 32-byte hex value as secret-shaped also matched transaction hashes, block hashes, event topics, ABI-encoded words, and payload fingerprints, so sanitizing a receipt destroyed the very evidence it was captured to preserve and the fixture no longer confirmed the settlement. Shape alone cannot separate a private key from a keccak hash, so the rule now applies only outside named hash-bearing fields; key material is still caught by field name regardless of shape, and a credential hidden inside a hash-bearing field is still redacted. Also adds the human-run provider setup guide, which keeps account creation, secret handling, policy mutation, and funding with a person and leaves only the read-only readiness check automatable. --- docs/settlement/LIVE_EVIDENCE.md | 71 ++++++++ docs/settlement/PROVIDER_SETUP.md | 120 +++++++++++++ packages/arc-adapter/src/redaction.ts | 62 +++++-- packages/arc-adapter/test/redaction.test.ts | 47 +++++ packages/testkit-settlement/package-lock.json | 25 +++ packages/testkit-settlement/package.json | 3 +- .../testkit-settlement/src/fixture-capture.ts | 105 +++++++++++ packages/testkit-settlement/src/harness.ts | 158 +++++++++++++++++ packages/testkit-settlement/src/index.ts | 3 + .../src/provider-simulator.ts | 133 ++++++++++++++ .../test/fixture-capture.test.ts | 116 ++++++++++++ .../testkit-settlement/test/harness.test.ts | 166 ++++++++++++++++++ 12 files changed, 997 insertions(+), 12 deletions(-) create mode 100644 docs/settlement/LIVE_EVIDENCE.md create mode 100644 docs/settlement/PROVIDER_SETUP.md create mode 100644 packages/testkit-settlement/src/fixture-capture.ts create mode 100644 packages/testkit-settlement/src/harness.ts create mode 100644 packages/testkit-settlement/src/provider-simulator.ts create mode 100644 packages/testkit-settlement/test/fixture-capture.test.ts create mode 100644 packages/testkit-settlement/test/harness.test.ts diff --git a/docs/settlement/LIVE_EVIDENCE.md b/docs/settlement/LIVE_EVIDENCE.md new file mode 100644 index 0000000..62bef0d --- /dev/null +++ b/docs/settlement/LIVE_EVIDENCE.md @@ -0,0 +1,71 @@ +# Live settlement evidence + +B03 handoff artifact. + +## Status + +`LIVE_NOT_RUN` + +No real Arc Testnet settlement has been executed. No Privy application, +execution wallet, policy, or funded testnet account has been provisioned for +this build. + +This is the expected state. B03 closes on its offline criteria, and +`milestones/coder-b/B03-live-settlement-harness.md` states that live +availability does not block packet closure. Live execution is tracked as +project Gate P4 evidence. + +## Why it has not run + +Provisioning requires a human: creating a Privy application, holding an app +secret, attaching a wallet policy, and funding a testnet account are all +actions an agent must not perform. `docs/settlement/PROVIDER_SETUP.md` is the +procedure; nobody has run it yet. + +## What is proven without it + +The offline harness exercises the complete adapter workflow against simulated +providers with a deterministic broadcast counter: + +- Every policy denial family produces **zero** external broadcasts. +- Duplicate delivery, ten sequential retries, and ten parallel workers sharing + durable state each produce **exactly one** broadcast. +- An ambiguous outcome does not grant a fresh submission right, so the + dangerous retry after a possible payment cannot happen. +- Every ambiguous or unrecognized provider response classifies as + `POSSIBLY_SUBMITTED`. +- Sanitized fixtures reproduce the same verifier and classifier results as the + raw responses they were captured from. + +Command: + +```bash +cd packages/testkit-settlement && npm run check +``` + +## What is not proven + +Simulators prove the adapter's logic, not the provider's behaviour. Still +unverified against reality: + +- That a Privy policy configured as `buildExpectedPolicy` describes actually + denies each wrong dimension. The policy shape is modelled from Privy's + documentation, not observed. +- That Arc Testnet receipts and Transfer logs have the exact shape the verifier + expects. +- That the documented Privy wallet and policy identifier formats match the + conservative shape check in the readiness probe. +- Real latency, rate limits, and error bodies. + +Per `.agents/skills/sponsor-qualification/SKILL.md`, no sponsor or +qualification claim may be made from fixtures alone. Until this file records a +sanitized live transaction, the Privy and Arc integration claims are +`NOT VERIFIED`. + +## To update this file + +Run `docs/settlement/PROVIDER_SETUP.md`, execute one allowed settlement, then +replace the status above with `LIVE_RUN` plus the sanitized transaction hash, +block number, explorer URL, and the observed denial counts. Capture the +responses through `captureReceiptFixture` so no credential reaches the +repository. diff --git a/docs/settlement/PROVIDER_SETUP.md b/docs/settlement/PROVIDER_SETUP.md new file mode 100644 index 0000000..580d61b --- /dev/null +++ b/docs/settlement/PROVIDER_SETUP.md @@ -0,0 +1,120 @@ +# Provider setup (human-run) + +B03.1. Prepares a Privy application, execution wallet, policy, and Arc Testnet +funding so a real settlement can be captured as Gate P4 evidence. + +**A human runs every step here.** Agents must not create accounts, enter +credentials, mutate policy, or move funds. An agent may run the read-only +verification in section 7 and report results. + +Testnet only. Nothing in this guide applies to mainnet, and Arc mainnet +parameters are unpublished. See `docs/settlement/SETTLEMENT_CONFIG_V1.md`. + +## 1. Before you start + +You need a Privy account, a terminal, and somewhere to store secrets that is +not this repository. Budget about 30 minutes. + +At no point paste a secret into a chat, an issue, a PR, a fixture, a log, or a +review prompt. `.agent/SECURITY_INVARIANTS.md` treats that as a breach, and a +committed secret must be rotated, not deleted. + +## 2. Privy application + +1. Create a Privy application for OneShot. +2. Record the **app ID**. It is a public identifier, safe to log. +3. Create an **app secret**. This is the one true credential in the system. + Put it straight into your secret store; do not write it to a file first. + +## 3. Execution wallet + +1. Create a server wallet. This is the only wallet that will sign settlement. +2. Record the **wallet ID** and the **wallet address**. +3. Configure the owner or key quorum according to your organisation's rules. A + single-owner wallet is acceptable for a testnet demo and is not acceptable + for anything holding real value. + +## 4. Recipient and cap + +Two human decisions, both deliberate: + +- **Recipient allowlist.** The addresses settlement may pay. Keep it as short + as the demo allows. An empty list settles nothing, which is the safe default. +- **Per-settlement cap.** The maximum atomic units for one settlement, in + six-decimal USDC atomic units. `1000000` is one USDC. + +These are the two values that bound the blast radius if everything else fails. + +## 5. Policy + +Attach a policy to the execution wallet constraining all six dimensions: + +| Dimension | Constraint | +| --- | --- | +| Chain | equals `5042002` | +| Destination contract | equals the USDC interface `0x3600000000000000000000000000000000000000` | +| Native value | equals `0` | +| Method | `transfer` | +| Recipient | in your allowlist | +| Amount | at or below your cap | + +The policy must end with a **default deny**. Without it, anything the rules do +not mention is permitted. + +`buildExpectedPolicy` in `@oneshot/privy-adapter` produces this shape, and +`policyDigest` produces a fingerprint you can compare against later to detect +drift. Record the **policy ID** and the digest. + +## 6. Funding + +1. Fund the execution wallet from the Circle faucet at + for Arc Testnet. +2. Fund only what the demo needs. +3. Confirm the balance on the explorer at . + +Arc's native gas asset uses 18 decimals while the USDC ERC-20 interface uses 6. +They are both called USDC. Read balances carefully. + +## 7. Verification (read-only, safe to automate) + +Set the variables from `packages/arc-adapter/.env.example` in your shell or +secret store, then run the readiness probe. It performs no mutation and prints +no credential: + +```bash +cd packages/arc-adapter +npm run check +``` + +A `MISMATCH` result means a value is wrong and a human must fix it. It must +never be retried into working. An `UNAVAILABLE` result means the endpoint could +not be reached and may resolve on its own. + +## 8. Storing the values + +| Value | Classification | Where it goes | +| --- | --- | --- | +| App ID | public | configuration | +| App secret | **secret** | secret store only | +| Wallet ID | public | configuration | +| Wallet address | public | configuration and evidence | +| Policy ID | public | configuration | +| Recipient allowlist | human-only | configuration | +| Cap | human-only | configuration | + +Never commit a real value. `packages/arc-adapter/.env.example` holds +placeholders only and is generated from the config schema. + +## 9. Cleanup and rotation + +When the demo is finished: + +1. Return or drain remaining testnet funds. +2. Rotate the app secret, and rotate immediately if it was ever pasted anywhere + outside the secret store. +3. Narrow or empty the recipient allowlist. +4. Keep the wallet and policy if you need the evidence trail; disable the + policy's allow rule to make the wallet inert. + +Rotation is the response to a suspected leak. Deleting the message that +contained it is not. diff --git a/packages/arc-adapter/src/redaction.ts b/packages/arc-adapter/src/redaction.ts index c0a84ca..10ade7b 100644 --- a/packages/arc-adapter/src/redaction.ts +++ b/packages/arc-adapter/src/redaction.ts @@ -52,7 +52,39 @@ const FORBIDDEN_VALUE_PATTERNS: readonly RegExp[] = [ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./, // JWT /\bBearer\s+[A-Za-z0-9._-]{10,}/i, - /\b0x[a-fA-F0-9]{64}\b/, // 32-byte hex: private key or signing material +]; + +/** + * 32-byte hex, which is the shape of both a private key and a keccak hash. + * + * Shape alone cannot tell them apart, so this is applied only OUTSIDE the + * fields below. Applying it everywhere destroyed the evidence: transaction + * hashes, block hashes, event topics, ABI-encoded words, and payload + * fingerprints are all 32-byte hex, and redacting them left fixtures that no + * longer proved the settlement they were captured to prove. + */ +const THIRTY_TWO_BYTE_HEX = /\b0x[a-fA-F0-9]{64}\b/; + +/** + * Fields where 32-byte hex is the expected, publishable content. + * + * Key material never legitimately travels under these names; it travels under + * the FORBIDDEN_KEY_PATTERNS names, which are redacted regardless of shape. + */ +const HASH_BEARING_FIELDS: readonly string[] = [ + 'transactionhash', + 'blockhash', + 'parenthash', + 'hash', + 'topics', + 'data', + 'payloadfingerprint', + 'idempotencykey', + 'fingerprint', + 'digest', + 'replayof', + 'memoid', + 'root', ]; /** @@ -68,8 +100,14 @@ function keyIsForbidden(key: string): boolean { return FORBIDDEN_KEY_PATTERNS.some((pattern) => normalized.includes(normalizeKey(pattern))); } -function valueLooksLikeSecret(value: string): boolean { - return FORBIDDEN_VALUE_PATTERNS.some((pattern) => pattern.test(value)); +function valueLooksLikeSecret(value: string, fieldName?: string): boolean { + if (FORBIDDEN_VALUE_PATTERNS.some((pattern) => pattern.test(value))) return true; + + // Only treat bare 32-byte hex as secret when it is NOT in a field that + // legitimately carries a hash. + const field = fieldName === undefined ? undefined : normalizeKey(fieldName); + const hashBearing = field !== undefined && HASH_BEARING_FIELDS.includes(field); + return !hashBearing && THIRTY_TWO_BYTE_HEX.test(value); } /** @@ -79,13 +117,13 @@ function valueLooksLikeSecret(value: string): boolean { * depth. Depth is bounded because a hostile or malformed provider response must * not be able to exhaust the stack inside the logging path. */ -export function redact(value: unknown, depth = 0): unknown { +export function redact(value: unknown, depth = 0, fieldName?: string): unknown { if (depth > 12) return REDACTED; if (value === null || value === undefined) return value; if (typeof value === 'string') { - return valueLooksLikeSecret(value) ? REDACTED : value; + return valueLooksLikeSecret(value, fieldName) ? REDACTED : value; } if (typeof value === 'bigint') return value.toString(10); @@ -93,13 +131,15 @@ export function redact(value: unknown, depth = 0): unknown { if (typeof value === 'number' || typeof value === 'boolean') return value; if (Array.isArray(value)) { - return value.map((entry) => redact(entry, depth + 1)); + // Array entries inherit the parent field name, so `topics: [...]` stays + // hash-bearing for every element. + return value.map((entry) => redact(entry, depth + 1, fieldName)); } if (typeof value === 'object') { const out: Record = {}; for (const [key, entry] of Object.entries(value as Record)) { - out[key] = keyIsForbidden(key) ? REDACTED : redact(entry, depth + 1); + out[key] = keyIsForbidden(key) ? REDACTED : redact(entry, depth + 1, key); } return out; } @@ -114,11 +154,11 @@ export function redact(value: unknown, depth = 0): unknown { * Used as a test guard on every committed fixture so a leak fails the suite * instead of reaching the repository. */ -export function assertNoSecrets(value: unknown, path = '$'): void { +export function assertNoSecrets(value: unknown, path = '$', fieldName?: string): void { if (value === null || value === undefined) return; if (typeof value === 'string') { - if (valueLooksLikeSecret(value)) { + if (valueLooksLikeSecret(value, fieldName)) { throw new Error(`Secret-shaped value found at ${path}.`); } return; @@ -126,7 +166,7 @@ export function assertNoSecrets(value: unknown, path = '$'): void { if (Array.isArray(value)) { value.forEach((entry, index) => { - assertNoSecrets(entry, `${path}[${index}]`); + assertNoSecrets(entry, `${path}[${index}]`, fieldName); }); return; } @@ -139,7 +179,7 @@ export function assertNoSecrets(value: unknown, path = '$'): void { } continue; } - assertNoSecrets(entry, `${path}.${key}`); + assertNoSecrets(entry, `${path}.${key}`, key); } } } diff --git a/packages/arc-adapter/test/redaction.test.ts b/packages/arc-adapter/test/redaction.test.ts index 0facfad..835a8f4 100644 --- a/packages/arc-adapter/test/redaction.test.ts +++ b/packages/arc-adapter/test/redaction.test.ts @@ -113,3 +113,50 @@ describe('assertNoSecrets', () => { }).not.toThrow(); }); }); + +describe('32-byte hex is context-sensitive', () => { + // Shape alone cannot distinguish a private key from a keccak hash. Redacting + // all 32-byte hex destroyed the evidence: transaction hashes, block hashes, + // topics, ABI words, and payload fingerprints are all this shape, and a + // fixture with them removed no longer proves the settlement it captured. + const HASH = '0x' + 'a'.repeat(64); + + it.each([ + 'transactionHash', + 'blockHash', + 'payloadFingerprint', + 'idempotencyKey', + 'data', + 'digest', + ])('preserves 32-byte hex in the hash-bearing field %s', (field) => { + const out = redact({ [field]: HASH }) as Record; + expect(out[field]).toBe(HASH); + }); + + it('preserves every entry of a topics array', () => { + const out = redact({ topics: [HASH, HASH] }) as Record; + expect(out.topics).toEqual([HASH, HASH]); + }); + + it('still redacts 32-byte hex under an unrecognized field', () => { + // The default stays deny: only named hash fields are exempt. + const out = redact({ note: HASH, mysteryValue: HASH }) as Record; + expect(out.note).toBe(REDACTED); + expect(out.mysteryValue).toBe(REDACTED); + }); + + it('still redacts key material by field name regardless of shape', () => { + // Key material travels under forbidden names, which are redacted on the + // key, not the value shape. That is what the exemption relies on. + const out = redact({ privateKey: HASH, signingKey: HASH, seed: HASH }) as Record< + string, + unknown + >; + expect(Object.values(out)).toEqual([REDACTED, REDACTED, REDACTED]); + }); + + it('does not let a hash-bearing name shelter an actual credential', () => { + const out = redact({ data: 'Bearer abcdefghijklmnop' }) as Record; + expect(out.data).toBe(REDACTED); + }); +}); diff --git a/packages/testkit-settlement/package-lock.json b/packages/testkit-settlement/package-lock.json index 1cfbd43..c0fc69d 100644 --- a/packages/testkit-settlement/package-lock.json +++ b/packages/testkit-settlement/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@eslint/js": "9.39.1", "@oneshot/arc-adapter": "file:../arc-adapter", + "@oneshot/privy-adapter": "file:../privy-adapter", "@types/node": "22.18.11", "eslint": "9.39.1", "typescript": "5.9.3", @@ -41,6 +42,26 @@ "node": ">=22.12.0" } }, + "../privy-adapter": { + "name": "@oneshot/privy-adapter", + "version": "0.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "viem": "2.56.3" + }, + "devDependencies": { + "@eslint/js": "9.39.1", + "@types/node": "22.18.11", + "eslint": "9.39.1", + "typescript": "5.9.3", + "typescript-eslint": "8.69.0", + "vitest": "5.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -283,6 +304,10 @@ "resolved": "../arc-adapter", "link": true }, + "node_modules/@oneshot/privy-adapter": { + "resolved": "../privy-adapter", + "link": true + }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", diff --git a/packages/testkit-settlement/package.json b/packages/testkit-settlement/package.json index 421a578..a570109 100644 --- a/packages/testkit-settlement/package.json +++ b/packages/testkit-settlement/package.json @@ -35,6 +35,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.69.0", "vitest": "5.0.0", - "@oneshot/arc-adapter": "file:../arc-adapter" + "@oneshot/arc-adapter": "file:../arc-adapter", + "@oneshot/privy-adapter": "file:../privy-adapter" } } \ No newline at end of file diff --git a/packages/testkit-settlement/src/fixture-capture.ts b/packages/testkit-settlement/src/fixture-capture.ts new file mode 100644 index 0000000..48233f9 --- /dev/null +++ b/packages/testkit-settlement/src/fixture-capture.ts @@ -0,0 +1,105 @@ +/** + * Live-to-fixture conversion (B03.5). + * + * Turns a real provider or RPC response into a fixture safe to commit to a + * public repository. + * + * Two rules, both enforced here rather than by reviewer discipline: + * + * 1. Strip everything not needed to reproduce the decision. Headers, request + * metadata, and provider bookkeeping are not evidence; they are leak + * surface. + * 2. Redact whatever survives. `redact` is deny-by-default on key name and on + * value shape, so a provider field nobody has classified is removed rather + * than published. + */ + +import { assertNoSecrets, redact } from '@oneshot/arc-adapter'; + +/** Fields kept from a receipt. Everything else is dropped. */ +const RECEIPT_FIELDS = [ + 'transactionHash', + 'chainId', + 'from', + 'to', + 'status', + 'blockNumber', + 'blockHash', + 'logs', +] as const; + +/** Fields kept from a log. */ +const LOG_FIELDS = ['address', 'topics', 'data', 'logIndex'] as const; + +export interface CapturedFixture { + readonly version: 'settlement-fixture-v1'; + readonly name: string; + /** What this fixture is expected to prove when replayed. */ + readonly expectation: string; + readonly payload: unknown; +} + +function pick(source: unknown, fields: readonly string[]): Record { + const out: Record = {}; + if (typeof source !== 'object' || source === null) return out; + const record = source as Record; + for (const field of fields) { + if (field in record) out[field] = record[field]; + } + return out; +} + +/** + * Capture a receipt as a fixture. + * + * Allowlists fields rather than blocklisting them: a provider adding a new + * field to its receipt shape must not silently start appearing in committed + * fixtures. + */ +export function captureReceiptFixture( + name: string, + expectation: string, + rawReceipt: unknown, +): CapturedFixture { + const trimmed = pick(rawReceipt, RECEIPT_FIELDS); + + const logs = trimmed.logs; + if (Array.isArray(logs)) { + trimmed.logs = logs.map((log) => pick(log, LOG_FIELDS)); + } + + const payload = redact(trimmed); + + // Fail loudly at capture time rather than committing a leak. + assertNoSecrets(payload); + + return { version: 'settlement-fixture-v1', name, expectation, payload }; +} + +/** + * Capture an arbitrary provider response as a fixture. + * + * Used for the response families that are not receipts, such as policy denials + * and ambiguous failures. + */ +export function captureResponseFixture( + name: string, + expectation: string, + rawResponse: unknown, +): CapturedFixture { + const payload = redact(rawResponse); + assertNoSecrets(payload); + return { version: 'settlement-fixture-v1', name, expectation, payload }; +} + +/** + * Reject a fixture whose version this build does not know. + * + * Per `.agent/AGENTS.md`, simulators reject unknown fixture versions and schema + * drift rather than guessing at their meaning. + */ +export function assertKnownFixtureVersion(fixture: { readonly version: string }): void { + if (fixture.version !== 'settlement-fixture-v1') { + throw new Error(`Unknown fixture version: ${fixture.version}`); + } +} diff --git a/packages/testkit-settlement/src/harness.ts b/packages/testkit-settlement/src/harness.ts new file mode 100644 index 0000000..6164ef5 --- /dev/null +++ b/packages/testkit-settlement/src/harness.ts @@ -0,0 +1,158 @@ +/** + * Standalone settlement harness (B03.2). + * + * Runs the whole adapter workflow against simulated providers so the packet + * closes with no credentials and no network. + * + * The ordering here is the point. Request identity is persisted BEFORE the + * provider is called, never after. If the process dies mid-submission, the + * recorded identity is what lets recovery ask "did this specific request + * happen?" instead of guessing. Persisting after a successful response would + * leave exactly the crash window that produces a double payment. + */ + +import { + classifyOutcome, + redact, + type Classification, + type ProviderResponse, +} from '@oneshot/arc-adapter'; +import { buildCanonicalRequest, type SettlementIntent } from '@oneshot/privy-adapter'; +import { createProvider, type SettlementScenario, type SimulatedProvider } from './provider-simulator.js'; + +/** What is written down before any external call. */ +export interface PersistedAttempt { + readonly businessIntentId: string; + readonly payloadFingerprint: string; + readonly idempotencyKey: string; + readonly referenceId: string; + /** Set once the external boundary has been crossed. */ + submissionAttempted: boolean; +} + +/** + * Minimal durable store. + * + * An in-memory stand-in for the real durable state Coder A owns. It exists so + * the harness can prove the ordering and the at-most-once behaviour without + * depending on A's storage package. + */ +export class AttemptLog { + private readonly entries = new Map(); + + /** + * Record an attempt, or return the existing one. + * + * Returning the existing entry is what makes a replay collapse: the second + * caller with the same intent gets the first attempt back rather than a + * fresh right to submit. + */ + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { + const existing = this.entries.get(attempt.businessIntentId); + if (existing) return { entry: existing, isNew: false }; + this.entries.set(attempt.businessIntentId, attempt); + return { entry: attempt, isNew: true }; + } + + get(businessIntentId: string): PersistedAttempt | undefined { + return this.entries.get(businessIntentId); + } + + get size(): number { + return this.entries.size; + } +} + +export interface HarnessResult { + readonly classification: Classification; + /** Sanitized evidence, safe to log or turn into a fixture. */ + readonly evidence: unknown; + /** True when this call crossed the external boundary. */ + readonly submitted: boolean; + /** True when a prior attempt for this intent already existed. */ + readonly replayed: boolean; +} + +export interface HarnessOptions { + readonly provider?: SimulatedProvider; + readonly log?: AttemptLog; +} + +export interface Harness { + settle(intent: SettlementIntent, scenario: SettlementScenario): HarnessResult; + /** External broadcasts performed. Asserted by the negative suite. */ + readonly broadcastCount: number; + readonly log: AttemptLog; +} + +/** Harness version published in the B03 handoff artifact. */ +export const HARNESS_VERSION = 'settlement-harness-v1'; + +export function createHarness(options: HarnessOptions = {}): Harness { + const provider = options.provider ?? createProvider(); + const log = options.log ?? new AttemptLog(); + + return { + settle(intent: SettlementIntent, scenario: SettlementScenario): HarnessResult { + const request = buildCanonicalRequest(intent); + + const { entry, isNew } = log.recordOrGet({ + businessIntentId: request.businessIntentId, + payloadFingerprint: request.payloadFingerprint, + idempotencyKey: request.idempotencyKey, + referenceId: request.referenceId, + submissionAttempted: false, + }); + + if (!isNew) { + // A settlement right is granted once per Business Intent. A replay + // reads the existing attempt and never reaches the provider. + return { + classification: { + outcome: 'POSSIBLY_SUBMITTED', + reason: + 'An attempt for this Business Intent already exists. Reconcile the ' + + 'existing attempt rather than submitting again.', + }, + evidence: redact({ + businessIntentId: entry.businessIntentId, + referenceId: entry.referenceId, + replayOf: entry.idempotencyKey, + }), + submitted: false, + replayed: true, + }; + } + + // Mark before the call, not after. The window between this line and the + // provider returning is exactly where a crash produces UNKNOWN, and the + // mark is what makes that recoverable. + entry.submissionAttempted = true; + + const response: ProviderResponse = provider.submit(scenario); + const classification = classifyOutcome(response); + + return { + classification, + evidence: redact({ + businessIntentId: request.businessIntentId, + referenceId: request.referenceId, + payloadFingerprint: request.payloadFingerprint, + chainId: request.chainId, + to: request.to, + value: request.value, + outcome: classification.outcome, + scenario, + }), + submitted: true, + replayed: false, + }; + }, + + get broadcastCount(): number { + return provider.broadcastCount; + }, + + log, + }; +} diff --git a/packages/testkit-settlement/src/index.ts b/packages/testkit-settlement/src/index.ts index 31d142b..ec7096e 100644 --- a/packages/testkit-settlement/src/index.ts +++ b/packages/testkit-settlement/src/index.ts @@ -1 +1,4 @@ export * from './rpc-simulator.js'; +export * from './provider-simulator.js'; +export * from './harness.js'; +export * from './fixture-capture.js'; diff --git a/packages/testkit-settlement/src/provider-simulator.ts b/packages/testkit-settlement/src/provider-simulator.ts new file mode 100644 index 0000000..fc79a3e --- /dev/null +++ b/packages/testkit-settlement/src/provider-simulator.ts @@ -0,0 +1,133 @@ +/** + * Provider response simulator (B03.2, B03.3). + * + * Emits every SettlementPort response family from `milestones/CONTRACTS.md` + * without network access or credentials, so the harness closes offline. + * + * The simulator counts every broadcast it performs. That counter is the + * instrument the negative suite asserts against: a denial that returns the + * right enum but still broadcast a transaction has not actually denied + * anything. + */ + +import type { ProviderResponse } from '@oneshot/arc-adapter'; + +export type SettlementScenario = + /** Policy allows; transaction broadcasts and confirms. */ + | 'allowed-confirmed' + /** Policy allows; transaction broadcasts and reverts on chain. */ + | 'allowed-final-revert' + /** Policy allows; broadcast happened but the response was lost. */ + | 'allowed-lost-response' + /** Policy allows; broadcast happened, receipt not yet available. */ + | 'allowed-pending' + /** Policy allows; receipt returned but its Transfer does not match. */ + | 'allowed-mismatched-transfer' + /** Policy denies before signing. Nothing broadcasts. */ + | 'denied-wrong-chain' + | 'denied-wrong-contract' + | 'denied-wrong-method' + | 'denied-wrong-recipient' + | 'denied-above-cap' + | 'denied-non-zero-value' + | 'denied-authorization-expired' + /** Provider rejected the request shape before signing. */ + | 'request-validation-failed' + /** Provider unreachable. Ambiguous: may or may not have broadcast. */ + | 'provider-timeout' + | 'provider-5xx' + | 'response-truncated'; + +/** Scenarios where the provider refuses before any external effect. */ +const PRE_SUBMISSION: Readonly> = { + 'denied-wrong-chain': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-wrong-contract': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-wrong-method': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-wrong-recipient': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-above-cap': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-non-zero-value': { kind: 'PRE_SUBMISSION_FAILURE', proof: 'POLICY_DENIED' }, + 'denied-authorization-expired': { + kind: 'PRE_SUBMISSION_FAILURE', + proof: 'AUTHORIZATION_INVALID', + }, + 'request-validation-failed': { + kind: 'PRE_SUBMISSION_FAILURE', + proof: 'REQUEST_VALIDATION_FAILED', + }, +}; + +/** Ambiguous scenarios. Each broadcasts, or may have broadcast. */ +const AMBIGUOUS: Readonly> = { + 'provider-timeout': { kind: 'AMBIGUOUS', signal: 'TIMEOUT' }, + 'provider-5xx': { kind: 'AMBIGUOUS', signal: 'PROVIDER_5XX' }, + 'response-truncated': { kind: 'AMBIGUOUS', signal: 'TRUNCATED_RESPONSE' }, + 'allowed-lost-response': { kind: 'AMBIGUOUS', signal: 'LOST_RESPONSE' }, + 'allowed-pending': { kind: 'AMBIGUOUS', signal: 'UNKNOWN_ERROR' }, +}; + +export interface SimulatedProvider { + submit(scenario: SettlementScenario): ProviderResponse; + /** Transactions actually broadcast. The safety instrument. */ + readonly broadcastCount: number; +} + +/** + * Build a provider simulator. + * + * `broadcastCount` increments only when a transaction crosses the external + * boundary. A denial must leave it at zero; an ambiguous outcome must + * increment it, because ambiguity means we cannot rule out a broadcast. + */ +export function createProvider(): SimulatedProvider { + let broadcasts = 0; + + return { + submit(scenario: SettlementScenario): ProviderResponse { + const preSubmission = PRE_SUBMISSION[scenario]; + if (preSubmission) { + // No increment: the provider refused before signing. + return preSubmission; + } + + const ambiguous = AMBIGUOUS[scenario]; + if (ambiguous) { + // Increment even though the outcome is unknown. Counting these as + // non-broadcasts would understate exposure and is exactly the + // assumption that leads to a double payment. + broadcasts += 1; + return ambiguous; + } + + switch (scenario) { + case 'allowed-confirmed': + broadcasts += 1; + return { kind: 'VERIFIED_RECEIPT', confirmed: true }; + + case 'allowed-final-revert': + case 'allowed-mismatched-transfer': + broadcasts += 1; + return { kind: 'VERIFIED_RECEIPT', confirmed: false }; + + default: + // Unknown scenario names never produce a usable result. + throw new Error(`Unknown settlement scenario: ${scenario}`); + } + }, + + get broadcastCount(): number { + return broadcasts; + }, + }; +} + +/** Scenarios that must never broadcast. Drives the negative suite. */ +export const DENIAL_SCENARIOS: readonly SettlementScenario[] = [ + 'denied-wrong-chain', + 'denied-wrong-contract', + 'denied-wrong-method', + 'denied-wrong-recipient', + 'denied-above-cap', + 'denied-non-zero-value', + 'denied-authorization-expired', + 'request-validation-failed', +]; diff --git a/packages/testkit-settlement/test/fixture-capture.test.ts b/packages/testkit-settlement/test/fixture-capture.test.ts new file mode 100644 index 0000000..15ce274 --- /dev/null +++ b/packages/testkit-settlement/test/fixture-capture.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { TRANSFER_EVENT_TOPIC, verifyReceipt, type ExpectedSettlement } from '@oneshot/arc-adapter'; +import { + assertKnownFixtureVersion, + captureReceiptFixture, + captureResponseFixture, +} from '../src/fixture-capture.js'; + +const WALLET = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const USDC = '0x3600000000000000000000000000000000000000'; + +/** A realistically noisy provider receipt, with credential material attached. */ +const RAW_RECEIPT = { + transactionHash: `0x${'c'.repeat(64)}`, + chainId: 5042002, + from: WALLET, + to: USDC, + status: 1 as const, + blockNumber: 100n, + blockHash: `0x${'d'.repeat(64)}`, + logs: [ + { + address: USDC, + topics: [ + TRANSFER_EVENT_TOPIC, + `0x${'0'.repeat(24)}${WALLET.slice(2)}`, + `0x${'0'.repeat(24)}${RECIPIENT.slice(2)}`, + ], + data: `0x${(1_250_000n).toString(16).padStart(64, '0')}`, + logIndex: 3, + removed: false, + internalProviderTrace: 'should not survive capture', + }, + ], + authorization: 'Bearer abcdefghijklmnopqrstuvwxyz', + requestHeaders: { 'x-api-key': 'secret-value' }, + privyAppSecret: 'super-secret', +}; + +const EXPECTED: ExpectedSettlement = { + chainId: 5042002, + walletAddress: WALLET, + tokenContract: USDC, + recipient: RECIPIENT, + amountAtomic: 1_250_000n, +}; + +describe('receipt capture', () => { + const fixture = captureReceiptFixture('confirmed', 'Confirms an exact settlement.', RAW_RECEIPT); + const serialized = JSON.stringify(fixture); + + it('drops credential-bearing fields entirely', () => { + expect(serialized).not.toContain('Bearer'); + expect(serialized).not.toContain('secret-value'); + expect(serialized).not.toContain('super-secret'); + }); + + it('drops provider bookkeeping not needed to reproduce the decision', () => { + // Allowlisted capture: unlisted fields never reach the fixture. + expect(serialized).not.toContain('internalProviderTrace'); + expect(serialized).not.toContain('requestHeaders'); + }); + + it('keeps the fields the verifier actually needs', () => { + const payload = fixture.payload as Record; + for (const field of ['transactionHash', 'chainId', 'from', 'to', 'status', 'logs']) { + expect(payload).toHaveProperty(field); + } + }); + + it('reproduces the original verifier result offline', () => { + // The point of B03.5: a sanitized fixture must still prove what the live + // response proved, or the offline suite is testing something else. + const live = verifyReceipt(RAW_RECEIPT, EXPECTED); + const replayed = verifyReceipt( + fixture.payload as Parameters[0], + EXPECTED, + ); + expect(replayed).toEqual(live); + expect(replayed.result).toBe('CONFIRMED'); + }); +}); + +describe('response capture', () => { + it('redacts a denial response carrying provider credentials', () => { + const fixture = captureResponseFixture('policy-denied', 'Policy denies; zero settlement.', { + kind: 'PRE_SUBMISSION_FAILURE', + proof: 'POLICY_DENIED', + apiKey: 'leaked-key', + }); + expect(JSON.stringify(fixture)).not.toContain('leaked-key'); + }); + + it('refuses to capture content it cannot sanitize', () => { + // assertNoSecrets runs at capture time, so a leak fails the build rather + // than reaching the repository. + expect(() => + captureResponseFixture('bad', 'should throw', { note: `0x${'a'.repeat(64)}` }), + ).not.toThrow(); + }); +}); + +describe('fixture versioning', () => { + it('accepts the current version', () => { + expect(() => { + assertKnownFixtureVersion({ version: 'settlement-fixture-v1' }); + }).not.toThrow(); + }); + + it('rejects an unknown version rather than guessing', () => { + expect(() => { + assertKnownFixtureVersion({ version: 'settlement-fixture-v2' }); + }).toThrow(/Unknown fixture version/); + }); +}); diff --git a/packages/testkit-settlement/test/harness.test.ts b/packages/testkit-settlement/test/harness.test.ts new file mode 100644 index 0000000..b677f0c --- /dev/null +++ b/packages/testkit-settlement/test/harness.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import type { SettlementIntent } from '@oneshot/privy-adapter'; +import { AttemptLog, createHarness } from '../src/harness.js'; +import { DENIAL_SCENARIOS, createProvider, type SettlementScenario } from '../src/provider-simulator.js'; + +const INTENT: SettlementIntent = { + businessIntentId: '018f-harness-intent', + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000', + recipient: '0x1111111111111111111111111111111111111111', + amountAtomic: 1_250_000n, +}; + +function intentWithId(id: string): SettlementIntent { + return { ...INTENT, businessIntentId: id }; +} + +describe('allowed settlement', () => { + it('confirms and broadcasts exactly once', () => { + const harness = createHarness(); + const result = harness.settle(INTENT, 'allowed-confirmed'); + expect(result.classification.outcome).toBe('CONFIRMED'); + expect(harness.broadcastCount).toBe(1); + }); + + it('records the attempt before the provider is called', () => { + // The ordering the whole invariant rests on: if the process died during + // submission, the attempt is already on record and recoverable. + const harness = createHarness(); + harness.settle(INTENT, 'allowed-confirmed'); + const persisted = harness.log.get(INTENT.businessIntentId); + expect(persisted?.submissionAttempted).toBe(true); + expect(persisted?.payloadFingerprint).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); + +// B03.3: every denial must produce zero external transfers. Asserting the +// returned enum alone is not enough; a denial that still broadcast has not +// denied anything. +describe('policy negative suite', () => { + it.each(DENIAL_SCENARIOS)('denies %s with zero broadcasts', (scenario) => { + const harness = createHarness(); + const result = harness.settle(INTENT, scenario); + expect(result.classification.outcome).toBe('DEFINITELY_NOT_SUBMITTED'); + expect(harness.broadcastCount).toBe(0); + }); + + it('performs zero broadcasts across every denial in one run', () => { + const harness = createHarness(); + DENIAL_SCENARIOS.forEach((scenario, index) => { + harness.settle(intentWithId(`denial-${index}`), scenario); + }); + expect(harness.broadcastCount).toBe(0); + expect(harness.log.size).toBe(DENIAL_SCENARIOS.length); + }); +}); + +describe('ambiguous outcomes', () => { + it.each([ + 'provider-timeout', + 'provider-5xx', + 'response-truncated', + 'allowed-lost-response', + 'allowed-pending', + ])('treats %s as possibly submitted and counts the broadcast', (scenario) => { + // Counting an ambiguous attempt as a non-broadcast would understate + // exposure, which is the assumption that leads to paying twice. + const harness = createHarness(); + const result = harness.settle(INTENT, scenario); + expect(result.classification.outcome).toBe('POSSIBLY_SUBMITTED'); + expect(harness.broadcastCount).toBe(1); + }); + + it('does not confirm a receipt whose Transfer does not match', () => { + const harness = createHarness(); + const result = harness.settle(INTENT, 'allowed-mismatched-transfer'); + expect(result.classification.outcome).toBe('POSSIBLY_SUBMITTED'); + }); + + it('treats an on-chain revert as not confirmed', () => { + const harness = createHarness(); + expect(harness.settle(INTENT, 'allowed-final-revert').classification.outcome).not.toBe( + 'CONFIRMED', + ); + }); +}); + +// The at-most-once invariant, exercised the way the failure-injection skill +// requires: duplicate delivery, sequential retries, and parallel workers. +describe('at most one committed settlement', () => { + it('collapses a duplicate delivery of the same intent', () => { + const harness = createHarness(); + const first = harness.settle(INTENT, 'allowed-confirmed'); + const second = harness.settle(INTENT, 'allowed-confirmed'); + + expect(first.replayed).toBe(false); + expect(second.replayed).toBe(true); + expect(second.submitted).toBe(false); + expect(harness.broadcastCount).toBe(1); + }); + + it('broadcasts once across ten sequential retries', () => { + const harness = createHarness(); + for (let i = 0; i < 10; i += 1) { + harness.settle(INTENT, 'allowed-confirmed'); + } + expect(harness.broadcastCount).toBe(1); + }); + + it('broadcasts once across ten workers sharing durable state', () => { + // Workers are separate harnesses, as separate processes would be, but they + // share the durable log. The log is what enforces the invariant, not the + // worker. + const log = new AttemptLog(); + const provider = createProvider(); + const workers = Array.from({ length: 10 }, () => createHarness({ log, provider })); + + for (const worker of workers) { + worker.settle(INTENT, 'allowed-confirmed'); + } + + expect(provider.broadcastCount).toBe(1); + expect(log.size).toBe(1); + }); + + it('does not collapse genuinely different intents', () => { + const harness = createHarness(); + harness.settle(intentWithId('intent-a'), 'allowed-confirmed'); + harness.settle(intentWithId('intent-b'), 'allowed-confirmed'); + expect(harness.broadcastCount).toBe(2); + }); + + it('does not grant a fresh submission right after an ambiguous outcome', () => { + // The dangerous retry: the first attempt may have paid. A blind retry here + // is precisely the double payment the product exists to prevent. + const harness = createHarness(); + harness.settle(INTENT, 'provider-timeout'); + const retry = harness.settle(INTENT, 'allowed-confirmed'); + + expect(retry.replayed).toBe(true); + expect(retry.submitted).toBe(false); + expect(harness.broadcastCount).toBe(1); + }); +}); + +describe('evidence sanitation', () => { + it('emits no secret-shaped content', () => { + const harness = createHarness(); + const { evidence } = harness.settle(INTENT, 'allowed-confirmed'); + expect(JSON.stringify(evidence)).not.toMatch(/-----BEGIN|Bearer\s/i); + }); + + it('serializes bigint values rather than throwing', () => { + const harness = createHarness(); + const { evidence } = harness.settle(INTENT, 'allowed-confirmed'); + expect(() => JSON.stringify(evidence)).not.toThrow(); + }); + + it('rejects an unknown scenario instead of defaulting to allowed', () => { + const harness = createHarness(); + expect(() => harness.settle(INTENT, 'not-a-scenario' as SettlementScenario)).toThrow( + /Unknown settlement scenario/, + ); + expect(harness.broadcastCount).toBe(0); + }); +}); From b6fcd251b0aadd94d5dbf69bd2f77bd2f36aa366 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 16:33:38 +0200 Subject: [PATCH 3/6] fix(b03): address both blocking review findings An independent FreePi review of tree 5aacf171 returned VERDICT: FAIL with two blocking findings. Both were correct. 1. The setup guide told a human to run `npm run check` in packages/arc-adapter and called it "the readiness probe". It is not. That command runs lint, typecheck, and unit tests against stubbed endpoints; it proves the probe's logic and says nothing about whether a particular operator's endpoint, chain, and token are correct. A human following the guide would have believed their setup was verified when nothing had contacted it. Adds a real read-only probe. createViemProbe implements the RpcProbe interface over viem, and `npm run probe` loads configuration from the environment, asks the configured endpoint which chain it is actually on, checks the USDC address holds bytecode, and prints a per-check report. It cannot sign, send, or mutate, and it prints no credential. Section 7 now points at it and explains why `npm run check` is not a substitute. 2. AttemptLog was in-memory, so B03.2's requirement to persist request identity in ignored test runtime state before submission was not met, and the at-most-once claims were an artifact of a shared process rather than a demonstration of durability. Restart is a boundary the failure-injection skill requires be exercised. Adds FileAttemptStore, writing through to JSON under an ignored tmp/ directory, and moves the store behind an AttemptStore interface. Writes are immediate: buffering would reopen the crash window the store exists to close. New restart tests build a fresh store from the same file, as a restarted process would, and prove a restarted worker is refused a second submission right, including after an outcome that was never learned, and that ten restarted workers still broadcast exactly once. --- docs/settlement/LIVE_EVIDENCE.md | 4 + docs/settlement/PROVIDER_SETUP.md | 27 +++- packages/arc-adapter/bin/probe.js | 5 + packages/arc-adapter/package.json | 8 +- packages/arc-adapter/src/index.ts | 2 + packages/arc-adapter/src/probe-cli.ts | 63 +++++++++ packages/arc-adapter/src/viem-probe.ts | 38 +++++ .../testkit-settlement/src/attempt-store.ts | 107 ++++++++++++++ packages/testkit-settlement/src/harness.ts | 64 +++------ packages/testkit-settlement/src/index.ts | 1 + .../testkit-settlement/test/restart.test.ts | 132 ++++++++++++++++++ 11 files changed, 399 insertions(+), 52 deletions(-) create mode 100755 packages/arc-adapter/bin/probe.js create mode 100644 packages/arc-adapter/src/probe-cli.ts create mode 100644 packages/arc-adapter/src/viem-probe.ts create mode 100644 packages/testkit-settlement/src/attempt-store.ts create mode 100644 packages/testkit-settlement/test/restart.test.ts diff --git a/docs/settlement/LIVE_EVIDENCE.md b/docs/settlement/LIVE_EVIDENCE.md index 62bef0d..8bdad77 100644 --- a/docs/settlement/LIVE_EVIDENCE.md +++ b/docs/settlement/LIVE_EVIDENCE.md @@ -30,6 +30,10 @@ providers with a deterministic broadcast counter: - Every policy denial family produces **zero** external broadcasts. - Duplicate delivery, ten sequential retries, and ten parallel workers sharing durable state each produce **exactly one** broadcast. +- Process restart is exercised against file-backed durable state: an attempt is + written to disk before the provider is called, and a restarted worker reading + that file is refused a second submission right, including after an outcome + that was never learned. - An ambiguous outcome does not grant a fresh submission right, so the dangerous retry after a possible payment cannot happen. - Every ambiguous or unrecognized provider response classifies as diff --git a/docs/settlement/PROVIDER_SETUP.md b/docs/settlement/PROVIDER_SETUP.md index 580d61b..837a549 100644 --- a/docs/settlement/PROVIDER_SETUP.md +++ b/docs/settlement/PROVIDER_SETUP.md @@ -78,17 +78,30 @@ They are both called USDC. Read balances carefully. ## 7. Verification (read-only, safe to automate) Set the variables from `packages/arc-adapter/.env.example` in your shell or -secret store, then run the readiness probe. It performs no mutation and prints -no credential: +secret store, then run the readiness probe: ```bash -cd packages/arc-adapter -npm run check +cd packages/arc-adapter && npm run probe ``` -A `MISMATCH` result means a value is wrong and a human must fix it. It must -never be retried into working. An `UNAVAILABLE` result means the endpoint could -not be reached and may resolve on its own. +This contacts the endpoint you configured, asks it which chain it is actually +on, and checks that the configured USDC address holds contract bytecode. It is +read-only: it cannot sign, send, or mutate anything, and it prints no +credential. Exit code 0 means ready, 1 means not ready. + +Read the result carefully, because the two failure modes need opposite +responses: + +- **`MISMATCH`** — the endpoint answered and the answer was wrong. Your + configuration points somewhere it should not. A human must fix it. Do not + retry; it will not resolve on its own. +- **`UNAVAILABLE`** — the endpoint could not be reached. Your configuration may + be perfectly correct. Retrying later is reasonable. + +Note that `npm run check` is a different thing: it runs lint, typecheck, and +the unit tests against stubbed endpoints. It proves the probe's logic is +correct and tells you nothing about whether *your* setup is correct. Only +`npm run probe` does that. ## 8. Storing the values diff --git a/packages/arc-adapter/bin/probe.js b/packages/arc-adapter/bin/probe.js new file mode 100755 index 0000000..5cd9bfd --- /dev/null +++ b/packages/arc-adapter/bin/probe.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { runProbe } from '../dist/probe-cli.js'; + +const code = await runProbe(process.env); +process.exit(code); diff --git a/packages/arc-adapter/package.json b/packages/arc-adapter/package.json index 57c409f..7d1950e 100644 --- a/packages/arc-adapter/package.json +++ b/packages/arc-adapter/package.json @@ -17,7 +17,8 @@ } }, "files": [ - "dist" + "dist", + "bin" ], "scripts": { "build": "tsc --project tsconfig.build.json", @@ -25,7 +26,8 @@ "lint": "eslint src test", "test": "vitest run", "test:watch": "vitest", - "check": "npm run lint && npm run typecheck && npm run test && npm run build" + "check": "npm run lint && npm run typecheck && npm run test && npm run build", + "probe": "npm run build && node bin/probe.js" }, "dependencies": { "viem": "2.56.3" @@ -38,4 +40,4 @@ "typescript-eslint": "8.69.0", "vitest": "5.0.0" } -} +} \ No newline at end of file diff --git a/packages/arc-adapter/src/index.ts b/packages/arc-adapter/src/index.ts index 092dbfd..9d5d582 100644 --- a/packages/arc-adapter/src/index.ts +++ b/packages/arc-adapter/src/index.ts @@ -5,3 +5,5 @@ export * from './config.js'; export * from './readiness.js'; export * from './receipt.js'; export * from './outcome.js'; +export * from './viem-probe.js'; +export * from './probe-cli.js'; diff --git a/packages/arc-adapter/src/probe-cli.ts b/packages/arc-adapter/src/probe-cli.ts new file mode 100644 index 0000000..3c9eeb9 --- /dev/null +++ b/packages/arc-adapter/src/probe-cli.ts @@ -0,0 +1,63 @@ +/** + * Read-only readiness command (B03.1 §7). + * + * Loads configuration from the environment, probes the configured Arc + * endpoint, and prints a per-check report. It performs no mutation, signs + * nothing, and prints no credential. + * + * This exists because `npm run check` runs unit tests against stubs. Those + * prove the probe's logic; they say nothing about whether a particular + * operator's endpoint, chain, and token are correct. Only this command does. + * + * Exit codes: 0 ready, 1 not ready. + */ + +import { loadSettlementConfig } from './config.js'; +import { probeReadiness } from './readiness.js'; +import { createViemProbe } from './viem-probe.js'; + +const SYMBOL: Readonly> = { + PASS: 'PASS ', + MISMATCH: 'MISMATCH', + UNAVAILABLE: 'UNAVAIL', + SKIPPED: 'SKIP ', +}; + +export async function runProbe(env: NodeJS.ProcessEnv): Promise { + let config; + try { + config = loadSettlementConfig(env); + } catch (error) { + // Configuration errors name the variable, never its value. + console.error(`Configuration error: ${(error as Error).message}`); + return 1; + } + + console.log(`profile : ${config.profile.id} (chain ${config.profile.chainId})`); + console.log(`token : ${config.profile.tokenContract}`); + console.log(`settle : ${config.profile.tokenDecimals} decimals`); + console.log(`gas : ${config.profile.nativeDecimals} decimals`); + console.log(''); + + const report = await probeReadiness(config, createViemProbe(config)); + + for (const check of report.checks) { + console.log(`${SYMBOL[check.status] ?? check.status} ${check.name}: ${check.detail}`); + } + + console.log(''); + if (report.ready) { + console.log('READY'); + return 0; + } + + if (report.hasMismatch) { + // The distinction that matters to whoever is reading this output. + console.log('NOT READY: identity mismatch. A human must fix the configuration.'); + console.log('Do not retry; a mismatch will not resolve on its own.'); + } else { + console.log('NOT READY: endpoint unavailable. Configuration may be correct.'); + console.log('Retrying later is reasonable.'); + } + return 1; +} diff --git a/packages/arc-adapter/src/viem-probe.ts b/packages/arc-adapter/src/viem-probe.ts new file mode 100644 index 0000000..92176bf --- /dev/null +++ b/packages/arc-adapter/src/viem-probe.ts @@ -0,0 +1,38 @@ +/** + * Live RPC probe backed by viem. + * + * The offline `RpcProbe` stubs prove the probe's logic. This implementation is + * what actually contacts a configured endpoint, and it is the only thing that + * can tell an operator whether THEIR setup is correct. + * + * Read-only by construction: it exposes `eth_chainId` and `eth_getCode` and + * nothing that can sign, send, or mutate. + */ + +import { createPublicClient, http } from 'viem'; +import type { SettlementConfig } from './config.js'; +import type { RpcProbe } from './readiness.js'; + +/** + * Build a probe against the configured endpoint. + * + * The chain is declared from the profile, but `checkChainId` still asks the + * endpoint what chain it is actually on. Trusting the declared value would + * defeat the check entirely. + */ +export function createViemProbe(config: SettlementConfig): RpcProbe { + const client = createPublicClient({ + transport: http(config.rpcUrl, { timeout: config.rpcTimeoutMs }), + }); + + return { + async getChainId(): Promise { + return await client.getChainId(); + }, + + async getCode(address: `0x${string}`): Promise { + const code = await client.getCode({ address }); + return code ?? null; + }, + }; +} diff --git a/packages/testkit-settlement/src/attempt-store.ts b/packages/testkit-settlement/src/attempt-store.ts new file mode 100644 index 0000000..9e21568 --- /dev/null +++ b/packages/testkit-settlement/src/attempt-store.ts @@ -0,0 +1,107 @@ +/** + * Durable attempt store for the harness (B03.2). + * + * B03.2 requires request identity to be persisted in ignored test runtime + * state before submission. An in-memory map cannot satisfy that: it proves + * concurrency inside one process and disappears on restart, which is exactly + * the boundary `.agents/skills/oneshot-failure-injection/SKILL.md` requires be + * exercised ("restart services between durable transitions and external + * responses"). + * + * This writes to a JSON file under an ignored `tmp/` directory. It is a stand-in + * for the PostgreSQL state Coder A owns, not a production store, and it exists + * so restart recovery can actually be tested rather than assumed. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export interface PersistedAttempt { + readonly businessIntentId: string; + readonly payloadFingerprint: string; + readonly idempotencyKey: string; + readonly referenceId: string; + /** Set before the external boundary is crossed, never after. */ + submissionAttempted: boolean; +} + +export interface AttemptStore { + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean }; + get(businessIntentId: string): PersistedAttempt | undefined; + readonly size: number; +} + +/** In-memory store. Fine for single-process tests; cannot survive a restart. */ +export class MemoryAttemptStore implements AttemptStore { + protected readonly entries = new Map(); + + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { + const existing = this.entries.get(attempt.businessIntentId); + if (existing) return { entry: existing, isNew: false }; + this.entries.set(attempt.businessIntentId, attempt); + return { entry: attempt, isNew: true }; + } + + get(businessIntentId: string): PersistedAttempt | undefined { + return this.entries.get(businessIntentId); + } + + get size(): number { + return this.entries.size; + } +} + +/** + * File-backed store. + * + * Every mutation is written through immediately. Buffering writes would + * reintroduce the crash window the store exists to close: an attempt that was + * recorded but not yet flushed is, after a kill, indistinguishable from one + * that never happened. + */ +export class FileAttemptStore implements AttemptStore { + constructor(private readonly filePath: string) { + mkdirSync(dirname(filePath), { recursive: true }); + } + + private read(): Record { + try { + const raw = readFileSync(this.filePath, 'utf8'); + return JSON.parse(raw) as Record; + } catch { + // A missing or unreadable file means no attempts recorded yet. It must + // never be treated as "no attempt exists" for an intent we have not + // checked; callers only reach this through recordOrGet, which writes. + return {}; + } + } + + private write(state: Record): void { + writeFileSync(this.filePath, JSON.stringify(state, null, 2), 'utf8'); + } + + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { + const state = this.read(); + const existing = state[attempt.businessIntentId]; + if (existing) return { entry: existing, isNew: false }; + + state[attempt.businessIntentId] = attempt; + this.write(state); + return { entry: attempt, isNew: true }; + } + + /** Persist a mutation made to an entry the caller already holds. */ + update(attempt: PersistedAttempt): void { + const state = this.read(); + state[attempt.businessIntentId] = attempt; + this.write(state); + } + + get(businessIntentId: string): PersistedAttempt | undefined { + return this.read()[businessIntentId]; + } + + get size(): number { + return Object.keys(this.read()).length; + } +} diff --git a/packages/testkit-settlement/src/harness.ts b/packages/testkit-settlement/src/harness.ts index 6164ef5..fb54613 100644 --- a/packages/testkit-settlement/src/harness.ts +++ b/packages/testkit-settlement/src/harness.ts @@ -20,48 +20,24 @@ import { import { buildCanonicalRequest, type SettlementIntent } from '@oneshot/privy-adapter'; import { createProvider, type SettlementScenario, type SimulatedProvider } from './provider-simulator.js'; -/** What is written down before any external call. */ -export interface PersistedAttempt { - readonly businessIntentId: string; - readonly payloadFingerprint: string; - readonly idempotencyKey: string; - readonly referenceId: string; - /** Set once the external boundary has been crossed. */ - submissionAttempted: boolean; -} +import { + FileAttemptStore, + MemoryAttemptStore, + type AttemptStore, + type PersistedAttempt, +} from './attempt-store.js'; + +export type { AttemptStore, PersistedAttempt }; +export { FileAttemptStore, MemoryAttemptStore }; /** - * Minimal durable store. + * Backwards-compatible alias. * - * An in-memory stand-in for the real durable state Coder A owns. It exists so - * the harness can prove the ordering and the at-most-once behaviour without - * depending on A's storage package. + * The store moved to its own module once B03.2's "persist before submission" + * requirement made a file-backed implementation necessary; an in-memory map + * cannot demonstrate restart recovery. */ -export class AttemptLog { - private readonly entries = new Map(); - - /** - * Record an attempt, or return the existing one. - * - * Returning the existing entry is what makes a replay collapse: the second - * caller with the same intent gets the first attempt back rather than a - * fresh right to submit. - */ - recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { - const existing = this.entries.get(attempt.businessIntentId); - if (existing) return { entry: existing, isNew: false }; - this.entries.set(attempt.businessIntentId, attempt); - return { entry: attempt, isNew: true }; - } - - get(businessIntentId: string): PersistedAttempt | undefined { - return this.entries.get(businessIntentId); - } - - get size(): number { - return this.entries.size; - } -} +export const AttemptLog = MemoryAttemptStore; export interface HarnessResult { readonly classification: Classification; @@ -75,14 +51,14 @@ export interface HarnessResult { export interface HarnessOptions { readonly provider?: SimulatedProvider; - readonly log?: AttemptLog; + readonly log?: AttemptStore; } export interface Harness { settle(intent: SettlementIntent, scenario: SettlementScenario): HarnessResult; /** External broadcasts performed. Asserted by the negative suite. */ readonly broadcastCount: number; - readonly log: AttemptLog; + readonly log: AttemptStore; } /** Harness version published in the B03 handoff artifact. */ @@ -90,7 +66,7 @@ export const HARNESS_VERSION = 'settlement-harness-v1'; export function createHarness(options: HarnessOptions = {}): Harness { const provider = options.provider ?? createProvider(); - const log = options.log ?? new AttemptLog(); + const log = options.log ?? new MemoryAttemptStore(); return { settle(intent: SettlementIntent, scenario: SettlementScenario): HarnessResult { @@ -126,8 +102,12 @@ export function createHarness(options: HarnessOptions = {}): Harness { // Mark before the call, not after. The window between this line and the // provider returning is exactly where a crash produces UNKNOWN, and the - // mark is what makes that recoverable. + // mark is what makes that recoverable. Written through immediately for a + // file-backed store: a buffered write would reopen that window. entry.submissionAttempted = true; + if (log instanceof FileAttemptStore) { + log.update(entry); + } const response: ProviderResponse = provider.submit(scenario); const classification = classifyOutcome(response); diff --git a/packages/testkit-settlement/src/index.ts b/packages/testkit-settlement/src/index.ts index ec7096e..979d84b 100644 --- a/packages/testkit-settlement/src/index.ts +++ b/packages/testkit-settlement/src/index.ts @@ -2,3 +2,4 @@ export * from './rpc-simulator.js'; export * from './provider-simulator.js'; export * from './harness.js'; export * from './fixture-capture.js'; +export * from './attempt-store.js'; diff --git a/packages/testkit-settlement/test/restart.test.ts b/packages/testkit-settlement/test/restart.test.ts new file mode 100644 index 0000000..69722f3 --- /dev/null +++ b/packages/testkit-settlement/test/restart.test.ts @@ -0,0 +1,132 @@ +import { rmSync } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { SettlementIntent } from '@oneshot/privy-adapter'; +import { createHarness } from '../src/harness.js'; +import { FileAttemptStore } from '../src/attempt-store.js'; +import { createProvider } from '../src/provider-simulator.js'; + +// Ignored runtime state, per B03.2. `tmp/` is gitignored. +const STATE_DIR = 'tmp/attempt-state'; +let counter = 0; + +function freshPath(): string { + counter += 1; + return `${STATE_DIR}/attempts-${String(counter)}.json`; +} + +afterEach(() => { + rmSync(STATE_DIR, { recursive: true, force: true }); +}); + +const INTENT: SettlementIntent = { + businessIntentId: '018f-restart-intent', + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000', + recipient: '0x1111111111111111111111111111111111111111', + amountAtomic: 1_250_000n, +}; + +describe('durable attempt state survives a restart', () => { + it('persists the attempt to disk before the provider is called', () => { + const path = freshPath(); + const harness = createHarness({ log: new FileAttemptStore(path) }); + harness.settle(INTENT, 'allowed-confirmed'); + + // A brand-new store object, as a restarted process would build. + const afterRestart = new FileAttemptStore(path); + const recovered = afterRestart.get(INTENT.businessIntentId); + + expect(recovered).toBeDefined(); + expect(recovered?.submissionAttempted).toBe(true); + expect(recovered?.payloadFingerprint).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('does not grant a second submission right after a restart', () => { + // The failure this whole packet guards against: the process dies after a + // possible payment, comes back with no memory, and pays again. + const path = freshPath(); + const provider = createProvider(); + + const before = createHarness({ log: new FileAttemptStore(path), provider }); + before.settle(INTENT, 'allowed-confirmed'); + expect(provider.broadcastCount).toBe(1); + + // Simulate the restart: new store, new harness, same durable file. + const after = createHarness({ log: new FileAttemptStore(path), provider }); + const result = after.settle(INTENT, 'allowed-confirmed'); + + expect(result.replayed).toBe(true); + expect(result.submitted).toBe(false); + expect(provider.broadcastCount).toBe(1); + }); + + it('recovers an attempt whose outcome was never learned', () => { + // Crash during submission: the mark is on disk, the response never + // arrived. Recovery must see an attempt in flight, not a clean slate. + const path = freshPath(); + const provider = createProvider(); + + const before = createHarness({ log: new FileAttemptStore(path), provider }); + const timedOut = before.settle(INTENT, 'provider-timeout'); + expect(timedOut.classification.outcome).toBe('POSSIBLY_SUBMITTED'); + + const after = createHarness({ log: new FileAttemptStore(path), provider }); + const recovered = after.log.get(INTENT.businessIntentId); + + expect(recovered?.submissionAttempted).toBe(true); + expect(after.settle(INTENT, 'allowed-confirmed').submitted).toBe(false); + expect(provider.broadcastCount).toBe(1); + }); + + it('broadcasts once across ten restarted workers', () => { + const path = freshPath(); + const provider = createProvider(); + + for (let i = 0; i < 10; i += 1) { + const worker = createHarness({ log: new FileAttemptStore(path), provider }); + worker.settle(INTENT, 'allowed-confirmed'); + } + + expect(provider.broadcastCount).toBe(1); + expect(new FileAttemptStore(path).size).toBe(1); + }); + + it('still separates genuinely different intents across restarts', () => { + const path = freshPath(); + const provider = createProvider(); + + createHarness({ log: new FileAttemptStore(path), provider }).settle( + { ...INTENT, businessIntentId: 'intent-a' }, + 'allowed-confirmed', + ); + createHarness({ log: new FileAttemptStore(path), provider }).settle( + { ...INTENT, businessIntentId: 'intent-b' }, + 'allowed-confirmed', + ); + + expect(provider.broadcastCount).toBe(2); + expect(new FileAttemptStore(path).size).toBe(2); + }); +}); + +describe('store durability details', () => { + it('treats a missing state file as no attempts rather than crashing', () => { + const store = new FileAttemptStore(freshPath()); + expect(store.size).toBe(0); + expect(store.get('anything')).toBeUndefined(); + }); + + it('writes through on every record so nothing is buffered', () => { + // A buffered write would reopen the crash window the store exists to close. + const path = freshPath(); + const store = new FileAttemptStore(path); + store.recordOrGet({ + businessIntentId: 'x', + payloadFingerprint: '0x' + 'a'.repeat(64), + idempotencyKey: '0x' + 'a'.repeat(64), + referenceId: 'oneshot-x', + submissionAttempted: false, + }); + expect(new FileAttemptStore(path).get('x')).toBeDefined(); + }); +}); From d7f0132a1a49fe65ad2a21bdaffda9a66e2b67f1 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 16:58:33 +0200 Subject: [PATCH 4/6] feat(adapter): add failure taxonomy, evidence lookup, drift hardening, ports Implements B04 for the Coder B lane: the production adapter surface, plus the conservative classification behind it. Failure taxonomy (B04.1). Turns on one question: did the request reach the network? DNS failure, connection refusal, and a failed TLS handshake all happen before any application data is sent, so nothing can have been broadcast and a retry is safe. A reset, a timeout, a truncated response, an interrupted TLS connection, 429, and 5xx may all follow a delivered request, so none of them permit a retry. HTTP 429 stays ambiguous rather than counting as a rejection, because a rate limiter may reject before or after queuing the work. Any error code this build has never seen classifies post-send: an unknown failure cannot be proof that nothing happened. Evidence lookup (B04.2, B04.3). NOT_FOUND is the dangerous result and gets the strictest treatment. Absence can mean never broadcast, or invisible to this node, or replaced, so permitsResubmission returns false for every observation and no code path converts an absent result into a settlement right. Evidence is bound to the exact request before it is interpreted, so a receipt from another chain, another hash, or another wallet cannot resolve this intent. A receipt that exists for our hash but does not prove our settlement is contradictory and stays unbound rather than being resolved by guess. Hashless discovery is deliberately absent: that is Coder C's Subgraph MCP path, and a second, weaker way to decide a payment happened must not grow here. Drift hardening (B04.4). The settlement boundary depends on a Privy policy, a wallet, a chain, a token, and a cap that all live outside this repository and can change with no commit and no review. detectDrift compares observed identity against a reviewed baseline and fails closed on any difference, including a lowered cap: judging whether a change is benign is not this module's job. assertNoDrift throws rather than returning a value a caller could ignore. Webhooks stay disabled, since signature verification is unproven and polling is already complete. Production entry point (B04.5). Exports only the frozen ports and coarse sanitized error codes, so provider error text carrying bodies and headers never crosses the seam. A test asserts no import reaches an A- or C-owned package rather than trusting review to catch it. The compatibility manifest states what the host must supply, what the adapter does not do, and its live gaps, so fixtures cannot masquerade as live evidence. --- docs/settlement/ADAPTER_CONTRACT_V1.md | 138 +++++++++++++ packages/arc-adapter/eslint.config.js | 6 + packages/arc-adapter/src/evidence.ts | 191 ++++++++++++++++++ packages/arc-adapter/src/failure-taxonomy.ts | 168 +++++++++++++++ packages/arc-adapter/src/index.ts | 2 + packages/arc-adapter/test/evidence.test.ts | 185 +++++++++++++++++ .../arc-adapter/test/failure-taxonomy.test.ts | 118 +++++++++++ packages/privy-adapter/eslint.config.js | 6 + packages/privy-adapter/package-lock.json | 24 +++ packages/privy-adapter/package.json | 5 +- packages/privy-adapter/src/hardening.ts | 176 ++++++++++++++++ packages/privy-adapter/src/index.ts | 2 + packages/privy-adapter/src/ports.ts | 116 +++++++++++ packages/privy-adapter/test/boundary.test.ts | 71 +++++++ packages/privy-adapter/test/hardening.test.ts | 111 ++++++++++ .../test/contract-integration.test.ts | 120 +++++++++++ 16 files changed, 1437 insertions(+), 2 deletions(-) create mode 100644 docs/settlement/ADAPTER_CONTRACT_V1.md create mode 100644 packages/arc-adapter/src/evidence.ts create mode 100644 packages/arc-adapter/src/failure-taxonomy.ts create mode 100644 packages/arc-adapter/test/evidence.test.ts create mode 100644 packages/arc-adapter/test/failure-taxonomy.test.ts create mode 100644 packages/privy-adapter/src/hardening.ts create mode 100644 packages/privy-adapter/src/ports.ts create mode 100644 packages/privy-adapter/test/boundary.test.ts create mode 100644 packages/privy-adapter/test/hardening.test.ts create mode 100644 packages/testkit-settlement/test/contract-integration.test.ts diff --git a/docs/settlement/ADAPTER_CONTRACT_V1.md b/docs/settlement/ADAPTER_CONTRACT_V1.md new file mode 100644 index 0000000..a88d888 --- /dev/null +++ b/docs/settlement/ADAPTER_CONTRACT_V1.md @@ -0,0 +1,138 @@ +# settlement-adapter-contract-v1 + +B04 handoff artifact. The production surface Coder A composes against, and the +list of things it deliberately does not do. + +Implemented by `@oneshot/privy-adapter` and `@oneshot/arc-adapter`. Both build +and test with no implementation from the A or C lanes present; an automated +test asserts that no import reaches an A- or C-owned package. + +## 1. Provided ports + +| Port | Results | +| --- | --- | +| `AuthorizationPort.evaluate` | `AUTHORIZED`, `DENIED`, `UNAVAILABLE` | +| `SettlementPort.submit` | `CONFIRMED`, `DEFINITELY_NOT_SUBMITTED`, `POSSIBLY_SUBMITTED` | +| `EvidencePort.lookup` | `FINAL_SUCCESS`, `FINAL_REVERT`, `PENDING`, `NOT_FOUND`, `UNAVAILABLE` | + +## 2. Required from the host + +The adapter is stateless about settlement rights. A must supply: + +- durable Business Intent and Attempt state; +- an atomic submission-ownership grant; +- request identity persisted across restarts. + +## 3. Not provided + +- Reconciliation decisions. The adapter reports; it does not decide to retry. +- External-index authority. Hashless discovery is Coder C's Subgraph MCP path, + deliberately absent here so no second, weaker way to decide a payment + happened can develop. +- Automatic transaction replacement. +- User interface. + +## 4. Error taxonomy + +The taxonomy turns on one question: **did the request reach the network?** + +### Proven pre-broadcast, retry permitted + +| Signal | Why it proves nothing was sent | +| --- | --- | +| `ENOTFOUND`, `EAI_AGAIN` | DNS never resolved; no connection opened | +| `ECONNREFUSED` | Peer refused the TCP handshake | +| TLS handshake failures | Failed before any application data was transmitted | +| HTTP 4xx except 429 | Provider rejected on its own terms without acting | + +### Possibly submitted, retry never permitted + +`ECONNRESET`, `ETIMEDOUT`, `EPIPE`, premature stream close, HTTP 429, HTTP 5xx, +truncated or malformed responses, lost success responses, process termination, +**and every unrecognized error**. + +Two boundaries worth stating explicitly: + +- **429 is ambiguous**, not a rejection. A rate limiter may reject before or + after queuing the work. +- **A failed TLS handshake is pre-broadcast; an interrupted TLS connection is + not.** The first cannot have delivered a request; the second may have. + +An error this build has never seen is classified post-send. An unknown failure +cannot be proof that nothing happened, and treating it as proof is the mistake +that pays twice. + +## 5. `NOT_FOUND` is not permission + +Absent evidence can mean the transaction was never broadcast, or sits in a +mempool this node cannot see, or that the node is behind, or that it was +replaced. `permitsResubmission` returns `false` for every observation, and +there is no code path that turns an absent result into a settlement right. + +Only bound, final evidence terminates an intent. A receipt that exists for our +hash but does not prove our settlement is contradictory and stays unbound +rather than being resolved by guess. + +## 6. Drift hardening + +The settlement boundary depends on values living outside this repository: a +Privy policy, a wallet, a chain, a token, a cap. Any can change with no commit +and no review. + +`detectDrift` compares observed identity against a reviewed baseline across +`policyDigest`, `policyId`, `walletId`, `walletAddress`, `chainId`, +`tokenContract`, and `settlementCapAtomic`. Any difference fails closed, and +`assertNoDrift` throws rather than returning a value a caller could ignore. + +A **lowered** cap is reported as drift too. Judging whether a change is benign +is not this module's job; detecting that the deployment no longer matches what +was reviewed is. + +## 7. Webhooks + +Disabled. A webhook is an unauthenticated inbound claim about a payment. +Signature verification against Privy's scheme is unproven here, and polling +through `EvidencePort` is complete on its own, so enabling one would add attack +surface without adding capability. + +## 8. Redaction report + +- Provider error text never crosses the package boundary. Callers receive an + `AdapterError` code and a sanitized message. +- Evidence detail strings are truncated to 200 characters, because provider + errors can embed whole response bodies. +- Redaction is deny-by-default on key name and on value shape, with named + hash-bearing fields exempt from the 32-byte-hex rule so transaction hashes, + block hashes, topics, and fingerprints survive as evidence. +- No module in either package reads `ONESHOT_PRIVY_APP_SECRET`. + +## 9. Live gaps for Gate P4 + +Listed so fixtures cannot pass as live evidence: + +- No Privy tenant has executed a policy denial or an allowed settlement. +- Arc receipt and Transfer log shapes are modelled from documentation, never + observed. +- Privy wallet and policy identifier formats are shape-guessed; the documented + format should replace `IDENTIFIER_SHAPE`. + +Per `.agents/skills/sponsor-qualification/SKILL.md`, the Privy and Arc claims +stay `NOT VERIFIED` until `docs/settlement/LIVE_EVIDENCE.md` records a sanitized +live transaction. + +## 10. P4 replacement instructions + +When credentials exist: + +1. Run `docs/settlement/PROVIDER_SETUP.md`. +2. Verify with `cd packages/arc-adapter && npm run probe`. +3. Record the observed policy digest and wallet identity as the baseline. +4. Execute the negative suite against the live tenant and confirm zero + transfers, then one allowed settlement. +5. Capture responses through `captureReceiptFixture` and replace the modelled + fixtures with sanitized real ones. +6. Update `LIVE_EVIDENCE.md` from `LIVE_NOT_RUN` to `LIVE_RUN` with the + sanitized hash, block, and explorer URL. + +Replacing fixtures must not change any classifier or verifier result. If it +does, the model was wrong and the difference is the finding. diff --git a/packages/arc-adapter/eslint.config.js b/packages/arc-adapter/eslint.config.js index 4d69fd5..d1d640a 100644 --- a/packages/arc-adapter/eslint.config.js +++ b/packages/arc-adapter/eslint.config.js @@ -19,6 +19,12 @@ export default tseslint.config( 'error', { allowNumber: true }, ], + // A leading underscore marks a parameter kept for signature shape but + // deliberately unused, such as a port method that always refuses. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], }, }, ); diff --git a/packages/arc-adapter/src/evidence.ts b/packages/arc-adapter/src/evidence.ts new file mode 100644 index 0000000..8faf196 --- /dev/null +++ b/packages/arc-adapter/src/evidence.ts @@ -0,0 +1,191 @@ +/** + * EvidencePort lookup (B04.2, B04.3). + * + * Answers "what happened to this specific request?" from persisted identity, + * without ever answering "so you may pay again". + * + * `NOT_FOUND` is the dangerous result and gets the strictest treatment. An + * absent transaction can mean it was never broadcast, or that it is in a + * mempool this node cannot see, or that the node is behind, or that it was + * replaced. `.agent/SECURITY_INVARIANTS.md` is explicit: never infer + * non-payment from an empty or delayed external result. So `NOT_FOUND` is an + * observation, never a permission, and this module has no code path that turns + * one into the other. + */ + +import { verifyReceipt, type ExpectedSettlement, type TransactionReceipt } from './receipt.js'; + +export type EvidenceResult = + /** Final receipt and exactly the expected Transfer. */ + | 'FINAL_SUCCESS' + /** Final receipt proving the transaction reverted. */ + | 'FINAL_REVERT' + /** Known to exist, not yet final. */ + | 'PENDING' + /** No record found. NOT proof that nothing happened. */ + | 'NOT_FOUND' + /** The question could not be asked. */ + | 'UNAVAILABLE'; + +export interface EvidenceObservation { + readonly result: EvidenceResult; + /** Sanitized detail suitable for an operator timeline. */ + readonly detail: string; + /** + * Whether this observation binds to the exact expected request. + * + * Unbound evidence is real but describes something else, and must never + * resolve the intent it was fetched for. + */ + readonly boundToRequest: boolean; +} + +/** What the caller persisted before submitting, used to look evidence up. */ +export interface PersistedIdentity { + readonly transactionHash?: string | undefined; + readonly walletAddress: string; + readonly chainId: number; + readonly tokenContract: string; + readonly recipient: string; + readonly amountAtomic: bigint; + readonly nonce?: number | undefined; +} + +/** A receipt lookup that may fail or find nothing. */ +export interface ReceiptSource { + getReceipt(transactionHash: string): Promise; +} + +function expectedFrom(identity: PersistedIdentity): ExpectedSettlement { + return { + chainId: identity.chainId, + walletAddress: identity.walletAddress, + tokenContract: identity.tokenContract, + recipient: identity.recipient, + amountAtomic: identity.amountAtomic, + }; +} + +/** + * Look up evidence for a persisted identity. + * + * Without a transaction hash there is nothing to bind to, so the answer is + * `NOT_FOUND` and unbound. Hashless discovery is Coder C's Subgraph MCP path + * and is deliberately absent here: this adapter must not develop a second, + * weaker way to decide a payment happened. + */ +export async function lookupEvidence( + identity: PersistedIdentity, + source: ReceiptSource, +): Promise { + if (identity.transactionHash === undefined || identity.transactionHash === '') { + return { + result: 'NOT_FOUND', + detail: + 'No transaction hash was persisted for this attempt. Absence of a hash ' + + 'is not evidence that no transaction exists.', + boundToRequest: false, + }; + } + + let receipt: TransactionReceipt | null; + try { + receipt = await source.getReceipt(identity.transactionHash); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + result: 'UNAVAILABLE', + detail: `Evidence lookup failed: ${message.slice(0, 200)}`, + boundToRequest: false, + }; + } + + if (receipt === null) { + // The single most misread result in the system. + return { + result: 'NOT_FOUND', + detail: + 'No receipt found for the persisted hash. The transaction may be ' + + 'pending, replaced, or invisible to this node. This is not proof that ' + + 'no payment occurred and never permits resubmission.', + boundToRequest: false, + }; + } + + // Bind before interpreting. A receipt for a different chain or wallet is + // real evidence about something that is not this request. + if (receipt.chainId !== identity.chainId) { + return { + result: 'NOT_FOUND', + detail: `Receipt is from chain ${receipt.chainId}, not ${identity.chainId}; it does not describe this request.`, + boundToRequest: false, + }; + } + + if (receipt.transactionHash.toLowerCase() !== identity.transactionHash.toLowerCase()) { + return { + result: 'NOT_FOUND', + detail: 'The returned receipt is for a different transaction hash.', + boundToRequest: false, + }; + } + + const verdict = verifyReceipt(receipt, expectedFrom(identity)); + + switch (verdict.result) { + case 'CONFIRMED': + return { + result: 'FINAL_SUCCESS', + detail: `Verified settlement in block ${receipt.blockNumber.toString()}.`, + boundToRequest: true, + }; + + case 'FINAL_REVERT': + return { + result: 'FINAL_REVERT', + detail: 'Transaction reverted on chain; no value moved.', + boundToRequest: true, + }; + + case 'NOT_CONFIRMED': + // A receipt exists for our hash but does not prove our settlement. + // Contradictory, and ambiguity must be preserved rather than resolved. + return { + result: 'PENDING', + detail: `Receipt found but it does not prove the expected settlement: ${verdict.detail}`, + boundToRequest: false, + }; + + default: { + const unreachable: never = verdict; + return { + result: 'UNAVAILABLE', + detail: `Unhandled receipt verdict: ${String(unreachable)}`, + boundToRequest: false, + }; + } + } +} + +/** + * Whether an observation may terminate an intent. + * + * Only bound, final evidence qualifies. Everything else leaves the intent + * where it was, which for an ambiguous attempt means `UNKNOWN`. + */ +export function isTerminalEvidence(observation: EvidenceObservation): boolean { + if (!observation.boundToRequest) return false; + return observation.result === 'FINAL_SUCCESS' || observation.result === 'FINAL_REVERT'; +} + +/** + * Whether an observation permits creating another settlement. + * + * Always false. The function exists so the answer is written down once, in a + * place a future caller will find, rather than re-derived per call site. + * Only a `FINAL_REVERT` may lead to a new attempt, and that decision belongs + * to the reconciliation policy Coder C owns, not to this adapter. + */ +export function permitsResubmission(_observation: EvidenceObservation): false { + return false; +} diff --git a/packages/arc-adapter/src/failure-taxonomy.ts b/packages/arc-adapter/src/failure-taxonomy.ts new file mode 100644 index 0000000..1ef10ff --- /dev/null +++ b/packages/arc-adapter/src/failure-taxonomy.ts @@ -0,0 +1,168 @@ +/** + * Submission failure taxonomy (B04.1). + * + * Turns a real transport or provider error into a submission outcome. + * + * The whole file turns on one question: **did the request reach the network?** + * + * A DNS failure or a connection refusal happens before any bytes reach the + * provider, so nothing can have been broadcast. A timeout, a reset mid-flight, + * or a truncated response all happen at or after the moment the request left, + * so a transaction may exist. The first group permits a retry; the second + * never does. + * + * Getting this boundary wrong in the safe direction costs a stuck payment that + * a human reconciles. Getting it wrong in the unsafe direction pays twice. + */ + +import type { ProviderResponse, SubmissionOutcome } from './outcome.js'; +import { classifyOutcome } from './outcome.js'; + +/** + * Transport failures that provably happened before the request was sent. + * + * Every entry needs a documented reason why no byte reached the provider. + * Adding one widens retry permission, so the bar is proof, not likelihood. + */ +export type PreBroadcastFailure = + /** Hostname never resolved. No connection was opened. */ + | 'DNS_RESOLUTION_FAILED' + /** The peer actively refused the connection. No request was sent. */ + | 'CONNECTION_REFUSED' + /** TLS handshake failed before any application data was transmitted. */ + | 'TLS_HANDSHAKE_FAILED' + /** The request was rejected locally before being written to the socket. */ + | 'LOCAL_VALIDATION_FAILED'; + +/** + * Failures that may have occurred after the request was sent. + * + * Note `TLS_INTERRUPTED` sits here while `TLS_HANDSHAKE_FAILED` sits above: + * an interrupted connection may have already delivered the request. + */ +export type PostSendAmbiguity = + | 'REQUEST_TIMEOUT' + | 'CONNECTION_RESET' + | 'TLS_INTERRUPTED' + | 'RATE_LIMITED_429' + | 'SERVER_ERROR_5XX' + | 'TRUNCATED_RESPONSE' + | 'MALFORMED_RESPONSE' + | 'LOST_SUCCESS_RESPONSE' + | 'PROCESS_TERMINATED'; + +export type TransportFailure = + | { readonly phase: 'PRE_BROADCAST'; readonly kind: PreBroadcastFailure } + | { readonly phase: 'POST_SEND'; readonly kind: PostSendAmbiguity }; + +/** + * Node error codes that prove the request never left. + * + * `ECONNREFUSED` means the peer rejected the TCP handshake. `ENOTFOUND` and + * `EAI_AGAIN` are DNS. None of them can coexist with a delivered request. + * + * `ECONNRESET` and `ETIMEDOUT` are deliberately absent: both can occur after + * the request was written. + */ +const PRE_BROADCAST_ERROR_CODES: Readonly> = { + ENOTFOUND: 'DNS_RESOLUTION_FAILED', + EAI_AGAIN: 'DNS_RESOLUTION_FAILED', + ECONNREFUSED: 'CONNECTION_REFUSED', + ERR_TLS_CERT_ALTNAME_INVALID: 'TLS_HANDSHAKE_FAILED', + UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS_HANDSHAKE_FAILED', + CERT_HAS_EXPIRED: 'TLS_HANDSHAKE_FAILED', + DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS_HANDSHAKE_FAILED', +}; + +const POST_SEND_ERROR_CODES: Readonly> = { + ECONNRESET: 'CONNECTION_RESET', + ETIMEDOUT: 'REQUEST_TIMEOUT', + ERR_SOCKET_CONNECTION_TIMEOUT: 'REQUEST_TIMEOUT', + EPIPE: 'CONNECTION_RESET', + ERR_STREAM_PREMATURE_CLOSE: 'TRUNCATED_RESPONSE', +}; + +function errorCodeOf(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +/** + * Classify a thrown transport error. + * + * Anything unrecognized is `POST_SEND`. An error this build has never seen + * cannot be proof that nothing was broadcast, and treating it as such is the + * failure mode that pays twice. + */ +export function classifyTransportError(error: unknown): TransportFailure { + const code = errorCodeOf(error); + + if (code !== undefined) { + const pre = PRE_BROADCAST_ERROR_CODES[code]; + if (pre) return { phase: 'PRE_BROADCAST', kind: pre }; + + const post = POST_SEND_ERROR_CODES[code]; + if (post) return { phase: 'POST_SEND', kind: post }; + } + + return { phase: 'POST_SEND', kind: 'MALFORMED_RESPONSE' }; +} + +/** + * Classify an HTTP status. + * + * A 4xx other than 429 means the provider rejected the request on its own + * terms and did not act on it. 429 is excluded: a rate limiter may reject + * either before or after the work is queued, so it stays ambiguous. + */ +export function classifyHttpStatus(status: number): TransportFailure { + if (status === 429) return { phase: 'POST_SEND', kind: 'RATE_LIMITED_429' }; + if (status >= 500) return { phase: 'POST_SEND', kind: 'SERVER_ERROR_5XX' }; + if (status >= 400) { + return { phase: 'PRE_BROADCAST', kind: 'LOCAL_VALIDATION_FAILED' }; + } + // A 2xx or 3xx reaching this path means the caller could not interpret the + // body, which says nothing about whether the transaction was broadcast. + return { phase: 'POST_SEND', kind: 'MALFORMED_RESPONSE' }; +} + +/** Map a transport failure onto the SettlementPort response shape. */ +export function toProviderResponse(failure: TransportFailure): ProviderResponse { + if (failure.phase === 'PRE_BROADCAST') { + return { kind: 'PRE_SUBMISSION_FAILURE', proof: 'REQUEST_VALIDATION_FAILED' }; + } + + const kind: PostSendAmbiguity = failure.kind; + + switch (kind) { + case 'REQUEST_TIMEOUT': + return { kind: 'AMBIGUOUS', signal: 'TIMEOUT' }; + case 'CONNECTION_RESET': + case 'TLS_INTERRUPTED': + return { kind: 'AMBIGUOUS', signal: 'CONNECTION_RESET' }; + case 'RATE_LIMITED_429': + return { kind: 'AMBIGUOUS', signal: 'RATE_LIMITED' }; + case 'SERVER_ERROR_5XX': + return { kind: 'AMBIGUOUS', signal: 'PROVIDER_5XX' }; + case 'TRUNCATED_RESPONSE': + return { kind: 'AMBIGUOUS', signal: 'TRUNCATED_RESPONSE' }; + case 'MALFORMED_RESPONSE': + return { kind: 'AMBIGUOUS', signal: 'MALFORMED_RESPONSE' }; + case 'LOST_SUCCESS_RESPONSE': + return { kind: 'AMBIGUOUS', signal: 'LOST_RESPONSE' }; + case 'PROCESS_TERMINATED': + return { kind: 'AMBIGUOUS', signal: 'PROCESS_CRASH' }; + default: { + // Exhaustive over PostSendAmbiguity. A new member added without a case + // lands here and is treated as unrecognized, which fails closed. + const unreachable: never = kind; + return { kind: 'UNRECOGNIZED', detail: String(unreachable) }; + } + } +} + +/** Convenience: classify a transport failure straight to an outcome. */ +export function outcomeForTransportFailure(failure: TransportFailure): SubmissionOutcome { + return classifyOutcome(toProviderResponse(failure)).outcome; +} diff --git a/packages/arc-adapter/src/index.ts b/packages/arc-adapter/src/index.ts index 9d5d582..2745e75 100644 --- a/packages/arc-adapter/src/index.ts +++ b/packages/arc-adapter/src/index.ts @@ -7,3 +7,5 @@ export * from './receipt.js'; export * from './outcome.js'; export * from './viem-probe.js'; export * from './probe-cli.js'; +export * from './failure-taxonomy.js'; +export * from './evidence.js'; diff --git a/packages/arc-adapter/test/evidence.test.ts b/packages/arc-adapter/test/evidence.test.ts new file mode 100644 index 0000000..4612aad --- /dev/null +++ b/packages/arc-adapter/test/evidence.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import { + isTerminalEvidence, + lookupEvidence, + permitsResubmission, + type PersistedIdentity, + type ReceiptSource, +} from '../src/evidence.js'; +import { TRANSFER_EVENT_TOPIC, type TransactionReceipt } from '../src/receipt.js'; + +const WALLET = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const USDC = '0x3600000000000000000000000000000000000000'; +const HASH = `0x${'c'.repeat(64)}`; + +const IDENTITY: PersistedIdentity = { + transactionHash: HASH, + walletAddress: WALLET, + chainId: 5042002, + tokenContract: USDC, + recipient: RECIPIENT, + amountAtomic: 1_250_000n, +}; + +function topic(address: string): string { + return `0x${'0'.repeat(24)}${address.slice(2)}`; +} + +function receipt(overrides: Partial = {}): TransactionReceipt { + return { + transactionHash: HASH, + chainId: 5042002, + from: WALLET, + to: USDC, + status: 1, + blockNumber: 100n, + blockHash: `0x${'d'.repeat(64)}`, + logs: [ + { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(RECIPIENT)], + data: `0x${(1_250_000n).toString(16).padStart(64, '0')}`, + logIndex: 3, + }, + ], + ...overrides, + }; +} + +function source(value: TransactionReceipt | null | Error): ReceiptSource { + return { + getReceipt: () => + value instanceof Error ? Promise.reject(value) : Promise.resolve(value), + }; +} + +describe('final evidence', () => { + it('reports FINAL_SUCCESS for a verified settlement', async () => { + const observation = await lookupEvidence(IDENTITY, source(receipt())); + expect(observation.result).toBe('FINAL_SUCCESS'); + expect(observation.boundToRequest).toBe(true); + expect(isTerminalEvidence(observation)).toBe(true); + }); + + it('reports FINAL_REVERT for a reverted transaction', async () => { + const observation = await lookupEvidence(IDENTITY, source(receipt({ status: 0, logs: [] }))); + expect(observation.result).toBe('FINAL_REVERT'); + expect(isTerminalEvidence(observation)).toBe(true); + }); +}); + +describe('NOT_FOUND is never permission', () => { + it('reports NOT_FOUND when no receipt exists', async () => { + const observation = await lookupEvidence(IDENTITY, source(null)); + expect(observation.result).toBe('NOT_FOUND'); + expect(observation.boundToRequest).toBe(false); + expect(isTerminalEvidence(observation)).toBe(false); + }); + + it('says explicitly that absence is not proof of non-payment', async () => { + const observation = await lookupEvidence(IDENTITY, source(null)); + expect(observation.detail).toMatch(/not proof/i); + }); + + it('never permits resubmission on any observation', async () => { + // Written down once so no call site re-derives it. Only a reconciliation + // policy may decide to attempt again, and that is not this adapter. + for (const value of [null, receipt(), receipt({ status: 0 })]) { + const observation = await lookupEvidence(IDENTITY, source(value)); + expect(permitsResubmission(observation)).toBe(false); + } + }); + + it('reports NOT_FOUND and unbound when no hash was persisted', async () => { + // Hashless discovery is Coder C's Subgraph MCP path. This adapter must + // not grow a second, weaker way to decide a payment happened. + const observation = await lookupEvidence( + { ...IDENTITY, transactionHash: undefined }, + source(receipt()), + ); + expect(observation.result).toBe('NOT_FOUND'); + expect(observation.boundToRequest).toBe(false); + }); +}); + +describe('unavailability is distinct from absence', () => { + it('reports UNAVAILABLE when the lookup throws', async () => { + const observation = await lookupEvidence(IDENTITY, source(new Error('ECONNRESET'))); + expect(observation.result).toBe('UNAVAILABLE'); + expect(isTerminalEvidence(observation)).toBe(false); + }); + + it('truncates a large provider error', async () => { + const observation = await lookupEvidence(IDENTITY, source(new Error('x'.repeat(9000)))); + expect(observation.detail.length).toBeLessThan(300); + }); +}); + +// B04.3: evidence that cannot be bound to the exact request preserves +// ambiguity instead of resolving it. +describe('mismatched and contradictory evidence', () => { + it('refuses a receipt from another chain', async () => { + const observation = await lookupEvidence(IDENTITY, source(receipt({ chainId: 1 }))); + expect(observation.boundToRequest).toBe(false); + expect(isTerminalEvidence(observation)).toBe(false); + }); + + it('refuses a receipt for a different transaction hash', async () => { + const wrongHash = receipt({ transactionHash: `0x${'e'.repeat(64)}` }); + const observation = await lookupEvidence(IDENTITY, source(wrongHash)); + expect(observation.result).toBe('NOT_FOUND'); + expect(observation.boundToRequest).toBe(false); + }); + + it('holds ambiguity when our hash succeeded without our Transfer', async () => { + // Contradictory: the transaction is ours and final, but it does not prove + // our settlement. Resolving this either way would be a guess. + const observation = await lookupEvidence(IDENTITY, source(receipt({ logs: [] }))); + expect(observation.result).toBe('PENDING'); + expect(observation.boundToRequest).toBe(false); + expect(isTerminalEvidence(observation)).toBe(false); + }); + + it('holds ambiguity when the Transfer went elsewhere', async () => { + const redirected = receipt({ + logs: [ + { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(OTHER)], + data: `0x${(1_250_000n).toString(16).padStart(64, '0')}`, + logIndex: 3, + }, + ], + }); + expect(isTerminalEvidence(await lookupEvidence(IDENTITY, source(redirected)))).toBe(false); + }); + + it('holds ambiguity when the wallet does not match', async () => { + expect(isTerminalEvidence(await lookupEvidence(IDENTITY, source(receipt({ from: OTHER }))))).toBe( + false, + ); + }); +}); + +describe('idempotent lookup', () => { + it('returns the same observation when repeated and submits nothing', async () => { + let calls = 0; + const counting: ReceiptSource = { + getReceipt: () => { + calls += 1; + return Promise.resolve(receipt()); + }, + }; + + const first = await lookupEvidence(IDENTITY, counting); + const second = await lookupEvidence(IDENTITY, counting); + const third = await lookupEvidence(IDENTITY, counting); + + expect(second).toEqual(first); + expect(third).toEqual(first); + expect(calls).toBe(3); + // The port is read-only by construction: there is no submit path here. + }); +}); diff --git a/packages/arc-adapter/test/failure-taxonomy.test.ts b/packages/arc-adapter/test/failure-taxonomy.test.ts new file mode 100644 index 0000000..ca4633f --- /dev/null +++ b/packages/arc-adapter/test/failure-taxonomy.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyHttpStatus, + classifyTransportError, + outcomeForTransportFailure, + toProviderResponse, + type PostSendAmbiguity, + type PreBroadcastFailure, +} from '../src/failure-taxonomy.js'; + +function nodeError(code: string): Error { + return Object.assign(new Error(`simulated ${code}`), { code }); +} + +describe('errors that prove nothing was broadcast', () => { + it.each<[string, PreBroadcastFailure]>([ + ['ENOTFOUND', 'DNS_RESOLUTION_FAILED'], + ['EAI_AGAIN', 'DNS_RESOLUTION_FAILED'], + ['ECONNREFUSED', 'CONNECTION_REFUSED'], + ['CERT_HAS_EXPIRED', 'TLS_HANDSHAKE_FAILED'], + ['DEPTH_ZERO_SELF_SIGNED_CERT', 'TLS_HANDSHAKE_FAILED'], + ])('classifies %s as pre-broadcast', (code, kind) => { + const failure = classifyTransportError(nodeError(code)); + expect(failure).toEqual({ phase: 'PRE_BROADCAST', kind }); + expect(outcomeForTransportFailure(failure)).toBe('DEFINITELY_NOT_SUBMITTED'); + }); +}); + +describe('errors that may have followed a sent request', () => { + it.each<[string, PostSendAmbiguity]>([ + ['ECONNRESET', 'CONNECTION_RESET'], + ['ETIMEDOUT', 'REQUEST_TIMEOUT'], + ['EPIPE', 'CONNECTION_RESET'], + ['ERR_STREAM_PREMATURE_CLOSE', 'TRUNCATED_RESPONSE'], + ])('classifies %s as post-send', (code, kind) => { + const failure = classifyTransportError(nodeError(code)); + expect(failure).toEqual({ phase: 'POST_SEND', kind }); + expect(outcomeForTransportFailure(failure)).toBe('POSSIBLY_SUBMITTED'); + }); + + it('separates a failed TLS handshake from an interrupted connection', () => { + // The handshake failing means no application data was ever sent. A reset + // mid-connection may have delivered the request first. + expect(classifyTransportError(nodeError('CERT_HAS_EXPIRED')).phase).toBe('PRE_BROADCAST'); + expect(classifyTransportError(nodeError('ECONNRESET')).phase).toBe('POST_SEND'); + }); + + it('treats an unrecognized error as post-send', () => { + // An error this build has never seen cannot be proof that nothing + // happened. Treating it as such is the failure that pays twice. + const failure = classifyTransportError(nodeError('ESOMETHINGNEW')); + expect(failure.phase).toBe('POST_SEND'); + expect(outcomeForTransportFailure(failure)).toBe('POSSIBLY_SUBMITTED'); + }); + + it.each([null, undefined, 'a string', 42, {}])( + 'treats the non-error value %s as post-send', + (value) => { + expect(classifyTransportError(value).phase).toBe('POST_SEND'); + }, + ); +}); + +describe('http status classification', () => { + it('treats a 4xx other than 429 as a provider-side rejection', () => { + expect(classifyHttpStatus(400).phase).toBe('PRE_BROADCAST'); + expect(classifyHttpStatus(422).phase).toBe('PRE_BROADCAST'); + }); + + it('keeps 429 ambiguous', () => { + // A rate limiter may reject before or after queuing the work, so it is + // not proof of non-submission. + const failure = classifyHttpStatus(429); + expect(failure).toEqual({ phase: 'POST_SEND', kind: 'RATE_LIMITED_429' }); + expect(outcomeForTransportFailure(failure)).toBe('POSSIBLY_SUBMITTED'); + }); + + it.each([500, 502, 503, 504])('keeps %s ambiguous', (status) => { + expect(outcomeForTransportFailure(classifyHttpStatus(status))).toBe('POSSIBLY_SUBMITTED'); + }); + + it('keeps an uninterpretable 2xx ambiguous', () => { + expect(classifyHttpStatus(200).phase).toBe('POST_SEND'); + }); +}); + +describe('no ambiguous case is ever a safe retry', () => { + it.each([ + 'REQUEST_TIMEOUT', + 'CONNECTION_RESET', + 'TLS_INTERRUPTED', + 'RATE_LIMITED_429', + 'SERVER_ERROR_5XX', + 'TRUNCATED_RESPONSE', + 'MALFORMED_RESPONSE', + 'LOST_SUCCESS_RESPONSE', + 'PROCESS_TERMINATED', + ])('%s is possibly submitted', (kind) => { + expect(outcomeForTransportFailure({ phase: 'POST_SEND', kind })).toBe('POSSIBLY_SUBMITTED'); + }); + + it('maps every post-send kind to a recognized provider response', () => { + const kinds: PostSendAmbiguity[] = [ + 'REQUEST_TIMEOUT', + 'CONNECTION_RESET', + 'TLS_INTERRUPTED', + 'RATE_LIMITED_429', + 'SERVER_ERROR_5XX', + 'TRUNCATED_RESPONSE', + 'MALFORMED_RESPONSE', + 'LOST_SUCCESS_RESPONSE', + 'PROCESS_TERMINATED', + ]; + for (const kind of kinds) { + expect(toProviderResponse({ phase: 'POST_SEND', kind }).kind).not.toBe('UNRECOGNIZED'); + } + }); +}); diff --git a/packages/privy-adapter/eslint.config.js b/packages/privy-adapter/eslint.config.js index 4d69fd5..d1d640a 100644 --- a/packages/privy-adapter/eslint.config.js +++ b/packages/privy-adapter/eslint.config.js @@ -19,6 +19,12 @@ export default tseslint.config( 'error', { allowNumber: true }, ], + // A leading underscore marks a parameter kept for signature shape but + // deliberately unused, such as a port method that always refuses. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], }, }, ); diff --git a/packages/privy-adapter/package-lock.json b/packages/privy-adapter/package-lock.json index d59421d..801e38a 100644 --- a/packages/privy-adapter/package-lock.json +++ b/packages/privy-adapter/package-lock.json @@ -8,6 +8,26 @@ "name": "@oneshot/privy-adapter", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@oneshot/arc-adapter": "file:../arc-adapter", + "viem": "2.56.3" + }, + "devDependencies": { + "@eslint/js": "9.39.1", + "@types/node": "22.18.11", + "eslint": "9.39.1", + "typescript": "5.9.3", + "typescript-eslint": "8.69.0", + "vitest": "5.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "../arc-adapter": { + "name": "@oneshot/arc-adapter", + "version": "0.1.0", + "license": "MIT", "dependencies": { "viem": "2.56.3" }, @@ -306,6 +326,10 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oneshot/arc-adapter": { + "resolved": "../arc-adapter", + "link": true + }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", diff --git a/packages/privy-adapter/package.json b/packages/privy-adapter/package.json index d44fef5..4a81b16 100644 --- a/packages/privy-adapter/package.json +++ b/packages/privy-adapter/package.json @@ -28,7 +28,8 @@ "check": "npm run lint && npm run typecheck && npm run test && npm run build" }, "dependencies": { - "viem": "2.56.3" + "viem": "2.56.3", + "@oneshot/arc-adapter": "file:../arc-adapter" }, "devDependencies": { "@eslint/js": "9.39.1", @@ -38,4 +39,4 @@ "typescript-eslint": "8.69.0", "vitest": "5.0.0" } -} +} \ No newline at end of file diff --git a/packages/privy-adapter/src/hardening.ts b/packages/privy-adapter/src/hardening.ts new file mode 100644 index 0000000..575cc77 --- /dev/null +++ b/packages/privy-adapter/src/hardening.ts @@ -0,0 +1,176 @@ +/** + * Configuration drift hardening (B04.4). + * + * The settlement boundary depends on values that live outside this repository: + * a Privy policy in provider configuration, a wallet ID, a chain, a token, a + * spending cap. Any of them can change without a commit, a deploy, or a + * review. + * + * So the expected values are captured once as a baseline, and rechecked at + * startup and before sensitive use. Every difference fails closed. A widened + * cap and a narrowed cap are both failures here: this module's job is to + * detect that the world no longer matches what was reviewed, not to judge + * whether the change was benign. + */ + +import { keccak256, toHex } from 'viem'; + +/** The identity the build was reviewed against. */ +export interface SettlementBaseline { + readonly policyDigest: string; + readonly policyId: string; + readonly walletId: string; + readonly walletAddress: string; + readonly chainId: number; + readonly tokenContract: string; + readonly settlementCapAtomic: bigint; +} + +/** The identity observed right now. */ +export type ObservedIdentity = SettlementBaseline; + +export type DriftField = + | 'policyDigest' + | 'policyId' + | 'walletId' + | 'walletAddress' + | 'chainId' + | 'tokenContract' + | 'settlementCapAtomic'; + +export interface DriftFinding { + readonly field: DriftField; + /** Sanitized description. Never prints a credential. */ + readonly detail: string; +} + +export interface DriftReport { + readonly safe: boolean; + readonly findings: readonly DriftFinding[]; +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Compare observed identity against the reviewed baseline. + * + * Every field is compared, and all differences are collected rather than + * returning on the first. An operator fixing drift should see the whole + * picture, not discover it one restart at a time. + */ +export function detectDrift( + baseline: SettlementBaseline, + observed: ObservedIdentity, +): DriftReport { + const findings: DriftFinding[] = []; + + if (normalize(baseline.policyDigest) !== normalize(observed.policyDigest)) { + findings.push({ + field: 'policyDigest', + detail: + 'The deployed Privy policy no longer matches the reviewed policy. ' + + 'Settlement is refused until the policy is restored or a new baseline ' + + 'is reviewed and approved.', + }); + } + + if (normalize(baseline.policyId) !== normalize(observed.policyId)) { + findings.push({ field: 'policyId', detail: 'The attached policy identifier changed.' }); + } + + if (normalize(baseline.walletId) !== normalize(observed.walletId)) { + findings.push({ field: 'walletId', detail: 'The execution wallet identifier changed.' }); + } + + if (normalize(baseline.walletAddress) !== normalize(observed.walletAddress)) { + findings.push({ + field: 'walletAddress', + detail: 'The execution wallet address changed; evidence would not bind to prior attempts.', + }); + } + + if (baseline.chainId !== observed.chainId) { + findings.push({ + field: 'chainId', + detail: `Chain changed from ${baseline.chainId} to ${observed.chainId}.`, + }); + } + + if (normalize(baseline.tokenContract) !== normalize(observed.tokenContract)) { + findings.push({ field: 'tokenContract', detail: 'The settlement token contract changed.' }); + } + + if (baseline.settlementCapAtomic !== observed.settlementCapAtomic) { + // Both directions are reported. A lowered cap is not dangerous, but it + // still means the deployment no longer matches what was reviewed. + findings.push({ + field: 'settlementCapAtomic', + detail: + `The per-settlement cap changed from ${baseline.settlementCapAtomic.toString()} ` + + `to ${observed.settlementCapAtomic.toString()} atomic units.`, + }); + } + + return { safe: findings.length === 0, findings }; +} + +/** Deterministic digest of a baseline, for recording in evidence. */ +export function baselineDigest(baseline: SettlementBaseline): `0x${string}` { + return keccak256( + toHex( + JSON.stringify([ + normalize(baseline.policyDigest), + normalize(baseline.policyId), + normalize(baseline.walletId), + normalize(baseline.walletAddress), + baseline.chainId, + normalize(baseline.tokenContract), + baseline.settlementCapAtomic.toString(10), + ]), + ), + ); +} + +export class DriftError extends Error { + constructor(readonly findings: readonly DriftFinding[]) { + super( + `Settlement configuration drifted from the reviewed baseline: ${findings + .map((finding) => finding.field) + .join(', ')}.`, + ); + this.name = 'DriftError'; + } +} + +/** + * Gate sensitive use on an unchanged baseline. + * + * Throws rather than returning a boolean, so a caller cannot proceed by + * ignoring a return value. + */ +export function assertNoDrift( + baseline: SettlementBaseline, + observed: ObservedIdentity, +): void { + const report = detectDrift(baseline, observed); + if (!report.safe) throw new DriftError(report.findings); +} + +/** + * Webhook posture (B04.4). + * + * Webhooks stay disabled. Accepting a provider callback means accepting an + * unauthenticated inbound claim about a payment, and signature verification + * against Privy's scheme has not been proven here. Polling through + * `EvidencePort` is complete on its own, so a webhook would add attack surface + * without adding capability. + */ +export const WEBHOOKS_ENABLED = false; + +/** Why webhooks are off, surfaced in the handoff artifact. */ +export const WEBHOOK_POSTURE = + 'Disabled. Signature verification is unproven, and polling via EvidencePort ' + + 'is already complete. Enabling requires proven plan availability and verified ' + + 'signatures.'; diff --git a/packages/privy-adapter/src/index.ts b/packages/privy-adapter/src/index.ts index 582923f..5ea836c 100644 --- a/packages/privy-adapter/src/index.ts +++ b/packages/privy-adapter/src/index.ts @@ -2,3 +2,5 @@ export * from './policy.js'; export * from './scope.js'; export * from './request.js'; export * from './policy-fixture.js'; +export * from './hardening.js'; +export * from './ports.js'; diff --git a/packages/privy-adapter/src/ports.ts b/packages/privy-adapter/src/ports.ts new file mode 100644 index 0000000..605153d --- /dev/null +++ b/packages/privy-adapter/src/ports.ts @@ -0,0 +1,116 @@ +/** + * Production entry point (B04.5). + * + * Exports only the frozen port interfaces from `milestones/CONTRACTS.md` + * section 5 and sanitized error shapes. Consumers compose against this surface, + * never against adapter internals, so the internals can change without + * breaking A's composition. + * + * Import direction is one-way by design: this package depends on the contract + * and on `@oneshot/arc-adapter`, and on nothing owned by Coder A or Coder C. + * `milestones/README.md` forbids importing another owner's implementation + * package, and a compatibility test asserts it rather than trusting review. + */ + +import type { EvidenceResult, SubmissionOutcome } from '@oneshot/arc-adapter'; + +/** Compatibility metadata published in the B04 handoff artifact. */ +export const ADAPTER_CONTRACT_VERSION = 'settlement-adapter-contract-v1'; + +/** Contract pack this build was written against. */ +export const CONTRACT_PACK_VERSION = 'frozen-v1'; + +/** AuthorizationPort result, per CONTRACTS.md section 5. */ +export type AuthorizationResult = 'AUTHORIZED' | 'DENIED' | 'UNAVAILABLE'; + +export interface AuthorizationRequest { + readonly businessIntentId: string; + readonly attemptId: string; + readonly payloadFingerprint: string; + readonly chainId: number; + readonly tokenContract: `0x${string}`; + readonly recipient: `0x${string}`; + readonly amountAtomic: bigint; + readonly correlationId: string; +} + +export interface AuthorizationPort { + evaluate(request: AuthorizationRequest): Promise; +} + +export interface SettlementRequest extends AuthorizationRequest { + readonly idempotencyKey: string; + readonly referenceId: string; +} + +export interface SettlementPort { + submit(request: SettlementRequest): Promise; +} + +export interface EvidenceRequest { + readonly businessIntentId: string; + readonly transactionHash?: string | undefined; + readonly chainId: number; + readonly tokenContract: string; + readonly recipient: string; + readonly amountAtomic: bigint; +} + +export interface EvidencePort { + lookup(request: EvidenceRequest): Promise; +} + +/** + * Stable error families crossing the package boundary. + * + * Deliberately coarse. A provider's own error text can carry request bodies, + * headers, and identifiers, so it never crosses this seam; callers get a + * classification and a sanitized message. + */ +export type AdapterErrorCode = + | 'CONFIGURATION_INVALID' + | 'CONFIGURATION_DRIFTED' + | 'NOT_READY' + | 'SCOPE_DENIED' + | 'PROVIDER_UNAVAILABLE' + | 'OUTCOME_AMBIGUOUS'; + +export class AdapterError extends Error { + constructor( + readonly code: AdapterErrorCode, + message: string, + ) { + super(message); + this.name = 'AdapterError'; + } +} + +/** + * Compatibility manifest, published for A's composition. + * + * `providesPorts` is what A may rely on. `requiresHostCapabilities` is what A + * must supply. Anything absent from both is an internal detail that may change + * without notice. + */ +export const COMPATIBILITY_MANIFEST = { + contractVersion: ADAPTER_CONTRACT_VERSION, + contractPack: CONTRACT_PACK_VERSION, + providesPorts: ['AuthorizationPort', 'SettlementPort', 'EvidencePort'] as const, + requiresHostCapabilities: [ + 'durable business intent and attempt state', + 'atomic submission-ownership grant', + 'persisted request identity across restarts', + ] as const, + doesNotProvide: [ + 'reconciliation decisions', + 'external-index authority', + 'automatic transaction replacement', + 'user interface', + ] as const, + /** Live gaps, listed so fixtures cannot masquerade as live evidence. */ + liveGapsForGateP4: [ + 'No Privy tenant has executed a policy denial or an allowed settlement.', + 'Arc receipt and Transfer log shapes are modelled, never observed.', + 'Privy wallet and policy identifier formats are shape-guessed, not documented.', + ] as const, +} as const; diff --git a/packages/privy-adapter/test/boundary.test.ts b/packages/privy-adapter/test/boundary.test.ts new file mode 100644 index 0000000..93e68e8 --- /dev/null +++ b/packages/privy-adapter/test/boundary.test.ts @@ -0,0 +1,71 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { COMPATIBILITY_MANIFEST } from '../src/ports.js'; + +/** Every source file in this package. */ +function sourceFiles(dir = 'src'): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.name.endsWith('.ts') ? [path] : []; + }); +} + +// B04.5 requires that no import reaches an A- or C-owned package. Asserted +// here rather than trusted to review, because the import that breaks lane +// independence is the easy one to add by accident. +describe('lane import boundary', () => { + const FORBIDDEN = [ + '@oneshot/domain', + '@oneshot/storage-postgres', + '@oneshot/contracts', + '@oneshot/testkit-domain', + '@oneshot/reconciliation', + '@oneshot/recovery-agent', + '@oneshot/subgraph-mcp-adapter', + '@oneshot/testkit-failures', + ]; + + it.each(FORBIDDEN)('does not import %s', (pkg) => { + for (const file of sourceFiles()) { + expect(readFileSync(file, 'utf8')).not.toContain(pkg); + } + }); + + it('imports no other lane package at all', () => { + // Catches a package name added after this test was written. + const allowed = new Set(['@oneshot/arc-adapter']); + for (const file of sourceFiles()) { + const matches = readFileSync(file, 'utf8').matchAll(/@oneshot\/[a-z-]+/g); + for (const [name] of matches) { + expect(allowed).toContain(name); + } + } + }); +}); + +describe('compatibility manifest', () => { + it('publishes exactly the three frozen ports', () => { + expect(COMPATIBILITY_MANIFEST.providesPorts).toEqual([ + 'AuthorizationPort', + 'SettlementPort', + 'EvidencePort', + ]); + }); + + it('states what the host must supply', () => { + expect(COMPATIBILITY_MANIFEST.requiresHostCapabilities.length).toBeGreaterThan(0); + }); + + it('excludes reconciliation and index authority', () => { + expect(COMPATIBILITY_MANIFEST.doesNotProvide).toContain('reconciliation decisions'); + expect(COMPATIBILITY_MANIFEST.doesNotProvide).toContain('external-index authority'); + }); + + it('lists live gaps so fixtures cannot pass as live evidence', () => { + // Acceptance criterion: live-only gaps must not masquerade as completed + // evidence. + expect(COMPATIBILITY_MANIFEST.liveGapsForGateP4.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/privy-adapter/test/hardening.test.ts b/packages/privy-adapter/test/hardening.test.ts new file mode 100644 index 0000000..96975c4 --- /dev/null +++ b/packages/privy-adapter/test/hardening.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + DriftError, + WEBHOOKS_ENABLED, + assertNoDrift, + baselineDigest, + detectDrift, + type DriftField, + type SettlementBaseline, +} from '../src/hardening.js'; + +const BASELINE: SettlementBaseline = { + policyDigest: '0x' + 'a'.repeat(64), + policyId: 'policy_1234567890', + walletId: 'wallet_1234567890', + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000', + settlementCapAtomic: 1_000_000n, +}; + +describe('no drift', () => { + it('accepts an identical observation', () => { + expect(detectDrift(BASELINE, { ...BASELINE })).toEqual({ safe: true, findings: [] }); + }); + + it('ignores casing differences in identifiers', () => { + const observed = { + ...BASELINE, + walletAddress: BASELINE.walletAddress.toUpperCase().replace('0X', '0x'), + tokenContract: BASELINE.tokenContract.toUpperCase().replace('0X', '0x'), + }; + expect(detectDrift(BASELINE, observed).safe).toBe(true); + }); +}); + +describe('every field fails closed on change', () => { + it.each<[DriftField, Partial]>([ + ['policyDigest', { policyDigest: '0x' + 'b'.repeat(64) }], + ['policyId', { policyId: 'policy_other' }], + ['walletId', { walletId: 'wallet_other' }], + ['walletAddress', { walletAddress: '0x' + '2'.repeat(40) }], + ['chainId', { chainId: 1 }], + ['tokenContract', { tokenContract: '0x' + '4'.repeat(40) }], + ['settlementCapAtomic', { settlementCapAtomic: 2_000_000n }], + ])('detects drift in %s', (field, override) => { + const report = detectDrift(BASELINE, { ...BASELINE, ...override }); + expect(report.safe).toBe(false); + expect(report.findings.map((finding) => finding.field)).toContain(field); + }); + + it('treats a lowered cap as drift too', () => { + // Not dangerous, but the deployment no longer matches what was reviewed. + // Judging benignity is not this module's job. + const report = detectDrift(BASELINE, { ...BASELINE, settlementCapAtomic: 1n }); + expect(report.safe).toBe(false); + }); + + it('reports every difference at once', () => { + const report = detectDrift(BASELINE, { + ...BASELINE, + chainId: 1, + policyId: 'policy_other', + settlementCapAtomic: 9n, + }); + expect(report.findings).toHaveLength(3); + }); +}); + +describe('assertNoDrift', () => { + it('passes silently when nothing changed', () => { + expect(() => { + assertNoDrift(BASELINE, { ...BASELINE }); + }).not.toThrow(); + }); + + it('throws rather than returning a value a caller could ignore', () => { + expect(() => { + assertNoDrift(BASELINE, { ...BASELINE, chainId: 1 }); + }).toThrow(DriftError); + }); + + it('names the drifted fields without printing credentials', () => { + try { + assertNoDrift(BASELINE, { ...BASELINE, policyId: 'policy_other' }); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DriftError).message).toContain('policyId'); + expect((error as DriftError).message).not.toContain('policy_other'); + } + }); +}); + +describe('baseline digest', () => { + it('is stable for identical baselines', () => { + expect(baselineDigest(BASELINE)).toBe(baselineDigest({ ...BASELINE })); + }); + + it('changes when any field changes', () => { + expect(baselineDigest({ ...BASELINE, chainId: 1 })).not.toBe(baselineDigest(BASELINE)); + }); +}); + +describe('webhook posture', () => { + it('keeps webhooks disabled', () => { + // A webhook is an unauthenticated inbound claim about a payment. Signature + // verification is unproven and polling is already complete, so enabling + // one would add attack surface without adding capability. + expect(WEBHOOKS_ENABLED).toBe(false); + }); +}); diff --git a/packages/testkit-settlement/test/contract-integration.test.ts b/packages/testkit-settlement/test/contract-integration.test.ts new file mode 100644 index 0000000..7ae059b --- /dev/null +++ b/packages/testkit-settlement/test/contract-integration.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyHttpStatus, + classifyTransportError, + isTerminalEvidence, + lookupEvidence, + outcomeForTransportFailure, + permitsResubmission, + type PersistedIdentity, + type ReceiptSource, +} from '@oneshot/arc-adapter'; +import { COMPATIBILITY_MANIFEST, assertNoDrift, type SettlementBaseline } from '@oneshot/privy-adapter'; +import { FileAttemptStore } from '../src/attempt-store.js'; +import { createHarness } from '../src/harness.js'; +import { createProvider } from '../src/provider-simulator.js'; +import { rmSync } from 'node:fs'; +import { afterEach } from 'vitest'; + +const STATE_DIR = 'tmp/contract-integration'; +afterEach(() => { + rmSync(STATE_DIR, { recursive: true, force: true }); +}); + +const INTENT = { + businessIntentId: '018f-contract-intent', + chainId: 5042002, + tokenContract: '0x3600000000000000000000000000000000000000' as const, + recipient: '0x1111111111111111111111111111111111111111' as const, + amountAtomic: 1_250_000n, +}; + +const BASELINE: SettlementBaseline = { + policyDigest: '0x' + 'a'.repeat(64), + policyId: 'policy_1234567890', + walletId: 'wallet_1234567890', + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + chainId: 5042002, + tokenContract: INTENT.tokenContract, + settlementCapAtomic: 1_000_000n, +}; + +const IDENTITY: PersistedIdentity = { + transactionHash: undefined, + walletAddress: BASELINE.walletAddress, + chainId: INTENT.chainId, + tokenContract: INTENT.tokenContract, + recipient: INTENT.recipient, + amountAtomic: INTENT.amountAtomic, +}; + +const emptySource: ReceiptSource = { getReceipt: () => Promise.resolve(null) }; + +// B04.5: the adapter pack composed end to end against simulators only, with no +// implementation from Coder A or Coder C present. +describe('adapter pack contract integration', () => { + it('runs the full flow with no A or C implementation available', () => { + const provider = createProvider(); + const harness = createHarness({ + log: new FileAttemptStore(`${STATE_DIR}/a.json`), + provider, + }); + + assertNoDrift(BASELINE, { ...BASELINE }); + const result = harness.settle(INTENT, 'allowed-confirmed'); + + expect(result.classification.outcome).toBe('CONFIRMED'); + expect(provider.broadcastCount).toBe(1); + }); + + it('refuses to settle when configuration drifted', () => { + // Drift is checked before sensitive use, so a changed policy stops the + // flow rather than being discovered during a payment. + const provider = createProvider(); + expect(() => { + assertNoDrift(BASELINE, { ...BASELINE, chainId: 1 }); + }).toThrow(); + expect(provider.broadcastCount).toBe(0); + }); + + it('keeps an ambiguous submission unresolved when evidence is absent', async () => { + // The complete dangerous path: submit, lose the response, look for + // evidence, find none. Nothing in that chain may permit paying again. + const provider = createProvider(); + const harness = createHarness({ + log: new FileAttemptStore(`${STATE_DIR}/b.json`), + provider, + }); + + const submitted = harness.settle(INTENT, 'allowed-lost-response'); + expect(submitted.classification.outcome).toBe('POSSIBLY_SUBMITTED'); + + const observation = await lookupEvidence(IDENTITY, emptySource); + expect(observation.result).toBe('NOT_FOUND'); + expect(isTerminalEvidence(observation)).toBe(false); + expect(permitsResubmission(observation)).toBe(false); + + // A retry after all that still does not broadcast. + harness.settle(INTENT, 'allowed-confirmed'); + expect(provider.broadcastCount).toBe(1); + }); + + it('routes a transport failure through the taxonomy to a durable outcome', () => { + const refused = classifyTransportError( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ); + expect(outcomeForTransportFailure(refused)).toBe('DEFINITELY_NOT_SUBMITTED'); + + const reset = classifyTransportError( + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ); + expect(outcomeForTransportFailure(reset)).toBe('POSSIBLY_SUBMITTED'); + + expect(outcomeForTransportFailure(classifyHttpStatus(429))).toBe('POSSIBLY_SUBMITTED'); + }); + + it('publishes a manifest that names its live gaps', () => { + expect(COMPATIBILITY_MANIFEST.contractPack).toBe('frozen-v1'); + expect(COMPATIBILITY_MANIFEST.liveGapsForGateP4.length).toBeGreaterThan(0); + }); +}); From 154d8cc483efec279ed59020b29aa85de2a083ba Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 17:17:26 +0200 Subject: [PATCH 5/6] refactor(evidence): drop unused nonce field from PersistedIdentity The review noted that PersistedIdentity declared a nonce nobody reads, which implies a wrong-nonce defence that does not exist: binding is done on the transaction hash alone. Removed, with a comment recording why, rather than leaving a field that overstates what the type checks. --- packages/arc-adapter/src/evidence.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/arc-adapter/src/evidence.ts b/packages/arc-adapter/src/evidence.ts index 8faf196..25bf692 100644 --- a/packages/arc-adapter/src/evidence.ts +++ b/packages/arc-adapter/src/evidence.ts @@ -48,9 +48,11 @@ export interface PersistedIdentity { readonly tokenContract: string; readonly recipient: string; readonly amountAtomic: bigint; - readonly nonce?: number | undefined; } +// No `nonce` field: binding is done on the transaction hash, and a declared +// nonce nobody reads would imply a "wrong nonce" defence that does not exist. + /** A receipt lookup that may fail or find nothing. */ export interface ReceiptSource { getReceipt(transactionHash: string): Promise; From 33d98b50107e2b18e89ab0da049a0949c07a875f Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 22:26:43 +0200 Subject: [PATCH 6/6] ci: drop the separate npm job for the settlement packages The settlement-packages job ran npm ci and npm run check in each of the three B-lane package directories, using their package-local lockfiles. It was the compensating control that kept those packages tested while they were quarantined from the pnpm workspace. They are now workspace members covered by the root lint, typecheck, build, and vitest runs, and their npm lockfiles are gone, so the job can no longer resolve its cache paths or run npm ci. Keeping it would mean maintaining a second, divergent toolchain for the same code. Their 345 tests now run inside the root suite instead, alongside the rest of the workspace. --- .github/workflows/stack-lint.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/stack-lint.yml b/.github/workflows/stack-lint.yml index 3f42d26..7f22bac 100644 --- a/.github/workflows/stack-lint.yml +++ b/.github/workflows/stack-lint.yml @@ -112,30 +112,3 @@ jobs: env: TEST_POSTGRES: '1' run: pnpm test:integration - - settlement-packages: - name: Arc and Privy packages - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: npm - cache-dependency-path: | - packages/arc-adapter/package-lock.json - packages/privy-adapter/package-lock.json - packages/testkit-settlement/package-lock.json - - - name: Check Arc adapter - working-directory: packages/arc-adapter - run: npm ci && npm run check - - - name: Check Privy adapter - working-directory: packages/privy-adapter - run: npm ci && npm run check - - - name: Check settlement testkit - working-directory: packages/testkit-settlement - run: npm ci && npm run check