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
27 changes: 0 additions & 27 deletions .github/workflows/stack-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,30 +112,3 @@ jobs:
env:
TEST_POSTGRES: '1'
run: pnpm test:integration

settlement-packages:
name: Arc and Privy packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
cache-dependency-path: |
packages/arc-adapter/package-lock.json
packages/privy-adapter/package-lock.json
packages/testkit-settlement/package-lock.json

- name: Check Arc adapter
working-directory: packages/arc-adapter
run: npm ci && npm run check

- name: Check Privy adapter
working-directory: packages/privy-adapter
run: npm ci && npm run check

- name: Check settlement testkit
working-directory: packages/testkit-settlement
run: npm ci && npm run check
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.
75 changes: 75 additions & 0 deletions docs/settlement/LIVE_EVIDENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Live settlement evidence

B03 handoff artifact.

## Status

`LIVE_NOT_RUN`

No real Arc Testnet settlement has been executed. No Privy application,
execution wallet, policy, or funded testnet account has been provisioned for
this build.

This is the expected state. B03 closes on its offline criteria, and
`milestones/coder-b/B03-live-settlement-harness.md` states that live
availability does not block packet closure. Live execution is tracked as
project Gate P4 evidence.

## Why it has not run

Provisioning requires a human: creating a Privy application, holding an app
secret, attaching a wallet policy, and funding a testnet account are all
actions an agent must not perform. `docs/settlement/PROVIDER_SETUP.md` is the
procedure; nobody has run it yet.

## What is proven without it

The offline harness exercises the complete adapter workflow against simulated
providers with a deterministic broadcast counter:

- Every policy denial family produces **zero** external broadcasts.
- Duplicate delivery, ten sequential retries, and ten parallel workers sharing
durable state each produce **exactly one** broadcast.
- Process restart is exercised against file-backed durable state: an attempt is
written to disk before the provider is called, and a restarted worker reading
that file is refused a second submission right, including after an outcome
that was never learned.
- An ambiguous outcome does not grant a fresh submission right, so the
dangerous retry after a possible payment cannot happen.
- Every ambiguous or unrecognized provider response classifies as
`POSSIBLY_SUBMITTED`.
- Sanitized fixtures reproduce the same verifier and classifier results as the
raw responses they were captured from.

Command:

```bash
cd packages/testkit-settlement && npm run check
```

## What is not proven

Simulators prove the adapter's logic, not the provider's behaviour. Still
unverified against reality:

- That a Privy policy configured as `buildExpectedPolicy` describes actually
denies each wrong dimension. The policy shape is modelled from Privy's
documentation, not observed.
- That Arc Testnet receipts and Transfer logs have the exact shape the verifier
expects.
- That the documented Privy wallet and policy identifier formats match the
conservative shape check in the readiness probe.
- Real latency, rate limits, and error bodies.

Per `.agents/skills/sponsor-qualification/SKILL.md`, no sponsor or
qualification claim may be made from fixtures alone. Until this file records a
sanitized live transaction, the Privy and Arc integration claims are
`NOT VERIFIED`.

## To update this file

Run `docs/settlement/PROVIDER_SETUP.md`, execute one allowed settlement, then
replace the status above with `LIVE_RUN` plus the sanitized transaction hash,
block number, explorer URL, and the observed denial counts. Capture the
responses through `captureReceiptFixture` so no credential reaches the
repository.
133 changes: 133 additions & 0 deletions docs/settlement/PROVIDER_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Provider setup (human-run)

B03.1. Prepares a Privy application, execution wallet, policy, and Arc Testnet
funding so a real settlement can be captured as Gate P4 evidence.

**A human runs every step here.** Agents must not create accounts, enter
credentials, mutate policy, or move funds. An agent may run the read-only
verification in section 7 and report results.

Testnet only. Nothing in this guide applies to mainnet, and Arc mainnet
parameters are unpublished. See `docs/settlement/SETTLEMENT_CONFIG_V1.md`.

## 1. Before you start

You need a Privy account, a terminal, and somewhere to store secrets that is
not this repository. Budget about 30 minutes.

At no point paste a secret into a chat, an issue, a PR, a fixture, a log, or a
review prompt. `.agent/SECURITY_INVARIANTS.md` treats that as a breach, and a
committed secret must be rotated, not deleted.

