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); + }); +});