Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions docs/settlement/ADAPTER_CONTRACT_V1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# settlement-adapter-contract-v1

B04 handoff artifact. The production surface Coder A composes against, and the
list of things it deliberately does not do.

Implemented by `@oneshot/privy-adapter` and `@oneshot/arc-adapter`. Both build
and test with no implementation from the A or C lanes present; an automated
test asserts that no import reaches an A- or C-owned package.

## 1. Provided ports

| Port | Results |
| --- | --- |
| `AuthorizationPort.evaluate` | `AUTHORIZED`, `DENIED`, `UNAVAILABLE` |
| `SettlementPort.submit` | `CONFIRMED`, `DEFINITELY_NOT_SUBMITTED`, `POSSIBLY_SUBMITTED` |
| `EvidencePort.lookup` | `FINAL_SUCCESS`, `FINAL_REVERT`, `PENDING`, `NOT_FOUND`, `UNAVAILABLE` |

## 2. Required from the host

The adapter is stateless about settlement rights. A must supply:

- durable Business Intent and Attempt state;
- an atomic submission-ownership grant;
- request identity persisted across restarts.

## 3. Not provided

- Reconciliation decisions. The adapter reports; it does not decide to retry.
- External-index authority. Hashless discovery is Coder C's Subgraph MCP path,
deliberately absent here so no second, weaker way to decide a payment
happened can develop.
- Automatic transaction replacement.
- User interface.

## 4. Error taxonomy

The taxonomy turns on one question: **did the request reach the network?**

### Proven pre-broadcast, retry permitted

| Signal | Why it proves nothing was sent |
| --- | --- |
| `ENOTFOUND`, `EAI_AGAIN` | DNS never resolved; no connection opened |
| `ECONNREFUSED` | Peer refused the TCP handshake |
| TLS handshake failures | Failed before any application data was transmitted |
| HTTP 4xx except 429 | Provider rejected on its own terms without acting |

### Possibly submitted, retry never permitted

`ECONNRESET`, `ETIMEDOUT`, `EPIPE`, premature stream close, HTTP 429, HTTP 5xx,
truncated or malformed responses, lost success responses, process termination,
**and every unrecognized error**.

Two boundaries worth stating explicitly:

- **429 is ambiguous**, not a rejection. A rate limiter may reject before or
after queuing the work.
- **A failed TLS handshake is pre-broadcast; an interrupted TLS connection is
not.** The first cannot have delivered a request; the second may have.

An error this build has never seen is classified post-send. An unknown failure
cannot be proof that nothing happened, and treating it as proof is the mistake
that pays twice.

## 5. `NOT_FOUND` is not permission

Absent evidence can mean the transaction was never broadcast, or sits in a
mempool this node cannot see, or that the node is behind, or that it was
replaced. `permitsResubmission` returns `false` for every observation, and
there is no code path that turns an absent result into a settlement right.

Only bound, final evidence terminates an intent. A receipt that exists for our
hash but does not prove our settlement is contradictory and stays unbound
rather than being resolved by guess.

## 6. Drift hardening

The settlement boundary depends on values living outside this repository: a
Privy policy, a wallet, a chain, a token, a cap. Any can change with no commit
and no review.

`detectDrift` compares observed identity against a reviewed baseline across
`policyDigest`, `policyId`, `walletId`, `walletAddress`, `chainId`,
`tokenContract`, and `settlementCapAtomic`. Any difference fails closed, and
`assertNoDrift` throws rather than returning a value a caller could ignore.

A **lowered** cap is reported as drift too. Judging whether a change is benign
is not this module's job; detecting that the deployment no longer matches what
was reviewed is.

## 7. Webhooks

Disabled. A webhook is an unauthenticated inbound claim about a payment.
Signature verification against Privy's scheme is unproven here, and polling
through `EvidencePort` is complete on its own, so enabling one would add attack
surface without adding capability.

## 8. Redaction report

- Provider error text never crosses the package boundary. Callers receive an
`AdapterError` code and a sanitized message.
- Evidence detail strings are truncated to 200 characters, because provider
errors can embed whole response bodies.
- Redaction is deny-by-default on key name and on value shape, with named
hash-bearing fields exempt from the 32-byte-hex rule so transaction hashes,
block hashes, topics, and fingerprints survive as evidence.
- No module in either package reads `ONESHOT_PRIVY_APP_SECRET`.

## 9. Live gaps for Gate P4

Listed so fixtures cannot pass as live evidence:

- No Privy tenant has executed a policy denial or an allowed settlement.
- Arc receipt and Transfer log shapes are modelled from documentation, never
observed.
- Privy wallet and policy identifier formats are shape-guessed; the documented
format should replace `IDENTIFIER_SHAPE`.

Per `.agents/skills/sponsor-qualification/SKILL.md`, the Privy and Arc claims
stay `NOT VERIFIED` until `docs/settlement/LIVE_EVIDENCE.md` records a sanitized
live transaction.

## 10. P4 replacement instructions

When credentials exist:

1. Run `docs/settlement/PROVIDER_SETUP.md`.
2. Verify with `cd packages/arc-adapter && npm run probe`.
3. Record the observed policy digest and wallet identity as the baseline.
4. Execute the negative suite against the live tenant and confirm zero
transfers, then one allowed settlement.
5. Capture responses through `captureReceiptFixture` and replace the modelled
fixtures with sanitized real ones.
6. Update `LIVE_EVIDENCE.md` from `LIVE_NOT_RUN` to `LIVE_RUN` with the
sanitized hash, block, and explorer URL.

Replacing fixtures must not change any classifier or verifier result. If it
does, the model was wrong and the difference is the finding.
6 changes: 6 additions & 0 deletions packages/arc-adapter/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export default tseslint.config(
'error',
{ allowNumber: true },
],
// A leading underscore marks a parameter kept for signature shape but
// deliberately unused, such as a port method that always refuses.
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
);
193 changes: 193 additions & 0 deletions packages/arc-adapter/src/evidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* EvidencePort lookup (B04.2, B04.3).
*
* Answers "what happened to this specific request?" from persisted identity,
* without ever answering "so you may pay again".
*
* `NOT_FOUND` is the dangerous result and gets the strictest treatment. An
* absent transaction can mean it was never broadcast, or that it is in a
* mempool this node cannot see, or that the node is behind, or that it was
* replaced. `.agent/SECURITY_INVARIANTS.md` is explicit: never infer
* non-payment from an empty or delayed external result. So `NOT_FOUND` is an
* observation, never a permission, and this module has no code path that turns
* one into the other.
*/

import { verifyReceipt, type ExpectedSettlement, type TransactionReceipt } from './receipt.js';

export type EvidenceResult =
/** Final receipt and exactly the expected Transfer. */
| 'FINAL_SUCCESS'
/** Final receipt proving the transaction reverted. */
| 'FINAL_REVERT'
/** Known to exist, not yet final. */
| 'PENDING'
/** No record found. NOT proof that nothing happened. */
| 'NOT_FOUND'
/** The question could not be asked. */
| 'UNAVAILABLE';

export interface EvidenceObservation {
readonly result: EvidenceResult;
/** Sanitized detail suitable for an operator timeline. */
readonly detail: string;
/**
* Whether this observation binds to the exact expected request.
*
* Unbound evidence is real but describes something else, and must never
* resolve the intent it was fetched for.
*/
readonly boundToRequest: boolean;
}

/** What the caller persisted before submitting, used to look evidence up. */
export interface PersistedIdentity {
readonly transactionHash?: string | undefined;
readonly walletAddress: string;
readonly chainId: number;
readonly tokenContract: string;
readonly recipient: string;
readonly amountAtomic: bigint;
}

// No `nonce` field: binding is done on the transaction hash, and a declared
// nonce nobody reads would imply a "wrong nonce" defence that does not exist.

/** A receipt lookup that may fail or find nothing. */
export interface ReceiptSource {
getReceipt(transactionHash: string): Promise<TransactionReceipt | null>;
}

