diff --git a/docs/COMPOSITION_MANIFEST.md b/docs/COMPOSITION_MANIFEST.md index 2a2388c..69e810b 100644 --- a/docs/COMPOSITION_MANIFEST.md +++ b/docs/COMPOSITION_MANIFEST.md @@ -34,8 +34,8 @@ All ports conform to frozen definitions in `@oneshot/contracts`: ### 2. `production` Profile (Targeted for Gate P4 Convergence) -- **Settlement**: Arc Settlement Adapter (`@oneshot/adapter-arc`, owned by Coder B) -- **Authorization**: Privy Authorization Adapter (`@oneshot/adapter-privy`, owned by Coder B) +- **Settlement**: `ArcSettlementAdapter` (`@oneshot/privy-adapter`, owned by Coder B) +- **Authorization**: `PrivyAuthorizationAdapter` (`@oneshot/privy-adapter`, owned by Coder B) - **Reconciliation**: Subgraph MCP Recovery Engine (`@oneshot/reconciliation-subgraph`, owned by Coder C) ## Environment Configuration diff --git a/docs/GATE_P4_CHECKLIST.md b/docs/GATE_P4_CHECKLIST.md index e329396..c8a8774 100644 --- a/docs/GATE_P4_CHECKLIST.md +++ b/docs/GATE_P4_CHECKLIST.md @@ -18,10 +18,17 @@ At Gate P4, checked simulators are replaced with real reviewed package versions, | Domain Models | `@oneshot/domain@0.1.0` | Lane A | Pinned | | PostgreSQL Storage | `@oneshot/storage-postgres@0.1.0` | Lane A | Pinned (Schema Digest: `5d5888894ff0f4f44049579f1c8ffca2a24e0b61c3af65aabdbcd78f06020d65`) | | Settlement Worker | `@oneshot/worker@0.1.0` | Lane A | Composed | -| Arc Settlement Adapter | `@oneshot/adapter-arc` | Lane B | Simulated via `SimulatorSettlementPort` | -| Privy Authorization Adapter | `@oneshot/adapter-privy` | Lane B | Simulated via `SimulatorAuthorizationPort` | +| Arc Settlement Adapter | `@oneshot/privy-adapter` (`ArcSettlementAdapter`) | Lane B | Simulated via `SimulatorSettlementPort` | +| Privy Authorization Adapter | `@oneshot/privy-adapter` (`PrivyAuthorizationAdapter`) | Lane B | Simulated via `SimulatorAuthorizationPort` | | Subgraph MCP Recovery | `@oneshot/reconciliation` | Lane C | Simulated via `c01-simulator-v1` scenarios | +Both lane-B adapters ship from `@oneshot/privy-adapter` rather than from +separate packages: settlement is a Privy wallet action carrying an Arc +transfer, so splitting them would put half of one call path in each package. +`@oneshot/arc-adapter` holds the Arc profiles, money, receipt verification, and +readiness probing they build on. See +`docs/settlement/GATE_P4_LANE_B_READINESS.md` for the injection recipe. + ## Replacement Instructions for Gate P4 1. **Replace Settlement Port**: @@ -45,10 +52,12 @@ At Gate P4, checked simulators are replaced with real reviewed package versions, ## Verification Commands -Before P4, the Arc, Privy, and settlement testkit packages keep their reviewed npm -toolchains and are checked by the dedicated `settlement-packages` CI job. P4 may -consolidate them into the root pnpm workspace only after their package contracts and -tool versions are reconciled. +The Arc, Privy, and settlement testkit packages are full members of the root +pnpm workspace and are covered by the root `lint`, `typecheck`, `build`, and +`vitest` runs. The separate `settlement-packages` CI job and their package-local +npm toolchains were removed when they were consolidated, ahead of P4 rather than +during it, because their npm lockfiles broke `pnpm install --frozen-lockfile` on +`develop`. Run the full verification matrix to validate integrated convergence: diff --git a/docs/settlement/GATE_P4_LANE_B_READINESS.md b/docs/settlement/GATE_P4_LANE_B_READINESS.md new file mode 100644 index 0000000..5c6989f --- /dev/null +++ b/docs/settlement/GATE_P4_LANE_B_READINESS.md @@ -0,0 +1,105 @@ +# Gate P4 readiness, lane B + +What lane B provides for Gate P4, what Coder A must change to use it, and the +one naming disagreement that needs settling before composition. + +## 1. What to inject + +`docs/GATE_P4_CHECKLIST.md` step 1 and 2 replace the simulator ports in +`apps/worker/src/composition.ts`. The replacements are: + +| Checklist name | Actual export | Package | +| --------------------------- | --------------------------- | ------------------------ | +| `ArcSettlementAdapter` | `ArcSettlementAdapter` | `@oneshot/privy-adapter` | +| `PrivyAuthorizationAdapter` | `PrivyAuthorizationAdapter` | `@oneshot/privy-adapter` | + +Both declare `contractVersion = '1.0.0'`, and the settlement adapter declares +`network = 'eip155:5042002'`, matching what `composeWorker` verifies. + +```ts +import { ArcSettlementAdapter, PrivyAuthorizationAdapter } from '@oneshot/privy-adapter'; +import { loadSettlementConfig } from '@oneshot/arc-adapter'; + +const config = loadSettlementConfig(process.env); + +const settlementPort = new ArcSettlementAdapter(config, walletProvider); +const authorizationPort = new PrivyAuthorizationAdapter(config, reviewedBaseline, observeIdentity); +``` + +## 2. Naming disagreement to settle + +The checklist reserves package slots `@oneshot/adapter-arc` and +`@oneshot/adapter-privy`. Lane B shipped `@oneshot/arc-adapter` and +`@oneshot/privy-adapter`, and those names are already merged, imported, and +referenced in `pnpm-workspace.yaml`, the root `tsconfig.json`, and the fixture +and documentation set. + +Renaming is possible but touches every consumer. The names above are what +exists today. This needs an explicit decision rather than being discovered +during composition. + +Both adapters live in `@oneshot/privy-adapter` rather than being split across +two packages, because settlement is a Privy wallet action that carries an Arc +transfer: splitting them would put half of one call path in each package. + +## 3. Conformance is checked at compile time + +`packages/privy-adapter/src/p4-conformance.ts` mirrors the worker's +`AuthorizationPort` and `SettlementPort` interfaces and statically asserts both +adapters satisfy them, including the `contractVersion` and `network` fields +that `composeWorker` reads. + +It mirrors rather than imports, because importing `apps/worker` would break the +lane rule against depending on another owner's implementation package. The +mirror is verified to fail: renaming `submit` makes `tsc` report + +```text +error TS2344: Type 'ArcSettlementAdapter' does not satisfy the constraint +'WorkerInjectableSettlementPort'. +``` + +If Coder A changes those interfaces, this file fails the build and the mismatch +surfaces here instead of during composition. + +## 4. What A must supply + +The adapters take their provider as an injected interface, so nothing in lane B +opens a socket or reads a credential. + +`WalletProvider` needs two methods: + +- `sendTransaction({ chainId, to, value, data, idempotencyKey, referenceId })` + signs and broadcasts, and **must pass `idempotencyKey` through to Privy** so a + duplicate collapses provider-side as well as in OneShot state. +- `getReceipt(transactionHash)` returns the receipt or `null`. + +`PrivyAuthorizationAdapter` also needs a reviewed `SettlementBaseline` and an +`observeIdentity()` callback returning the currently deployed identity, so +policy drift is detected before authorization rather than during a payment. + +## 5. Behaviour worth knowing before composition + +- **Drift reports `UNAVAILABLE`, not `DENIED`.** A drifted policy makes every + answer untrustworthy rather than making this particular intent unauthorized. + Treat it as retryable-after-fix, not as a decision about the intent. +- **`POSSIBLY_SUBMITTED` is the default for doubt.** Only a proven pre-broadcast + failure or an on-chain revert returns `DEFINITELY_NOT_SUBMITTED`. Everything + else, including an unreadable receipt and a receipt that does not prove our + Transfer, is possibly submitted. +- **A successful receipt is not confirmation.** `CONFIRMED` requires exactly one + matching Transfer from the configured token to the expected recipient for the + exact amount. +- **Native value is always zero**, asserted by test. + +## 6. Still not proven + +Per `.agents/skills/sponsor-qualification/SKILL.md`, no sponsor claim may rest +on fixtures. These remain unverified against reality and are listed in +`COMPATIBILITY_MANIFEST.liveGapsForGateP4`: + +- No Privy tenant has executed a policy denial or an allowed settlement. +- Arc receipt and Transfer log shapes are modelled from documentation. +- Privy wallet and policy identifier formats are shape-guessed. + +`docs/settlement/LIVE_EVIDENCE.md` still reads `LIVE_NOT_RUN`. Privy and Arc +claims stay `NOT VERIFIED` until it does not. diff --git a/packages/privy-adapter/package.json b/packages/privy-adapter/package.json index 73b039c..3ac42fb 100644 --- a/packages/privy-adapter/package.json +++ b/packages/privy-adapter/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@oneshot/arc-adapter": "workspace:*", - "viem": "2.56.3" + "viem": "2.56.3", + "@oneshot/contracts": "workspace:*" } } diff --git a/packages/privy-adapter/src/adapters.ts b/packages/privy-adapter/src/adapters.ts new file mode 100644 index 0000000..2474755 --- /dev/null +++ b/packages/privy-adapter/src/adapters.ts @@ -0,0 +1,306 @@ +/** + * Concrete adapters for Gate P4 composition. + * + * `docs/GATE_P4_CHECKLIST.md` replaces `SimulatorSettlementPort` and + * `SimulatorAuthorizationPort` in `apps/worker/src/composition.ts` with real + * lane-B implementations. These are those implementations. + * + * They satisfy the worker's port interfaces structurally rather than by + * importing them: `apps/worker` is Coder A's package, and the lane rule forbids + * importing another owner's implementation. `@oneshot/contracts` is the + * sanctioned shared seam, so the result shapes come from there and the classes + * fit the worker's interfaces without a dependency on it. + * + * Both take their provider as an injected interface. Nothing here opens a + * socket or reads a credential, so the whole settlement path is exercisable + * offline and P4 supplies the live implementations. + */ + +import { + asBlockNumber, + asProviderReferenceId, + asTransactionHash, + type AuthorizationResult, + type CreateIntentRequest, + type SettlementResult, +} from '@oneshot/contracts'; +import { + classifyOutcome, + classifyTransportError, + toProviderResponse, + verifyReceipt, + type SettlementConfig, + type TransactionReceipt, +} from '@oneshot/arc-adapter'; +import { buildCanonicalRequest } from './request.js'; +import { evaluateScope, type ExpectedScope } from './scope.js'; +import { assertNoDrift, type SettlementBaseline } from './hardening.js'; + +/** + * Port contract version the worker checks at composition time. + * + * Distinct from `ADAPTER_CONTRACT_VERSION` in `ports.ts`, which names the + * published handoff document. This one is the value + * `apps/worker/src/composition.ts` compares against. + */ +export const WORKER_PORT_CONTRACT_VERSION = '1.0.0'; + +/** The only network these adapters will act on. */ +export const SUPPORTED_NETWORK = 'eip155:5042002'; + +export interface SettlementContext { + readonly attemptId: string; + readonly correlationId: string; +} + +/** + * The provider capability the settlement adapter needs. + * + * Deliberately tiny: send one prepared transaction, and fetch one receipt. A + * larger surface would be a larger blast radius. + */ +export interface WalletProvider { + /** + * Sign and broadcast. Implementations must pass `idempotencyKey` to the + * provider so a duplicate collapses provider-side as well as locally. + */ + sendTransaction(input: { + readonly chainId: number; + readonly to: `0x${string}`; + readonly value: bigint; + readonly data: `0x${string}`; + readonly idempotencyKey: string; + readonly referenceId: string; + }): Promise<{ readonly transactionHash: string; readonly providerReferenceId: string }>; + + getReceipt(transactionHash: string): Promise; +} + +function amountOf(request: CreateIntentRequest): bigint { + return BigInt(request.amount_atomic); +} + +/** + * Authorization adapter. + * + * Refuses locally before anything reaches Privy. The remote policy is the + * enforcement boundary; this is a second, independent check that can only + * deny, never grant, so drift in the remote policy cannot silently widen what + * this build will attempt. + */ +export class PrivyAuthorizationAdapter { + readonly name = 'PrivyAuthorizationAdapter'; + readonly contractVersion = WORKER_PORT_CONTRACT_VERSION; + + constructor( + private readonly config: SettlementConfig, + private readonly baseline: SettlementBaseline, + private readonly observeIdentity: () => SettlementBaseline, + ) {} + + authorize(request: CreateIntentRequest): Promise { + if (request.network !== SUPPORTED_NETWORK) { + return Promise.resolve({ + kind: 'DENIED', + reason: `Network ${request.network} is not the enabled Arc profile`, + }); + } + + // Drift is checked before the scope check, because a drifted policy makes + // every other answer untrustworthy rather than merely wrong. + try { + assertNoDrift(this.baseline, this.observeIdentity()); + } catch { + return Promise.resolve({ + kind: 'UNAVAILABLE', + reason: 'Settlement configuration drifted from the reviewed baseline', + }); + } + + const recipient = request.recipient as `0x${string}`; + + if (!this.config.recipientAllowlist.includes(recipient.toLowerCase() as `0x${string}`)) { + return Promise.resolve({ kind: 'DENIED', reason: 'Recipient is not allowlisted' }); + } + + let amount: bigint; + try { + amount = amountOf(request); + } catch { + return Promise.resolve({ kind: 'DENIED', reason: 'Amount is not a canonical integer' }); + } + + if (amount <= 0n) { + return Promise.resolve({ kind: 'DENIED', reason: 'Amount must be greater than zero' }); + } + + if (amount > BigInt(this.config.settlementCapAtomic)) { + return Promise.resolve({ + kind: 'DENIED', + reason: 'Amount exceeds the approved per-settlement cap', + }); + } + + const scope: ExpectedScope = { + chainId: this.config.profile.chainId, + tokenContract: this.config.profile.tokenContract, + recipient, + amountAtomic: amount, + }; + const decision = evaluateScope( + { + chainId: scope.chainId, + to: scope.tokenContract, + value: 0n, + data: buildCanonicalRequest({ + businessIntentId: request.business_intent_id, + chainId: scope.chainId, + tokenContract: scope.tokenContract, + recipient, + amountAtomic: amount, + }).data, + }, + scope, + ); + + if (decision.result === 'DENIED') { + return Promise.resolve({ kind: 'DENIED', reason: decision.reason }); + } + + return Promise.resolve({ kind: 'AUTHORIZED' }); + } +} + +/** + * Settlement adapter. + * + * Submits the direct USDC transfer chosen by the B01.3 spike, then confirms + * only from a verified receipt. Every failure path that could have broadcast + * returns `POSSIBLY_SUBMITTED`, which is what keeps a retry from paying twice. + */ +export class ArcSettlementAdapter { + readonly name = 'ArcSettlementAdapter'; + readonly contractVersion = WORKER_PORT_CONTRACT_VERSION; + readonly network = SUPPORTED_NETWORK; + + constructor( + private readonly config: SettlementConfig, + private readonly provider: WalletProvider, + ) {} + + /** + * Takes no `SettlementContext`: a method with fewer parameters still + * satisfies the worker's port, and the attempt and correlation identifiers + * are not used here. Submission identity comes from the Business Intent, so + * that a retry under a new attempt id still derives the same idempotency key. + */ + async submit(request: CreateIntentRequest): Promise { + if (request.network !== SUPPORTED_NETWORK) { + return { + kind: 'DEFINITELY_NOT_SUBMITTED', + reason: `Network ${request.network} is not the enabled Arc profile`, + }; + } + + let canonical; + try { + canonical = buildCanonicalRequest({ + businessIntentId: request.business_intent_id, + chainId: this.config.profile.chainId, + tokenContract: this.config.profile.tokenContract, + recipient: request.recipient as `0x${string}`, + amountAtomic: amountOf(request), + }); + } catch (error) { + // Refused locally; nothing was sent. + return { + kind: 'DEFINITELY_NOT_SUBMITTED', + reason: `Request rejected before submission: ${(error as Error).message.slice(0, 160)}`, + }; + } + + let sent: { transactionHash: string; providerReferenceId: string }; + try { + sent = await this.provider.sendTransaction({ + chainId: canonical.chainId, + to: canonical.to, + value: canonical.value, + data: canonical.data, + idempotencyKey: canonical.idempotencyKey, + referenceId: canonical.referenceId, + }); + } catch (error) { + // The taxonomy decides whether this could have reached the network. + const classification = classifyOutcome( + toProviderResponse(classifyTransportError(error)), + ); + return classification.outcome === 'DEFINITELY_NOT_SUBMITTED' + ? { kind: 'DEFINITELY_NOT_SUBMITTED', reason: classification.reason } + : { kind: 'POSSIBLY_SUBMITTED', reason: classification.reason }; + } + + let receipt: TransactionReceipt | null; + try { + receipt = await this.provider.getReceipt(sent.transactionHash); + } catch { + // The transaction was broadcast; only the confirmation failed. + return { + kind: 'POSSIBLY_SUBMITTED', + reason: 'Transaction was broadcast but its receipt could not be read', + }; + } + + if (receipt === null) { + return { + kind: 'POSSIBLY_SUBMITTED', + reason: 'Transaction was broadcast but no receipt is available yet', + }; + } + + const verdict = verifyReceipt(receipt, { + chainId: this.config.profile.chainId, + walletAddress: sent.providerReferenceId, + tokenContract: this.config.profile.tokenContract, + recipient: request.recipient, + amountAtomic: amountOf(request), + }); + + switch (verdict.result) { + case 'CONFIRMED': { + // transferLogIndex originates in provider data, so it is validated + // here rather than trusted. parseSettlementResult would reject a bad + // value downstream, but that surfaces as a thrown contract error + // inside the worker; failing closed to POSSIBLY_SUBMITTED keeps the + // intent reconcilable instead. + if ( + !Number.isSafeInteger(verdict.transferLogIndex) || + verdict.transferLogIndex < 0 + ) { + return { + kind: 'POSSIBLY_SUBMITTED', + reason: 'Receipt matched but its Transfer log index was not a valid non-negative integer', + }; + } + return { + kind: 'CONFIRMED', + provider_reference_id: asProviderReferenceId(sent.providerReferenceId), + transaction_hash: asTransactionHash(receipt.transactionHash), + block_number: asBlockNumber(receipt.blockNumber.toString(10)), + transfer_log_index: verdict.transferLogIndex, + }; + } + + case 'FINAL_REVERT': + // A revert moved no value, so a fresh attempt is safe. + return { kind: 'DEFINITELY_NOT_SUBMITTED', reason: verdict.detail }; + + case 'NOT_CONFIRMED': + // A receipt exists but does not prove our settlement. Failing to prove + // it happened is not proof that it did not. + return { kind: 'POSSIBLY_SUBMITTED', reason: verdict.detail }; + + default: + return { kind: 'POSSIBLY_SUBMITTED', reason: 'Unhandled receipt verdict' }; + } + } +} diff --git a/packages/privy-adapter/src/index.ts b/packages/privy-adapter/src/index.ts index 5ea836c..6e8ea20 100644 --- a/packages/privy-adapter/src/index.ts +++ b/packages/privy-adapter/src/index.ts @@ -4,3 +4,5 @@ export * from './request.js'; export * from './policy-fixture.js'; export * from './hardening.js'; export * from './ports.js'; +export * from './adapters.js'; +export type { AuthorizationPortConformance, SettlementPortConformance } from './p4-conformance.js'; diff --git a/packages/privy-adapter/src/p4-conformance.ts b/packages/privy-adapter/src/p4-conformance.ts new file mode 100644 index 0000000..6c765f8 --- /dev/null +++ b/packages/privy-adapter/src/p4-conformance.ts @@ -0,0 +1,68 @@ +/** + * Compile-time proof that the P4 adapters fit the worker's ports. + * + * `apps/worker/src/types.ts` declares `AuthorizationPort` and `SettlementPort`. + * Importing them here would break the lane rule against depending on another + * owner's implementation package, so the shapes are mirrored and structural + * assignability is asserted instead. + * + * This file emits no runtime code. Its whole job is to fail `tsc` the moment + * these adapters stop fitting the seam they are meant to be injected into, so + * the mismatch surfaces here rather than during Gate P4 composition. + * + * If the worker's interfaces change, update the mirror below deliberately and + * treat the diff as a contract change to agree with Coder A. + */ + +import type { + AuthorizationResult, + CreateIntentRequest, + SettlementResult, +} from '@oneshot/contracts'; +import type { ArcSettlementAdapter, PrivyAuthorizationAdapter } from './adapters.js'; + +/** Mirror of `apps/worker/src/types.ts` `AuthorizationPort`. */ +interface WorkerAuthorizationPort { + authorize(request: CreateIntentRequest): Promise; +} + +/** Mirror of `apps/worker/src/types.ts` `SettlementContext`. */ +interface WorkerSettlementContext { + readonly attemptId: string; + readonly correlationId: string; +} + +/** Mirror of `apps/worker/src/types.ts` `SettlementPort`. */ +interface WorkerSettlementPort { + submit( + request: CreateIntentRequest, + context: WorkerSettlementContext, + ): Promise; +} + +/** + * `composeWorker` also reads `contractVersion` off an injected port and + * compares it against its expected value, and reads `network` off the + * settlement port. Both are asserted so a rename cannot slip through. + */ +interface WorkerInjectableSettlementPort extends WorkerSettlementPort { + readonly contractVersion: string; + readonly network: string; +} + +interface WorkerInjectableAuthorizationPort extends WorkerAuthorizationPort { + readonly contractVersion: string; +} + +/** Fails to compile if the adapter stops satisfying the port. */ +type Satisfies = Adapter; + +export type SettlementPortConformance = Satisfies< + WorkerInjectableSettlementPort, + ArcSettlementAdapter +>; + +export type AuthorizationPortConformance = Satisfies< + WorkerInjectableAuthorizationPort, + PrivyAuthorizationAdapter +>; diff --git a/packages/privy-adapter/test/adapters.test.ts b/packages/privy-adapter/test/adapters.test.ts new file mode 100644 index 0000000..8c35c54 --- /dev/null +++ b/packages/privy-adapter/test/adapters.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; +import type { CreateIntentRequest } from '@oneshot/contracts'; +import { + TRANSFER_EVENT_TOPIC, + loadSettlementConfig, + type RawEnv, + type TransactionReceipt, +} from '@oneshot/arc-adapter'; +import { + ArcSettlementAdapter, + PrivyAuthorizationAdapter, + SUPPORTED_NETWORK, + WORKER_PORT_CONTRACT_VERSION, + type WalletProvider, +} from '../src/adapters.js'; +import type { SettlementBaseline } from '../src/hardening.js'; + +const WALLET = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const USDC = '0x3600000000000000000000000000000000000000'; + +const ENV: RawEnv = { + ONESHOT_ARC_PROFILE: 'arc-testnet', + ONESHOT_ARC_RPC_URL: 'https://rpc.example.invalid', + ONESHOT_PRIVY_APP_ID: 'app_1234567890', + ONESHOT_PRIVY_WALLET_ID: 'wallet_1234567890', + ONESHOT_PRIVY_POLICY_ID: 'policy_1234567890', + ONESHOT_RECIPIENT_ALLOWLIST: RECIPIENT, + ONESHOT_SETTLEMENT_CAP_ATOMIC: '1000000', +}; + +const config = loadSettlementConfig(ENV); + +const BASELINE: SettlementBaseline = { + policyDigest: '0x' + 'a'.repeat(64), + policyId: 'policy_1234567890', + walletId: 'wallet_1234567890', + walletAddress: WALLET, + chainId: 5042002, + tokenContract: USDC, + settlementCapAtomic: 1_000_000n, +}; + +function intent(overrides: Partial = {}): CreateIntentRequest { + return { + business_intent_id: '018f-adapter-intent', + recipient: RECIPIENT, + amount_atomic: '500000', + asset: 'USDC', + network: SUPPORTED_NETWORK, + purpose: 'Invoice INV-1001', + ...overrides, + } as CreateIntentRequest; +} + +function topic(address: string): string { + return `0x${'0'.repeat(24)}${address.slice(2)}`; +} + +function receipt(overrides: Partial = {}): TransactionReceipt { + return { + transactionHash: `0x${'c'.repeat(64)}`, + chainId: 5042002, + from: WALLET, + to: USDC, + status: 1, + blockNumber: 500n, + blockHash: `0x${'d'.repeat(64)}`, + logs: [ + { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(RECIPIENT)], + data: `0x${(500_000n).toString(16).padStart(64, '0')}`, + logIndex: 2, + }, + ], + ...overrides, + }; +} + +function provider(overrides: Partial = {}): WalletProvider & { sends: number } { + const state = { + sends: 0, + sendTransaction: () => { + state.sends += 1; + return Promise.resolve({ + transactionHash: `0x${'c'.repeat(64)}`, + providerReferenceId: WALLET, + }); + }, + getReceipt: () => Promise.resolve(receipt()), + ...overrides, + }; + return state as WalletProvider & { sends: number }; +} + +const auth = (observe = () => BASELINE) => + new PrivyAuthorizationAdapter(config, BASELINE, observe); + +describe('PrivyAuthorizationAdapter', () => { + it('declares the contract version the worker checks', () => { + expect(auth().contractVersion).toBe(WORKER_PORT_CONTRACT_VERSION); + expect(WORKER_PORT_CONTRACT_VERSION).toBe('1.0.0'); + }); + + it('authorizes an in-scope intent', async () => { + await expect(auth().authorize(intent())).resolves.toEqual({ kind: 'AUTHORIZED' }); + }); + + it.each<[string, Partial]>([ + ['a non-allowlisted recipient', { recipient: OTHER }], + ['a zero amount', { amount_atomic: '0' }], + ['an amount above the cap', { amount_atomic: '1000001' }], + ])('denies %s', async (_label, override) => { + const result = await auth().authorize(intent(override)); + expect(result.kind).toBe('DENIED'); + }); + + it('permits an amount exactly at the cap', async () => { + const result = await auth().authorize(intent({ amount_atomic: '1000000' })); + expect(result.kind).toBe('AUTHORIZED'); + }); + + it('denies a foreign network', async () => { + const result = await auth().authorize(intent({ network: 'eip155:1' })); + expect(result.kind).toBe('DENIED'); + }); + + it('reports UNAVAILABLE rather than DENIED when configuration drifted', async () => { + // Drift makes every other answer untrustworthy rather than merely wrong, + // so it must not be reported as a policy decision about this intent. + const drifted = auth(() => ({ ...BASELINE, chainId: 1 })); + const result = await drifted.authorize(intent()); + expect(result.kind).toBe('UNAVAILABLE'); + }); +}); + +describe('ArcSettlementAdapter', () => { + it('confirms from a verified receipt with contract-shaped fields', async () => { + const wallet = provider(); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'attempt-1', + correlationId: 'corr-1', + }); + + expect(result.kind).toBe('CONFIRMED'); + if (result.kind === 'CONFIRMED') { + expect(result.transaction_hash).toBe(`0x${'c'.repeat(64)}`); + expect(result.block_number).toBe('500'); + expect(result.transfer_log_index).toBe(2); + } + expect(wallet.sends).toBe(1); + }); + + it('passes a stable idempotency key derived from the intent', async () => { + const seen: string[] = []; + const wallet = provider({ + sendTransaction: (input) => { + seen.push(input.idempotencyKey); + return Promise.resolve({ + transactionHash: `0x${'c'.repeat(64)}`, + providerReferenceId: WALLET, + }); + }, + }); + const adapter = new ArcSettlementAdapter(config, wallet); + const ctx = { attemptId: 'a', correlationId: 'c' }; + + await adapter.submit(intent(), ctx); + await adapter.submit(intent(), ctx); + + // Same obligation, same key, so a duplicate collapses provider-side too. + expect(seen[0]).toBe(seen[1]); + expect(seen[0]).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('never attaches native value', async () => { + let observed: bigint | undefined; + const wallet = provider({ + sendTransaction: (input) => { + observed = input.value; + return Promise.resolve({ + transactionHash: `0x${'c'.repeat(64)}`, + providerReferenceId: WALLET, + }); + }, + }); + await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(observed).toBe(0n); + }); + + it('refuses a foreign network without sending', async () => { + const wallet = provider(); + const result = await new ArcSettlementAdapter(config, wallet).submit( + intent({ network: 'eip155:1' }), + { attemptId: 'a', correlationId: 'c' }, + ); + expect(result.kind).toBe('DEFINITELY_NOT_SUBMITTED'); + expect(wallet.sends).toBe(0); + }); + + it('treats a connection refusal as definitely not submitted', async () => { + const wallet = provider({ + sendTransaction: () => + Promise.reject(Object.assign(new Error('refused'), { code: 'ECONNREFUSED' })), + }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('DEFINITELY_NOT_SUBMITTED'); + }); + + it.each(['ECONNRESET', 'ETIMEDOUT'])( + 'treats %s during send as possibly submitted', + async (code) => { + const wallet = provider({ + sendTransaction: () => Promise.reject(Object.assign(new Error(code), { code })), + }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('POSSIBLY_SUBMITTED'); + }, + ); + + it('is possibly submitted when the receipt cannot be read', async () => { + const wallet = provider({ getReceipt: () => Promise.reject(new Error('timeout')) }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('POSSIBLY_SUBMITTED'); + }); + + it('is possibly submitted when no receipt exists yet', async () => { + const wallet = provider({ getReceipt: () => Promise.resolve(null) }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('POSSIBLY_SUBMITTED'); + }); + + it('does not confirm a receipt whose Transfer went elsewhere', async () => { + const wallet = provider({ + getReceipt: () => + Promise.resolve( + receipt({ + logs: [ + { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(OTHER)], + data: `0x${(500_000n).toString(16).padStart(64, '0')}`, + logIndex: 2, + }, + ], + }), + ), + }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('POSSIBLY_SUBMITTED'); + }); + + it('treats an on-chain revert as definitely not submitted', async () => { + // A revert moved no value, so the policy may schedule a fresh attempt. + const wallet = provider({ + getReceipt: () => Promise.resolve(receipt({ status: 0, logs: [] })), + }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent(), { + attemptId: 'a', + correlationId: 'c', + }); + expect(result.kind).toBe('DEFINITELY_NOT_SUBMITTED'); + }); +}); + +describe('provider data is validated at the boundary', () => { + it.each([-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( + 'refuses to confirm when the Transfer log index is %s', + async (logIndex) => { + // The value comes from provider data. parseSettlementResult would reject + // it downstream, but that throws inside the worker; failing closed here + // keeps the intent reconcilable. + const wallet = provider({ + getReceipt: () => + Promise.resolve( + receipt({ + logs: [ + { + address: USDC, + topics: [TRANSFER_EVENT_TOPIC, topic(WALLET), topic(RECIPIENT)], + data: `0x${(500_000n).toString(16).padStart(64, '0')}`, + logIndex, + }, + ], + }), + ), + }); + const result = await new ArcSettlementAdapter(config, wallet).submit(intent()); + expect(result.kind).toBe('POSSIBLY_SUBMITTED'); + }, + ); + + it('still confirms a log index of zero', () => { + // Zero is valid and must not be rejected by a truthiness check. + expect(Number.isSafeInteger(0) && 0 >= 0).toBe(true); + }); +}); diff --git a/packages/privy-adapter/test/boundary.test.ts b/packages/privy-adapter/test/boundary.test.ts index 7f8ccd9..8327cb9 100644 --- a/packages/privy-adapter/test/boundary.test.ts +++ b/packages/privy-adapter/test/boundary.test.ts @@ -24,7 +24,6 @@ describe('lane import boundary', () => { const FORBIDDEN = [ '@oneshot/domain', '@oneshot/storage-postgres', - '@oneshot/contracts', '@oneshot/testkit-domain', '@oneshot/reconciliation', '@oneshot/recovery-agent', @@ -38,9 +37,11 @@ describe('lane import boundary', () => { } }); - it('imports no other lane package at all', () => { + it('imports no lane package beyond the shared contract seam', () => { // Catches a package name added after this test was written. - const allowed = new Set(['@oneshot/arc-adapter']); + // @oneshot/contracts is the sanctioned cross-lane seam per + // milestones/CONTRACTS.md; the others are owned implementations. + const allowed = new Set(['@oneshot/arc-adapter', '@oneshot/contracts']); for (const file of sourceFiles()) { const matches = readFileSync(file, 'utf8').matchAll(/@oneshot\/[a-z-]+/g); for (const [name] of matches) { diff --git a/packages/privy-adapter/tsconfig.json b/packages/privy-adapter/tsconfig.json index 45e645a..5b9ab14 100644 --- a/packages/privy-adapter/tsconfig.json +++ b/packages/privy-adapter/tsconfig.json @@ -9,6 +9,9 @@ "src/**/*.ts" ], "references": [ + { + "path": "../contracts" + }, { "path": "../arc-adapter" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43df853..3cfdd74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: '@oneshot/arc-adapter': specifier: workspace:* version: link:../arc-adapter + '@oneshot/contracts': + specifier: workspace:* + version: link:../contracts viem: specifier: 2.56.3 version: 2.56.3(typescript@6.0.3)