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..25bf692 --- /dev/null +++ b/packages/arc-adapter/src/evidence.ts @@ -0,0 +1,193 @@ +/** + * 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; +} + +// 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; +} + +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); + }); +});