function expectedFrom(identity: PersistedIdentity): ExpectedSettlement {
return {
chainId: identity.chainId,
walletAddress: identity.walletAddress,
tokenContract: identity.tokenContract,
recipient: identity.recipient,
amountAtomic: identity.amountAtomic,
};
}

/**
* Look up evidence for a persisted identity.
*
* Without a transaction hash there is nothing to bind to, so the answer is
* `NOT_FOUND` and unbound. Hashless discovery is Coder C's Subgraph MCP path
* and is deliberately absent here: this adapter must not develop a second,
* weaker way to decide a payment happened.
*/
export async function lookupEvidence(
identity: PersistedIdentity,
source: ReceiptSource,
): Promise<EvidenceObservation> {
if (identity.transactionHash === undefined || identity.transactionHash === '') {
return {
result: 'NOT_FOUND',
detail:
'No transaction hash was persisted for this attempt. Absence of a hash ' +
'is not evidence that no transaction exists.',
boundToRequest: false,
};
}

let receipt: TransactionReceipt | null;
try {
receipt = await source.getReceipt(identity.transactionHash);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
result: 'UNAVAILABLE',
detail: `Evidence lookup failed: ${message.slice(0, 200)}`,
boundToRequest: false,
};
}

if (receipt === null) {
// The single most misread result in the system.
return {
result: 'NOT_FOUND',
detail:
'No receipt found for the persisted hash. The transaction may be ' +
'pending, replaced, or invisible to this node. This is not proof that ' +
'no payment occurred and never permits resubmission.',
boundToRequest: false,
};
}

// Bind before interpreting. A receipt for a different chain or wallet is
// real evidence about something that is not this request.
if (receipt.chainId !== identity.chainId) {
return {
result: 'NOT_FOUND',
detail: `Receipt is from chain ${receipt.chainId}, not ${identity.chainId}; it does not describe this request.`,
boundToRequest: false,
};
}

if (receipt.transactionHash.toLowerCase() !== identity.transactionHash.toLowerCase()) {
return {
result: 'NOT_FOUND',
detail: 'The returned receipt is for a different transaction hash.',
boundToRequest: false,
};
}

const verdict = verifyReceipt(receipt, expectedFrom(identity));

switch (verdict.result) {
case 'CONFIRMED':
return {
result: 'FINAL_SUCCESS',
detail: `Verified settlement in block ${receipt.blockNumber.toString()}.`,
boundToRequest: true,
};

case 'FINAL_REVERT':
return {
result: 'FINAL_REVERT',
detail: 'Transaction reverted on chain; no value moved.',
boundToRequest: true,
};

case 'NOT_CONFIRMED':
// A receipt exists for our hash but does not prove our settlement.
// Contradictory, and ambiguity must be preserved rather than resolved.
return {
result: 'PENDING',
detail: `Receipt found but it does not prove the expected settlement: ${verdict.detail}`,
boundToRequest: false,
};

default: {
const unreachable: never = verdict;
return {
result: 'UNAVAILABLE',
detail: `Unhandled receipt verdict: ${String(unreachable)}`,
boundToRequest: false,
};
}
}
}

/**
* Whether an observation may terminate an intent.
*
* Only bound, final evidence qualifies. Everything else leaves the intent
* where it was, which for an ambiguous attempt means `UNKNOWN`.
*/
export function isTerminalEvidence(observation: EvidenceObservation): boolean {
if (!observation.boundToRequest) return false;
return observation.result === 'FINAL_SUCCESS' || observation.result === 'FINAL_REVERT';
}

/**
* Whether an observation permits creating another settlement.
*
* Always false. The function exists so the answer is written down once, in a
* place a future caller will find, rather than re-derived per call site.
* Only a `FINAL_REVERT` may lead to a new attempt, and that decision belongs
* to the reconciliation policy Coder C owns, not to this adapter.
*/
export function permitsResubmission(_observation: EvidenceObservation): false {
return false;
}
Loading
Loading