From 72f643d06723ec712a1e78b9d91ee13e4096eac4 Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 23:13:23 +0200 Subject: [PATCH 1/3] feat(adapter): add P4 composition adapters and compile-time port conformance Gate P4 replaces the simulator ports in apps/worker/src/composition.ts with lane-B implementations. Until now lane B exported port interfaces and pure classification logic but no concrete classes to inject, so the checklist's replacement step could not be executed against what was built. Adds ArcSettlementAdapter and PrivyAuthorizationAdapter conforming to the canonical result shapes in @oneshot/contracts, both declaring the 1.0.0 contract version the worker verifies, and the settlement adapter declaring the enabled Arc network. They satisfy the worker's interfaces structurally rather than by import. apps/worker is Coder A's package and the lane rule forbids importing another owner's implementation, so p4-conformance.ts mirrors those interfaces and statically asserts assignability, including the contractVersion and network fields composeWorker reads. The guard was verified to bite: renaming submit makes tsc report that ArcSettlementAdapter no longer satisfies the port. A change to A's interfaces now fails the build here instead of surfacing during composition. Corrects the lane boundary test, which wrongly forbade @oneshot/contracts. That package is the sanctioned cross-lane seam named by milestones/CONTRACTS.md, not an owned implementation. Implementation packages remain forbidden. Both adapters take their provider as an injected interface, so nothing opens a socket or reads a credential and the whole settlement path stays exercisable offline. Behaviour preserves the lane invariants: drift reports UNAVAILABLE rather than DENIED because a drifted policy makes every answer untrustworthy rather than making one intent unauthorized; doubt defaults to POSSIBLY_SUBMITTED, with DEFINITELY_NOT_SUBMITTED reserved for a proven pre-broadcast failure or an on-chain revert; a successful receipt without exactly one matching Transfer is not confirmation; and native value is always zero. submit takes no SettlementContext. Fewer parameters still satisfy the port, and submission identity derives from the Business Intent so a retry under a new attempt id still produces the same idempotency key. Documents the injection recipe, the required WalletProvider surface, and the package naming disagreement: the checklist reserves @oneshot/adapter-arc and @oneshot/adapter-privy, while the merged packages are @oneshot/arc-adapter and @oneshot/privy-adapter. That needs an explicit decision rather than being discovered during composition. --- docs/settlement/GATE_P4_LANE_B_READINESS.md | 105 +++++++ packages/privy-adapter/package.json | 3 +- packages/privy-adapter/src/adapters.ts | 291 +++++++++++++++++++ packages/privy-adapter/src/index.ts | 2 + packages/privy-adapter/src/p4-conformance.ts | 68 +++++ packages/privy-adapter/test/adapters.test.ts | 284 ++++++++++++++++++ packages/privy-adapter/test/boundary.test.ts | 7 +- packages/privy-adapter/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 9 files changed, 762 insertions(+), 4 deletions(-) create mode 100644 docs/settlement/GATE_P4_LANE_B_READINESS.md create mode 100644 packages/privy-adapter/src/adapters.ts create mode 100644 packages/privy-adapter/src/p4-conformance.ts create mode 100644 packages/privy-adapter/test/adapters.test.ts 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..4c5091d --- /dev/null +++ b/packages/privy-adapter/src/adapters.ts @@ -0,0 +1,291 @@ +/** + * 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': + 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..7d4ecd2 --- /dev/null +++ b/packages/privy-adapter/test/adapters.test.ts @@ -0,0 +1,284 @@ +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'); + }); +}); 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 176b3fd..529064c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,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) From 93fe9f9ded18ad8a692aec06fc2e40d348a1099f Mon Sep 17 00:00:00 2001 From: selezenart Date: Mon, 7 Sep 2026 23:30:06 +0200 Subject: [PATCH 2/3] fix(adapter): validate the Transfer log index before confirming The review noted that transferLogIndex reaches a CONFIRMED result straight from provider data with no bounds check. parseSettlementResult would reject a bad value downstream, but that surfaces as a thrown contract error inside the worker rather than a result the intent can be reconciled from. Validated at the boundary instead, per the invariant that untrusted external data is checked where it enters. An invalid index fails closed to POSSIBLY_SUBMITTED: the settlement may well have happened, so claiming it did not would be wrong, and claiming a malformed CONFIRMED would break the caller. --- packages/privy-adapter/src/adapters.ts | 17 +++++++++- packages/privy-adapter/test/adapters.test.ts | 33 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/privy-adapter/src/adapters.ts b/packages/privy-adapter/src/adapters.ts index 4c5091d..2474755 100644 --- a/packages/privy-adapter/src/adapters.ts +++ b/packages/privy-adapter/src/adapters.ts @@ -266,7 +266,21 @@ export class ArcSettlementAdapter { }); switch (verdict.result) { - case 'CONFIRMED': + 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), @@ -274,6 +288,7 @@ export class ArcSettlementAdapter { 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. diff --git a/packages/privy-adapter/test/adapters.test.ts b/packages/privy-adapter/test/adapters.test.ts index 7d4ecd2..8c35c54 100644 --- a/packages/privy-adapter/test/adapters.test.ts +++ b/packages/privy-adapter/test/adapters.test.ts @@ -282,3 +282,36 @@ describe('ArcSettlementAdapter', () => { 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); + }); +}); From 62d7ea7d268b178ca1e8ade0bc6f11529bc86297 Mon Sep 17 00:00:00 2001 From: selezenart Date: Tue, 8 Sep 2026 00:03:34 +0200 Subject: [PATCH 3/3] docs: correct the lane B package names in the P4 composition docs The Gate P4 checklist and composition manifest name @oneshot/adapter-arc and @oneshot/adapter-privy. Those packages do not exist. The merged packages are @oneshot/arc-adapter and @oneshot/privy-adapter, already referenced by pnpm-workspace.yaml, the root tsconfig, the fixtures, and the lane docs. Nothing imports the reserved names, so this was documentation drift rather than a build failure waiting to happen. Corrected the docs rather than renaming the packages: the rename would touch every consumer and every import for a cosmetic gain, right as composition begins. The class names in the replacement instructions were already correct. Adds a note that both adapters ship from one package, so that an Arc settlement adapter living in privy-adapter does not read as a mistake: settlement is a Privy wallet action carrying an Arc transfer, and splitting it would put half of one call path in each package. Also refreshes the verification-commands paragraph, which said the lane B packages keep their own npm toolchains and are checked by a dedicated settlement-packages CI job, and that consolidation may happen at P4. That consolidation already happened ahead of P4, because those npm lockfiles broke pnpm install --frozen-lockfile on develop. The job is gone and the packages are covered by the root runs. Edited with the repository owner's explicit permission, since these files sit in Coder A's lane. --- docs/COMPOSITION_MANIFEST.md | 4 ++-- docs/GATE_P4_CHECKLIST.md | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) 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: