From 031b119b8d97570b1247cbe37a5e42032333ef84 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 15:45:40 +0200 Subject: [PATCH] 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); + }); +});