## 2. Privy application

1. Create a Privy application for OneShot.
2. Record the **app ID**. It is a public identifier, safe to log.
3. Create an **app secret**. This is the one true credential in the system.
Put it straight into your secret store; do not write it to a file first.

## 3. Execution wallet

1. Create a server wallet. This is the only wallet that will sign settlement.
2. Record the **wallet ID** and the **wallet address**.
3. Configure the owner or key quorum according to your organisation's rules. A
single-owner wallet is acceptable for a testnet demo and is not acceptable
for anything holding real value.

## 4. Recipient and cap

Two human decisions, both deliberate:

- **Recipient allowlist.** The addresses settlement may pay. Keep it as short
as the demo allows. An empty list settles nothing, which is the safe default.
- **Per-settlement cap.** The maximum atomic units for one settlement, in
six-decimal USDC atomic units. `1000000` is one USDC.

These are the two values that bound the blast radius if everything else fails.

## 5. Policy

Attach a policy to the execution wallet constraining all six dimensions:

| Dimension | Constraint |
| --- | --- |
| Chain | equals `5042002` |
| Destination contract | equals the USDC interface `0x3600000000000000000000000000000000000000` |
| Native value | equals `0` |
| Method | `transfer` |
| Recipient | in your allowlist |
| Amount | at or below your cap |

The policy must end with a **default deny**. Without it, anything the rules do
not mention is permitted.

`buildExpectedPolicy` in `@oneshot/privy-adapter` produces this shape, and
`policyDigest` produces a fingerprint you can compare against later to detect
drift. Record the **policy ID** and the digest.

## 6. Funding

1. Fund the execution wallet from the Circle faucet at
<https://faucet.circle.com> for Arc Testnet.
2. Fund only what the demo needs.
3. Confirm the balance on the explorer at <https://testnet.arcscan.app>.

Arc's native gas asset uses 18 decimals while the USDC ERC-20 interface uses 6.
They are both called USDC. Read balances carefully.

## 7. Verification (read-only, safe to automate)

Set the variables from `packages/arc-adapter/.env.example` in your shell or
secret store, then run the readiness probe:

```bash
cd packages/arc-adapter && npm run probe
```

This contacts the endpoint you configured, asks it which chain it is actually
on, and checks that the configured USDC address holds contract bytecode. It is
read-only: it cannot sign, send, or mutate anything, and it prints no
credential. Exit code 0 means ready, 1 means not ready.

Read the result carefully, because the two failure modes need opposite
responses:

- **`MISMATCH`** — the endpoint answered and the answer was wrong. Your
configuration points somewhere it should not. A human must fix it. Do not
retry; it will not resolve on its own.
- **`UNAVAILABLE`** — the endpoint could not be reached. Your configuration may
be perfectly correct. Retrying later is reasonable.

Note that `npm run check` is a different thing: it runs lint, typecheck, and
the unit tests against stubbed endpoints. It proves the probe's logic is
correct and tells you nothing about whether *your* setup is correct. Only
`npm run probe` does that.

## 8. Storing the values

| Value | Classification | Where it goes |
| --- | --- | --- |
| App ID | public | configuration |
| App secret | **secret** | secret store only |
| Wallet ID | public | configuration |
| Wallet address | public | configuration and evidence |
| Policy ID | public | configuration |
| Recipient allowlist | human-only | configuration |
| Cap | human-only | configuration |

Never commit a real value. `packages/arc-adapter/.env.example` holds
placeholders only and is generated from the config schema.

## 9. Cleanup and rotation

When the demo is finished:

1. Return or drain remaining testnet funds.
2. Rotate the app secret, and rotate immediately if it was ever pasted anywhere
outside the secret store.
3. Narrow or empty the recipient allowlist.
4. Keep the wallet and policy if you need the evidence trail; disable the
policy's allow rule to make the wallet inert.

Rotation is the response to a suspected leak. Deleting the message that
contained it is not.
9 changes: 1 addition & 8 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: [
'**/coverage/**',
'**/dist/**',
'**/generated/**',
'packages/arc-adapter/**',
'packages/privy-adapter/**',
'packages/testkit-settlement/**',
],
ignores: ['**/coverage/**', '**/dist/**', '**/generated/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
Expand Down
Loading
Loading