Skip to content

Sync current main into remediation branch - #25

Draft
coinsecuritiescompany wants to merge 69 commits into
rebase/remediation-onto-mainfrom
main
Draft

Sync current main into remediation branch#25
coinsecuritiescompany wants to merge 69 commits into
rebase/remediation-onto-mainfrom
main

Conversation

@coinsecuritiescompany

Copy link
Copy Markdown
Contributor

Internal branch-sync PR only. Merges the 11 commits currently on main into rebase/remediation-onto-main so PR #16 can be reviewed against the actual current base. No package publication or deployment. If GitHub reports conflicts, do not force merge; resolve explicitly.

coinsecuritiescompany and others added 11 commits August 12, 2026 15:38
Dependabot has 26 open alerts on this repo. Sorted by who they actually reach:

  6   vite + postcss, via vitest — a devDependency. `npm pack --dry-run` on
      node/ ships 62 files, `dist/ README.md package.json`, no node_modules.
      Nobody installing @aifinpay/agent gets vitest. ZERO consumer exposure.
  4   uuid, via @solana/web3.js -> jayson. Real, but see below.
  16  ws / qs / body-parser in examples/*, which are `private: true` and never
      published — but the README calls them "Production bridges in front of
      io.net / Exa / Venice". These run on our own box. THIS is the exposure,
      and it is to us, not to SDK consumers.

So the four `ws` high-severity memory-exhaustion DoS alerts are the ones that
matter, and they matter because our own bridges serve traffic with them.

`npm audit fix` cleared them and also dragged viem from 2.48.11 to 2.55.13 in
three production bridges — seven minors on the library that builds payment
calldata, as a side effect of a security patch. Reverted. Replaced with an
`overrides` block naming only the three vulnerable packages, which brings the
change from ~84 package moves down to 7 per bridge and leaves viem alone.

uuid is deliberately NOT overridden. The advisory is "missing buffer bounds
check in v3/v5/v6 WHEN buf is provided"; jayson has exactly three uuid call
sites (generateRequest.js:49, utils.js:52, client/browser/index.js:30) and all
three are `uuid.v4()` with no arguments. Not reachable. npm's proposed fix is to
downgrade @solana/web3.js to 0.0.3, which is worse than the risk by any measure.

In _generic-x402-bridge ws moves 7.5.11 -> 8.21.3, which looks like a major but
is a de-dupe: jayson, isomorphic-ws, rpc-websockets and viem/isows in that tree
all already require ws@8. The 7.5.11 entry was a stale lockfile tail.

Verified by running it, not by reading the diff: the generic bridge starts and
answers `POST /chat/completions` with a well-formed 402. (It binds IPv6 `*`, so
probe it on localhost — 127.0.0.1 times out. Same trap as before.)

Two things the smoke test surfaced that are NOT fixed here:
  - the 402 says `x402Version: 1` with `maxAmountRequired`; the live standard is
    v2 and calls that field `amount`. Our own bridge is on the wrong side of the
    same gap the client SDK is.
  - the default splitter in the banner is 0xE34Fc0E6…8440, superseded on
    2026-07-31 by v1.2 0xbD1fa545…4DDe.

node/ and mcp/ lockfiles are untouched on purpose: PR #16 already edits both and
is under review. Bumping them here would hand that PR a conflict for six alerts
that reach nobody.

Production still runs deploy/bridges-v12; this fix does not reach the box until
someone deploys from a ref that contains it. See AIFINP-92.

Refs AIFINP-107.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An autonomous agent asked to "give me a wallet" imports AiFinPayAgent, and that
pulls the whole transaction stack: viem + @solana/web3.js, 142 packages, ~18,500
files, ~157 MB. In a constrained sandbox that does not merely bloat — it fails.
A real Grok run spent 14 minutes and died on `TAR_ENTRY_ERROR EIO` unpacking
viem, and never got a wallet. AIFINP-117.

Deriving the three addresses needs four small crypto primitives and no chain
client. `src/wallet.ts` uses exactly those — tweetnacl, bs58, @noble/hashes,
@noble/curves — and nothing else. Exposed two ways:

  import { deriveWallet, newWallet } from "@aifinpay/agent";         // convenience
  import { deriveWallet } from "@aifinpay/agent/wallet";             // light graph

The subpath's module graph is free of viem and @solana/web3.js, so a bundler
that imports only it tree-shakes the transaction stack out entirely. Proven, not
asserted: a test walks the built dist/wallet.js import graph and fails if either
heavy package appears in it — and mutation-checked (adding a viem import to the
module reddens exactly that test).

Byte-for-byte identical to the full agent: same seed → same Solana, EVM and
Casper addresses, checked against AiFinPayAgent across four seeds. The EVM path
reproduces viem's EIP-55 address from @noble alone — verified equal to
`privateKeyToAccount(...).address` before this was written.

Two footguns the reported case hit are closed here:
  - newWallet() RETURNS its seed (keys.seedHex). AiFinPayAgent.new() generates a
    seed it never exposes, leaving the wallet unrecoverable.
  - a malformed seed throws instead of silently deriving a different wallet.

Standalone reproduction with only the four light deps at the versions
@aifinpay/agent pins: 4 packages, 510 files, 4.5 MB — against 157 MB — and it
derives the same 0x467aeE37… for seed 11×32.

What this does NOT do: `npm install @aifinpay/agent` still pulls viem+@Solana on
disk, because they remain runtime dependencies of the main entry. Removing them
for node installs is the breaking dep-flip (optional peerDependencies + lazy
loading) that belongs in 2.0 — this file is the derivation half of it, landed
non-breaking so the 2.0 change is a dependency move rather than a rewrite. The
immediate win is real for bundled/edge agents, where tree-shaking applies today.

@noble/curves is now an explicit dependency; it was only transitive via
viem/@Solana before, and the light path must not rely on those being installed.

node suite 130 tests pass (wallet.test.ts: 9). Tarball ships dist/wallet.js.
Version 1.8.3.

Refs AIFINP-117.


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…c (AIFINP-118) (#17)

* sdk: accept pay_native — agent.call() failed against every live bridge

The production bridges renamed their 402 payment block pay_matic → pay_native
on 2026-08-04, when the on-chain entrypoint became payNative. No SDK branch
followed — verified: `git grep pay_native` over every remote branch of this
repository returns zero hits in node/src and python/aifinpay. So
agent.call({provider}) — the headline call — failed against io-net, venice, exa
and the generic bridge alike, with an error blaming facilitator wiring:

    bridge io-net returned 402 but no pay_matic block — only legacy
    AiFinPay/Coinbase facilitators not yet wired into AiFinPayAgent.call()

The facilitators were fine. The field was renamed. AIFINP-118.

Not a contract problem either, which was the first guess in both directions:
the live 402 names the v1.2 splitter (0xbD1fa545…4DDe), quotes v1.2's
four-argument payNative signature, and its numbers are exactly what the
fee-inclusive contract computes — treasury = total×100/10000, merchant = the
remainder. Both ends were internally correct; only the key differed.

The fix accepts both names, newest first, in one place per language:

    nativePayBlock(challenge)   node/src/unifiedAgent.ts  (exported)
    native_pay_block(challenge) python/aifinpay/unified_agent.py

pay_matic stays accepted — old bridges exist until every deployment is
redeployed, and dropping the old name would recreate this incident in the
other direction. The no-block error now lists the fields it DID receive
instead of blaming facilitators.

## Why it shipped broken, and what prevents the repeat

The bridge tests built their 402 fixtures in the SDK's own vocabulary, so both
sides of every assertion came from this repository — renaming the field on the
server broke nothing here. A test that only ever meets fixtures it wrote
itself is a mirror, not a test.

The new fixture is the verbatim body of a production 402, captured with curl
on 2026-08-13 and committed unedited (values are dynamic — live POL pricing,
order ids — so the assertions pin shape, not numbers). The selection logic is
extracted and exported precisely so this fixture drives the exact function
call() uses, in both languages. If the bridges rename the field again, the
fixture refresh goes red the same day.

Mutation-tested in both languages: reverting to pay_matic-only fails 2 of 5
tests in each. node suite 126 tests pass; python 55.

Refs AIFINP-118.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

* release: 1.8.3 / 1.4.1 for the pay_native fix

The version gate is right: node/src and python/aifinpay changed, so the
packages must move — otherwise the registry keeps serving the broken call()
under a number people already have, which is the exact failure mode the gate's
own header documents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

* chore: drop stray files a bulk git-add swept into this branch

widget/, pubkey.bin, the pnpm lockfiles (this SDK uses npm), AGENTS.md and
.gaps-baseline.json are not part of the pay_native fix — they were untracked
working-tree leftovers picked up by a git add -A. main never had them.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* python: the default registry URL was the one combination that 404s

unified_agent.py carries a table of which host serves the registry on which
path, because the two disagree:

    aifinpay.io/api/providers      -> JSON
    aifinpay.io/providers          -> 200 HTML (the SPA catch-all)
    api.aifinpay.io/providers      -> JSON
    api.aifinpay.io/api/providers  -> 404

Three lines below that table, the default was built as
`"https://api.aifinpay.io" + DEFAULT_REGISTRY_PATHS[0]` — the fourth row. Every
Python agent spent its first discovery request on a guaranteed miss and was
rescued by the fallback, so it never looked broken: the cost was a wasted
round-trip and a 404 in every user's debug log, forever.

The path ordering is right and stays: /api/providers goes first because guessing
wrong with it gives a clean 404, while guessing wrong with /providers gives
200-with-HTML that looks like success. What was wrong is that the host and the
path are not independent, and the code paired them as if they were.

Now pinned to aifinpay.io, which is also what the Node SDK defaults to
(node/src/agent.ts DEFAULT_BASE_URL). The two SDKs disagreeing on the default
host is how this survived — Node hit 200 first try, so nobody debugging Python's
extra 404 had a reference to compare against. A test now asserts they match.

Verified live 2026-08-12: aifinpay.io/api/providers 200,
api.aifinpay.io/api/providers 404.

The test is offline and structural on purpose — it has to fail in a bare
checkout with no network, because a network test would be skipped in exactly the
environments where this breaks. Mutation-tested: 3 of its 4 assertions go red
when the old host is restored. Python suite 54 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* python: bump to 1.4.2 for the registry-default fix

Changes published python (the default registry URL), so the version gate
requires a bump. 1.4.2 rather than 1.4.1 because PR #17 (pay_native) already
claims 1.4.1 — whichever merges second reconciles. Both are additive, no
conflict in the code itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two protocols answer a 402 at this tool. payable_fetch spoke one of them.

  AIFP-1 gateway (gateway.aifinpay.io/{slug}/…): quote -> settle on-chain ->
    receipt -> retry. Lives on the unified agent as fetchPaid().
  x402 facilitators (AiFinPay / Coinbase): the X-PAYMENT header flow, on the
    wrapped Solana-side agent as inner.pay().

The handler called inner.pay() only. So an MCP agent pointed at a gateway URL —
the most common paid surface we actually run — got stuck on a 402 it could not
read, while the tool's own description claimed it "automatically detects the
facilitator". It detected one of two.

Routing, and why it is safe by construction: fetchPaid() is documented to return
a non-AIFP-1 402 UNTOUCHED at no cost, so trying it first can pay an AIFP-1 URL
and can never mis-pay an x402 one. Anything it hands back still 402 falls through
to inner.pay(). A caller who forces a facilitator has stated x402 intent, so
AIFP-1 is skipped for them entirely. A budget skip (fetchPaid returns null) is
reported, NOT retried through x402 — retrying would pay around the cap the
caller set.

The happy path settles real money on-chain and cannot run here (no spend), so
the test asserts the routing with both payment methods stubbed: which is tried,
in what order, forced-facilitator bypass, and the budget-skip-is-not-laundered
case. Mutation-checked — disabling the AIFP-1-first branch fails 4 of the 5.

Also declares bs58 and tweetnacl, which mcp imports at runtime in
agent-claim-self.ts and never declared: building mcp against the agent from
source (CI's own job) fails with TS2307 without them, because the tarball
install does not hoist them the way the published package does. Same latent
issue noted in AIFINP-118; correct to fix here regardless.

mcp 1.5.1 -> 1.5.2. Suite 48 tests pass (routing: 5). tsc clean against the
source-built agent.

Refs AIFINP-107.


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* mcp: `npx @aifinpay/mcp init` — one command to a usable agent wallet

Asked how to give a terminal agent a wallet, the honest answer today is a
Node script. That is the wrong answer to that question.

What happened before: `npx @aifinpay/mcp` starts and warns

    no AIFINPAY_AGENT_SECRET set — generated an EPHEMERAL, NON-RECOVERABLE agent
    >> DO NOT FUND these addresses
    >> For a persistent wallet … (AiFinPayAgent.fromSeed / `aifinpay init`)

`aifinpay init` is not published. `@aifinpay/cli` and `aifinpay` both 404 on
npm — checked. So the documented exit from the dead end did not exist, and the
real path was: install the SDK, learn that `AiFinPayAgent.new()` generates a
seed it never exposes (no `seed`, `secret`, `key` or `export` field anywhere on
the object — the README's `agent.secretB58` belongs to the OTHER class, the
Solana-only `Agent`), generate your own 32 bytes, and wire it up.

Now:

    npx @aifinpay/mcp init      once
    npx @aifinpay/mcp           thereafter

`init` writes ~/.aifinpay/agent.json at mode 600 and prints the three addresses
plus a paste-ready MCP config block. The server reads that keystore when
AIFINPAY_AGENT_SECRET is unset, so **the secret never goes into the config
block** — those get pasted into chats and committed to git.

Deliberate details:

  - A second `init` does NOT regenerate. The file may already hold funds;
    overwriting it to save a line of output would destroy a wallet.
  - `--help` and `--version` no longer fall through to the stdio server. They
    used to, which meant `--help` looked like a hang: the process was waiting
    for MCP framing on stdin that a human never types.
  - An unknown argument exits 2 instead of quietly starting a server. Starting
    on a typo is how someone funds an ephemeral address.
  - The output says the derivation is not BIP-39, so nobody expects Phantom or
    MetaMask to recover it from a phrase, and that the addresses hold nothing.

Also declares `bs58` and `tweetnacl`, which this package imports at runtime in
`agent-claim-self.ts` and never declared. Not cosmetic: installing the agent by
path (what CI does, and what building against the local SDK does) changes the
hoisting and `npm run build` fails with two TS2307s. It only ever worked by
accident. PR #16 carries the same two lines — expect a trivial overlap there.

Nine tests, all driving the real bin as a subprocess: a unit test of the
helpers would not have caught the `--help` hang. One of them was wrong first —
the helper read stdout only, while the server logs to stderr, so two assertions
failed against a binary that was behaving correctly.

mcp suite 43 -> 52 tests, all pass. Build clean.

Refs AIFINP-107.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* mcp: bump to 1.5.3, declare bs58/tweetnacl for the init command

The cherry-pick carried the init command but not a version bump; the new
published behaviour needs one. 1.5.3 because #19 (payable_fetch) claims 1.5.2 —
whichever merges second reconciles. bs58/tweetnacl are declared for the same
reason as elsewhere: agent-claim-self.ts imports them at runtime and the
source-built CI job fails TS2307 without them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…way (#23)

The reported failure (AIFINP-117): an agent runs `npm install @aifinpay/agent`
to get a wallet, and in a constrained sandbox the install FAILS — a real Grok
run spent 14 minutes and died on TAR_ENTRY_ERROR unpacking viem, ~157 MB of
transaction stack it never needed.

The subpath shipped in 1.8.4 (`@aifinpay/agent/wallet`) does not fix that: npm
installs every dependency of a package regardless of which entry point you
import, so installing @aifinpay/agent still pulls viem + @solana/web3.js. And
removing them from @aifinpay/agent is a breaking major (pay consumers would have
to install them). Neither is the fix an agent-sandbox needs today.

A separate tiny package is. `@aifinpay/wallet` installs 4 packages / ~4.5 MB in
a couple of seconds, has a CLI (`npx @aifinpay/wallet`), and derives the exact
same Solana, EVM and Casper addresses the full SDK does — asserted byte-for-byte
against @aifinpay/agent 1.8.4 in CI across four seeds. Non-breaking: nothing in
@aifinpay/agent changes.

The keystore it writes (~/.aifinpay/agent.json, mode 600) is the one
@aifinpay/mcp reads, so the labour splits cleanly: this light package to CREATE
a wallet anywhere, the full SDK only when you actually PAY.

The derivation is a copy of node/src/wallet.ts, kept honest by the byte-identity
test rather than a shared import (importing the full SDK would defeat the point).
newWallet() returns a recoverable seed; walletFromSolanaSecret() re-derives from
the stored secret. The CLI refuses to overwrite an existing keystore, errors on
an unknown command, and warns on loose file mode.

An import-graph test asserts dist/index.js pulls neither viem nor
@solana/web3.js nor @aifinpay/agent. It was wrong once first: its regex counted
a doc-comment that literally contains `import { AiFinPayAgent } from
"@aifinpay/agent"` as a real import. Now it strips comments before scanning —
the graph is what the CODE pulls, not what the prose mentions.

Standalone install measured: 156 files, 2.7 MB of production deps, vs 157 MB.
12 tests pass. CI builds and tests it as its own job. First release: 0.1.0.

Refs AIFINP-117.


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The repo was AIFP-2sdk when this package was written; it has been renamed back
to AiFinPay/sdk. Only wallet/package.json still carried the interim name — the
older node/mcp/python references were on AiFinPay/sdk all along and are correct
again. Package is 0.1.0, unpublished, so this is metadata-only.
enot3615 and others added 30 commits August 20, 2026 20:50
The founder's ask, verbatim: a developer should be able to ask their
agent "скільки в тебе запитів до такого-то сервісу лишилось" and get a
real number that then sits in the conversation. The data has existed all
along (GET /v1/agents/:address/receipts, used/remaining per retained
receipt); this is the missing last inch into the agent's context window.

Read-only, closed-world (talks only to the configured backend), rolls
batches up per merchant so the answer is one line, and carries the
honesty caveat: `remaining` is authoritative where AiFinPay meters; a
merchant running @aifinpay/gate meters locally and our copy can lag.

Two edges pinned by test: a backend error is an error result, never an
empty quota ("nothing left" and "could not check" are different answers,
and conflating them stops an agent from paying merchants); and the tool
never emits a bearer JWT even if the wire someday leaked one.

8 new tests; mcp suite green, build clean.


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
"What if an agent just opens Playwright and visits?" — partner question,
onboarding call 2026-08-21. Un-disguised headless Chromium announces
itself as HeadlessChrome/…, so knownAiAgent now treats it (and PhantomJS)
like any self-identifying crawler: 402 on content routes. Deliberately
NOT "electron" — Electron UAs are humans inside app webviews, and
exempting humans is the predicate's entire promise. A driver that spoofs
a human UA stays out of scope by design (anti-bot territory).


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ckfile bumps (#34)

node/ and mcp/ (lockfile-only, `npm audit fix`, no --force):
- vite 8.0.11 -> 8.2.2 (dev-only, via vitest): fixes GHSA-fx2h-pf6j-xcff
  (server.fs.deny bypass) + GHSA-v6wh-96g9-6wx3 (launch-editor NTLMv2)
- nanoid 3.3.12 -> 3.3.18 (dev-only): GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8
- postcss 8.5.14 -> 8.5.26 (dev-only): GHSA-fxqj-rqcc-2cmp, GHSA-r28c-9q8g-f849
- vite's own transitive deps moved with it (rolldown rc.18 -> 1.2.5,
  lightningcss 1.32 -> 1.33, picomatch, tinyglobby)
- mcp/ runtime: @aifinpay/agent 1.8.1 -> 1.8.4 (within ^1.8.1)
- lockfile root versions sync to package.json (were stale: node 1.8.4,
  mcp 1.5.3 -> 2.0.0-rc.1)

NOT fixed (needs major bump, left alone): uuid <11.1.1 moderate
(GHSA-w5hq-g745-h8pq) -- jayson 4.3.0 pins uuid ^8.3.2 under
@solana/web3.js 1.x, and the only patched uuid is 11.1.1; real fix is
the web3.js v2/kit migration. Affects node/, mcp/, mcp-http/, wallet/.

Tests: node 155/155, mcp 65/65 (vitest); tsc builds clean in both.
gate/ already at 0 vulns; mcp-http/ and wallet/ untouched (uuid chain only).


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Align merchant-facing gate docs and pricing comments with canonical AIFP-1 gross-inclusive 99/1/0 economics, while clarifying that AIFP-2 is provider-preserving with current 0% fee.
* reconcile: port canonical README.md

* reconcile: port canonical mcp/README.md

* reconcile: port canonical mcp/package.json

* reconcile: port canonical mcp/package-lock.json

* reconcile: port canonical mcp/src/tools/pay-with-split.ts

* reconcile: port canonical node/README.md

* reconcile: port canonical node/package.json

* reconcile: port canonical node/package-lock.json

* reconcile: port canonical node/src/aifp1.ts

* reconcile: port canonical node/src/unifiedAgent.ts

* reconcile: port canonical node/tests/aifp1.test.ts

* fix(gate): export canonical split constants
…migration (#38)

npm still serves 0.2.1, whose README promises the superseded model in as many
words: a 1% fee "charged on top of the agent's payment — never deducted from
you", with a worked example of a merchant quoting $0.0005 and receiving
$0.0005. The canonical model (CEO decision 2026-08-23) is the reverse for
AIFP-1 — the agent pays the published price, 1% is withheld from it, the
merchant receives 99% — and a partner is integrating against the published
text right now.

The repository README was already corrected; this ships that correction and
adds the part it was missing. The corrected text described the target as
though it were current, which is its own inaccuracy: Polygon mainnet still
runs the previous splitter at an immutable 98.99/1.00/0.01, and the backend
grosses up to match it, so today an AIFP-1 merchant is made whole and the
agent pays slightly more than the displayed price. A merchant integrating this
week needs both numbers and the boundary between them, so the note gives all
three and says the package API does not change when it flips.

Per ТЗ §13 documentation must not lead the code — which is exactly why the
note describes the current chain rather than only the target.

Also fixed a test comment asserting "on top, never deducted" in prose beside
an assertion of the right number.


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ge (AIFINP-78)

New private workspace package @aifinpay/internal-tokenlist. Per the
review on the ticket, the token list is GENERATED from live chain
reads, never hand-typed: scripts/gen-tokenlist.mjs carries only
addresses and provenance, reads name/symbol/decimals from each chain,
and fails on a codeless address or any disagreement with the pinned
expectations. --check is a drift gate against live chain state.

Seeded with the verified entries from the ticket review: Polygon
native-Circle USDC + USDT (self-reports USDT0 — keyed by address),
BSC USDT/USDC at their real 18 decimals (the AIFINP-120 trap, pinned
by an offline regression test), Avalanche USDC, and the canonical
Solana USDC/USDT mints (decimals read from the mint account). The
fabricated/bridged addresses from the original plan are excluded and
the USDC.e exclusion is test-enforced.

Not yet included, recorded in the README: Tron (needs a TronGrid read
path so it can be verified rather than trusted) and the AIFP contract
ABIs (to be lifted from backend/polygon.js, not rewritten).

7/7 tests, tsc clean, runtime smoke-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge (AIFINP-78) (#24)

feat(tokenlist): chain-verified internal token list package (AIFINP-78)
…om (#39)

An external QA pass on the Raters integration read our 402 and reported it as a
protocol defect: "no merchant address, no Polygon/USDC, no quote/order ID, no
expiry, no receipt instructions". Verdict BLOCKER.

Most of that report traces to the middleware not being in the request path at
all on that deployment, which is a separate matter. But this part was a fair
reading of a real gap: the 402 does not carry those fields AND never said where
they were, so "missing" was the only conclusion available.

They cannot be inlined. accepted_chains is derived per merchant from
Object.keys(merchant.pay_to) (backend/routes/aifp.js:608); accepted_assets drops
POL whenever there is no live POL rate (:550); order_id and expiry belong to a
quote that does not exist yet. All of it changes without this resource changing,
and a gate running on the partner's own host holds none of that state. A static
challenge listing them would sometimes promise a settlement the quote refuses,
which is worse than not listing them.

So the 402 gains one line naming the endpoint that does have them:

  settlement_terms_from: "POST https://api.aifinpay.io/v1/quote — returns
  accepted_chains, accepted_assets, amount, order_id and expiry"

Two things the QA report asserted that are NOT our protocol, recorded here
because the same reading will recur:

  * There is no free quota. No "first 100 free", no grace, no trial — grep of
    the manifest, llms.txt, the gate README and the frontend finds no such
    promise anywhere. In AIFP-1 request #1 gets the 402. `min_requests` is the
    minimum PREPAID batch ($0.10 / unit price), a different thing entirely.
  * Chains are named strings — "polygon", "amoy" — not numeric chain IDs. There
    is no 137 in this API to return.

tests/challenge.test.ts pins the exact key set of the 402, so it failed on this
change. That is the test working: the protocol surface should not grow by
accident. Updated deliberately, with the reason next to it.

15 files, 94 tests pass.

Refs AIFINP-209


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#41)

A partner registered 52 resources, 8 of them pages, deployed, and served every
page 200. Their tester filed it as a BLOCKER against our protocol. The protocol
was fine; the README documented AIFP_MERCHANT_ID and AIFP_MERCHANT_SECRET and
stopped. No Next.js example, no matcher, no mention that shouldCharge is opt-in,
nothing about robots.txt. AIFINP-209.

All four failures are silent, and in all four the dashboard shows
paywall_enabled: true while the truth is otherwise:

  * A path missing from config.matcher is never seen by the middleware. The
    resource is registered, priced, enabled — enforcement zero, forever, and
    nothing reports it.

  * shouldCharge omitted charges everything. core.ts is `if
    (options.shouldCharge)`, no default. On an API that is usually right; on a
    PAGE it puts a 402 in front of your own readers and Googlebot, which is
    worse than having no gate at all.

  * A predicate that throws charges the request. A reader who assumes the
    opposite writes one that fails open and serves crawlers free.

  * robots.txt Disallow stops a well-behaved crawler before it reaches the
    paywall. No amount of correct SDK configuration fixes that, and merchants
    who arrive from "block the AI scrapers" already have the line in place. You
    cannot forbid a crawler and bill it at the same time.

The new §1b is a working middleware.ts with the matcher covering pages, the
predicate passed, and the robots.txt consequence stated as the business decision
it is: monetising AI traffic means stop blocking, start charging.

There is a test on it. Documentation usually does not deserve one — this does,
because the failure it prevents is invisible. Nobody files a bug for revenue
that never arrived, which is why a partner's QA found this and we did not.
Verified it fails against the README this replaces: 4 of 4.

98 tests pass across 16 files.

Refs AIFINP-209


Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f-hosted origin (#42)

* fix(sdk): stop taking the merchant's royalty, and let MCP reach a self-hosted origin

Three things an external E2E run found on 2026-08-27, after the payment had
already succeeded. AIFINP-211.

## 1. The royalty slot defaulted to our own treasury

python/aifinpay/unified_agent.py substituted the splitter's treasury() whenever
a payment method carried no ip_creator, justified in the code as:

    Passing address(0) would skip the transfer and permanently strand the 1bp
    inside B2BSplitter — no sweep function.

B2BSplitter._split does not do that:

    if (_ipCreator != address(0)) { ipAmt = ...; }
    // else: ipAmt stays 0 and is absorbed into merchantAmt below
    merchantAmt = _total - treasuryAmt - ipAmt;
    ...
    if (ipAmt > 0) { transfer to _ipCreator }

With address(0) nothing is stranded — the merchant keeps it and no transfer is
attempted. The premise was wrong and the consequence was that 0.01% of every
unattributed payment moved from the merchant to us, silently, while /v1/quote
published a 99/1/0 split.

Observed on-chain: tx 0x6b853876… — merchant 98.99%, treasury 1.00%, and 0.01%
paid to 0xD31d82…3c8e, our own Safe. The SDK ignored settlement_call.args
.ip_creator = 0x000…000 and used the fallback.

## 2. MCP never exposed gatewayOrigins

@aifinpay/agent has supported it since parseGatewayUrl existed. This wrapper did
not pass it, so a self-hosted merchant was unreachable: payable_fetch reached the
402 and refused with "dev.ratersapp.com is not a known AiFinPay gateway
(allowed: https://gateway.aifinpay.io)".

Now AIFINPAY_GATEWAY_ORIGINS. Validated rather than trusted: a bare origin, https
only, and a plain hostname. WHATWG URL accepts "*" in a hostname, so
"https://*.example.com" parses and its origin round-trips — it would be stored,
match nothing, and read as "I allowed this host". My first version of the
validator let it through; the test caught it.

Unset stays undefined rather than [] — those differ downstream, where [] means
"no origin is payable at all".

## 3. MCP could not resolve anything behind a proxy

safe-fetch resolves a hostname and refuses if any answer is private — an SSRF
guard. Behind an HTTP proxy the client does not resolve at all, so lookup()
fails with EAI_AGAIN and every host is refused as "cannot resolve": the guard
misfiring on the environment rather than on a threat.

Now AIFINPAY_TRUSTED_HOSTS, and deliberately NOT a proxy-detection switch. "We
seem to be behind a proxy, disable the check" turns one environment quirk into a
blanket SSRF bypass, which is the vulnerability that file exists to prevent. An
operator names hosts one at a time, matched EXACTLY — no suffix matching,
because "example.com" trusting "evil-example.com" is how an allowlist stops
meaning anything.

The resolution failure now names the variable, so the next person meets a
misconfiguration instead of a broken SDK.

Tests keep both of these allowlists: the tempting fix for either is a global
switch. Verified the python tests fail against the treasury fallback (3 of 4).
74 tests pass across 6 files.

Refs AIFINP-211, AIFINP-210

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

* chore(release): aifinpay-agent 1.5.0, @aifinpay/mcp 2.0.0-rc.3

The version gate is right to insist. aifinpay-agent 1.5.0 is a minor, not a
patch: the royalty fallback changes where 0.01% of every unattributed payment
goes, so an agent that upgrades builds a different transaction. A patch bump
would have said the opposite.

@aifinpay/mcp 2.0.0-rc.3 adds AIFINPAY_GATEWAY_ORIGINS and AIFINPAY_TRUSTED_HOSTS,
both unset by default — no behaviour changes for anyone who does not set them.

Refs AIFINP-211

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
dev's only unique content is the chain-verified internal token list
(AIFINP-78, merged to dev as #24): 12 files, +1665 lines, zero deletions —
a new node/internal-tokenlist package that cannot alter anything already here.

Opened from a branch off main rather than merging dev directly, because main's
protection requires the head to be up to date and dev is 3 commits behind it.
Same result, no push to a shared branch.

Why main is the trunk, and why this direction: dev does NOT have the #42
royalty fix — _splitter_treasury(pm[ is still in python/aifinpay/unified_agent.py
there and absent here — so anything built on dev takes the merchant's royalty.
dev has also never produced a release (no unique tags), CI triggers on bare
push/pull_request with no branch filter so dev buys no extra safety, main
requires 4 status checks to dev's 3, and 7 of 11 open PRs already target main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX
chore: bring dev's token list onto main, making main the single trunk
…fail closed on the policy window (#40)

* feat(sdk): select v1.3 splitters by chain AND route, with no fallback

From v1.3 a chain carries one splitter per protocol route, because the fee
split is immutable at construction and the two protocols need different
economics: merchant-aifp1 is 100/0 and agent-x402 is 0/0. The existing
SPLITTER_DEPLOYMENTS map is keyed by chain alone and its version union is
"1.1" | "1.2", so it can express neither.

Adds SPLITTER_ROUTES, keyed "<chain>:<route>", covering all eighteen v1.3
deployments across the nine chains, with resolveSplitterRoute() that
throws on an unknown pair instead of falling back.

The no-fallback rule is not a style preference. The splitters were
deployed with CREATE, so an address derives from deployer and nonce and
the same address recurs on other chains for the other route:

  0x1Fe2021336596655Fac72bC7bC40F7FFFA501d55 is OP's merchant-aifp1 and
  also Base's agent-x402.

  0xF03B3387415D557b6ab709D06E8aF0b4ABD6Eb74 is Unichain's
  merchant-aifp1, Avalanche's agent-x402, and the legacy v1.2 splitter on
  Optimism.

Every route therefore resolves to a real, deployed, working contract. A
fallback would not fail — it would settle at the wrong fee split, and the
amounts would look plausible in every log. A test asserts that this reuse
exists and that a shared address still resolves to different economics
per chain.

resolveSettlingSplitterRoute() is deliberately separate: reading the
registry and being cleared to move money are different questions.
Settlement is disabled on all eighteen and each route is enabled
individually after a paid mainnet end-to-end with verified balance
deltas, and the policy window is enforced so an unreviewed route stops
settling rather than drifting on.

Values are generated from the canonical registry in evm-contract
(registry/generated/splitter-table.json, schemaVersion 2), where treasury,
both bps values and the runtime code hash were read from chain rather
than transcribed. A test pins the consequence: exactly two distinct
runtime code hashes across eighteen contracts, one per route, which is
what proves the right immutable profile reached every chain.

Also moves the botchain and xrplevm chain definitions into src/chains.ts
so unifiedAgent and splitterRoutes cannot drift apart on a chain id.

SPLITTER_DEPLOYMENTS is left untouched; the v1.1/v1.2 entries it serves
are marked superseded with settlement disabled in the registry.

Build clean; 170 tests passing across 18 files, 15 of them new.

* chore(agent): 2.0.0-rc.3 — new route-selection exports are published files

The version gate is right to fail: splitterRoutes.ts, chains.ts and the
new index exports all ship in the package, so consumers can pin them.
Additive only — SPLITTER_DEPLOYMENTS and every existing export are
unchanged, so this is a prerelease bump rather than a breaking one.

* feat(sdk): generate SPLITTER_ROUTES from the canonical registry, fail closed on time

Two repositories were hand-maintaining the same payment-critical table:
addresses, code hashes, fee splits, policy dates and settlement flags lived in
evm-contract's registry AND were typed out again in splitterRoutes.ts. Those
disagree eventually, and the failure is silent — the amounts still look
plausible in every log.

They are now one table. registry/splitter-table.json is a byte-for-byte copy of
the canonical artifact, registry/source.json records the evm-contract commit and
its sha256, and src/splitterRoutes.generated.ts is produced from it:

  npm run registry:sync -- --from ../evm-contract
  npm run registry:check    (CI gate)

`registry:check` regenerates and compares byte-for-byte, and re-hashes the
vendored artifact against its recorded provenance, so hand-editing either one
turns CI red. Offline and deterministic: this gate must not need a network read
of another repository to know whether it is in sync. Rejected, as it should:
a hand-edited payout address, a hand-edited fee split, a hand-edited settlement
flag in the artifact.

Only the 18 current v1.3 routes are generated. The superseded v1.1/v1.2 entries
stay in the canonical registry as deployment evidence but are deliberately not
representable here — a resolver that cannot name a legacy splitter cannot
silently fall back to one. owner is carried through and asserted against the
governance Safe, in the generator and again in the tests.

Not generated, deliberately: viemChain, defaultRpc and explorer. A wrong RPC
fails loudly and pays nobody; a wrong splitter address pays the wrong party
successfully. A chain with no transport entry is an error, not a default.

resolveSettlingSplitterRoute failed OPEN on the input it could least trust.
Date.parse("nonsense") is NaN, NaN fails every comparison, so with `t < from` /
`t >= until` both gates were false and a route with a malformed policy window
settled with no time check at all. Every comparison is now written as "prove it
is inside the window", and an unparseable window, an inverted window or an
invalid `now` are each rejected explicitly.

The old expiry test could not have caught this: every shipped route has
settlementEnabled false, so it rejected on the flag before reaching validUntil
— it proved the settlement flag worked, twice. The window is now tested against
a synthetic ENABLED route, asserting the reason and not just the throw: before
validFrom, exactly at validFrom, mid-window, one ms before validUntil, exactly
at validUntil, after it, and malformed/inverted/invalid-now failing closed.

Verified against the pre-fix implementation: the five fail-closed tests fail,
the boundary tests still pass. 185 tests, 18 files.

* ci(registry): verify the vendored artifact against evm-contract at the recorded commit

registry:check is offline and self-consistent — a writer who can change this
repository can change the artifact, its provenance and the generated table
together, and the check blesses the set. That was the audit's HIGH: a green
CI can certify a self-consistent forged snapshot.

This step fetches the artifact from AiFinPay/evm-contract itself, at the exact
40-hex commit source.json records, over HTTPS from GitHub, and requires the
bytes to be identical to the vendored copy. The commit is immutable and both
repositories are public, so nothing in this check can be satisfied by editing
files here. An unreachable GitHub fails; it does not skip.

* chore(registry): sync to evm-contract main after #20 — schema 4, quorum and allowlist carried through

The canonical artifact now carries the fields #20 added: rpcQuorum, the
per-chain stablecoin allowlist, the full Safe shape and build provenance.
Re-vendored byte-for-byte from evm-contract main and regenerated.

rpcQuorum and stablecoins are carried into SplitterRouteDeployment. The
generator refuses an artifact that marks a single-provider route enabled,
mirroring the registry's own gate so a hand-edited copy cannot get past the
SDK either, and refuses a route with no recorded allowlist.

All 18 routes remain settlementEnabled: false.

---------

Co-authored-by: Syed Hassan <304852340+syedhassan-aifinpay@users.noreply.github.com>
Co-authored-by: Syed Hassan <syd_hassan@hotmail.com>
Co-authored-by: enot <223016348+enot3615@users.noreply.github.com>
The partner's integration is correct and their gate refuses paid requests
anyway, because this package is stricter than the hosted gateway it claims to
port.

src/scope.ts says, in its own header, what happens if that is ever true:

  "if this gate is stricter than the hosted one, an agent buys a wide receipt,
   is charged for it on-chain, and then gets a 403 from the merchant who
   installed our middleware — money taken, service not rendered, and the
   merchant looks like the one who broke it."

It then had exactly that defect for eleven days. The file was written
2026-08-20. backend/aifp/scope.js was fixed 2026-08-27 (aifinpay-web 1173b93,
"a receipt for /genres/* must open /genres/action, not the literal string"),
adding patternCovers and routing 'exact' through it. The port did not follow.

Measured head to head before changing anything:

  scope=exact  /movies/*      → /movies/inception       hosted true, gate false
  scope=exact  /genres/*      → /genres/action          hosted true, gate false
  scope=exact  /collections/* → /collections/best-2026  hosted true, gate false

A merchant registers "/movies/*" as ONE resource and the quote's resource is
that pattern, not the URL the agent hit — so a literal comparison matches
nothing and every URL beneath needs its own batch. At the $0.10 minimum that
turns a 670k-page catalogue into $67,000. Keeping the wildcard in the receipt is
deliberate; this is what makes it mean anything.

Live effect: of the five wildcard page resources a partner has registered
(/movies/*, /genres/*, /collections/*, /crew/*, /watch-online/*), none could be
bought and used. Their gate returns 402 correctly, the agent pays, and the
retry is refused.

WHY THE EXISTING TESTS PASSED THROUGHOUT

tests/scope.test.ts asserted this package's own behaviour, so it agreed with the
bug. The replacement is written from the HOSTED side's behaviour instead, which
is the only direction that can catch a port going stale. All five original cases
are carried over — including /api/v1 not covering /api/v10/secret, the sharpest
form of the prefix boundary rule — plus the wildcard cases that were missing.

Verified the new cases fail against the old implementation: restoring
`return path === resource` turns two of them red. 105 tests pass across the
package.

0.2.2 → 0.2.3. The published 0.2.2 carries the bug, so integrators on wildcard
resources need the upgrade; a merchant with only literal paths is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX
Verified live against a partner's gate before touching anything:

  aifinpay-agent-node/0.3.0  → 200   no challenge
  aifinpay-agent-py/1.0.0    → 200   no challenge
  GPTBot/1.0                 → 402

An agent built on our own SDK was served free by a page gate using
knownAiAgent, so it never saw the 402 and never paid. The list named fourteen
third-party crawlers and neither of ours.

The AIFP-Agent-Id branch already covered part of this: node/src/aifp1.ts:616
sets that header on every request including the first, and sending it turns the
same request into a 402 — confirmed. But node/src/agent.ts does not set it, and
the Python client sets it nowhere, so whether our own agent could see a paywall
depended on which of our clients an integrator picked and which code path they
took. Naming the user agent makes it depend on nothing.

"aifinpay-agent" as a substring covers both clients and any future one that
keeps the prefix.

108 tests pass. The browser case is asserted alongside, because a marker this
broad is exactly the kind that starts 402-ing humans if it is written carelessly.
When a merchant has not named an ip_creator, the Node client resolved the
royalty slot by reading B2BSplitter.treasury() and passing that. The comment
justified it: address(0) "would skip the transfer and permanently strand the
1bp inside B2BSplitter — the contract has no sweep function."

The premise is false. B2BSplitter._split(), contracts/B2BSplitter.sol:238-244:

    if (_ipCreator != address(0)) { ipAmt = _total * ipCreatorBps / D; }
    // else: ipAmt stays 0 and is absorbed into merchantAmt below
    merchantAmt = _total - treasuryAmt - ipAmt;

Nothing is stranded — with address(0) the royalty folds into the merchant's
leg, which is where it belongs when nobody named a creator. So the fallback
was not rescuing a basis point, it was taking one off every such payment and
paying it to us. That is the on-chain source of the "0.01% creator fee" that
QA raised.

Python was corrected in #42 on 2026-08-27. Node was not. Same defect, same
week, twin file left alone — the third instance of that pattern this month
(gate/src/scope.ts against backend/aifp/scope.js was the second).

A unit test on either client alone passes throughout, so the guard added here
reads both files and fails when they disagree. Verified failing on the defect
before it was accepted. The dead splitterTreasury() helper and its cache are
removed with it; leaving the machinery in place is how this comes back.

The Solana branch keeps routing through treasury in both clients. That one is
matched and deliberate — the Solana bridges do not surface an ip_creator at
all — and the fourth assertion pins it so nobody "fixes" it by symmetry.

Full Node suite: 189/189.
fix(gate): a receipt bought for "/movies/*" must open /movies/inception
The published-files gate is right: unifiedAgent.ts ships to users, so two
different builds must not both call themselves rc.3.

rc.6 rather than rc.4 on purpose. rc.4 is claimed by #46 and rc.5 by #49, both
still open. Taking the next number above every open claim means this can merge
in any order without making anyone renumber — and a royalty fix should not wait
on a release-numbering negotiation.
…hant

fix(node): stop paying the merchant's royalty to our own treasury
v1.4 inverts who authorises a payment. Through v1.3 the agent built its own
calldata and settled unilaterally; the backend verified afterwards. In v1.4 both
entrypoints go through _verifyQuote, which requires a 65-byte ECDSA signature
from a SIGN_OPERATOR_ROLE holder. The agent cannot produce one, so the quote
arrives already signed and the SDK's job is to CHECK it and submit it — never
to construct it.

That makes this module mostly refusals, and every one of them exists because
the contract would otherwise revert AFTER the agent has paid gas, naming
something the agent cannot see: InvalidSigner, SignatureExpired, InvalidNonce,
IncorrectNativeValue. Each check cites the clause of V14_QUOTE_FORMAT §8 it
implements, because a check nobody can trace to a requirement is one somebody
deletes.

  §8.2  the quote is signed FOR one address; the contract enforces
        payer == msg.sender, so a foreign payer is caught before gas
  §8.3  the order id is hashed locally and compared. A caller that does not
        know its order id gets orderIdChecked:false rather than a false
        assurance that something was verified
  §8.4  expiry with headroom, because the deadline applies when the
        transaction is MINED, not when it is prepared
  §8.5  msg.value must equal grossAmount exactly. Overpaying reverts too, and
        would otherwise look like generosity
  §8.6  consumedNonce and payerNonce answer different questions and both are
        asked: already-spent means do not pay twice, stale means another
        payment from this wallet settled first and a fresh quote is needed
  §8.7  routes are allow-listed BY NAME, not by hash. The contract derives one
        from the other — Profiles.routeId(name) == keccak256(bytes(name)),
        verified against the deployed Amoy Profiles for both routes — so
        pinning hashes would store a derived value and invite the two to
        disagree. An unknown route is refused, which is what §8.7 asks of an
        SDK older than its deployment.

Stablecoin settlement is refused rather than attempted: settleStable needs
approve(splitter, grossAmount) first, and approving on the agent's behalf is
its policy layer's decision, not this function's.

The execute tests drive executeV14Settlement itself, not the validators. That
is a direct lesson from the backend side today, where bindsToQuote() was
perfect, exported, unit-tested and never called — a unit test on a helper
cannot tell you the helper is reachable. Three of these assert that nothing is
broadcast when a check fails.

SDK suite: 209 passing across 20 files.
feat(v14): client-side settlement for B2BSplitterV14
An agent buying access for "/genres" could not tell whether that covered
"/genres/action". It found out by paying and being refused. That is the
confusion behind punch-list item 6, and fixing the scope COMPARISON in 0.2.3
does not help while the challenge never says which scope was sold.

scope is a property of the MOUNT, not of the merchant's payout state, so a
self-hosted gate knows it and can state it. That is the line that separates it
from accepted_chains, which this file already declines to inline and explains
why: chains are derived per merchant and change without the resource changing,
so a gate on the partner's own host would be publishing a guess the quote then
refuses. settlement_terms_from names the endpoint that holds that state, and
that answer was already right.

The existing "carries every field the hosted gateway emits" test caught this
change, which is the test working. Its premise needed narrowing rather than
loosening: the two gates are no longer byte-identical, deliberately, and the
comment now records which fields differ and why, so the next person does not
"fix" the difference back.

Gate suite: 108 passing across 16 files.
…write it (#54)

A partner asked how an agent learns a site takes payments before it hits a 402.
Two answers, and the gate should own the second one.

  1. The 402 itself carries how_to_pay. An agent that just tries learns
     everything from the response — no prior knowledge needed. This already
     works.
  2. An agent that discovers politely, before spending a request on a paywalled
     path, reads a well-known file. That file is an x402 standard, and today
     every merchant would write it by hand and let it drift from what they
     actually gate.

The gate already knows every resource it protects, so it can build the file.
aifpDiscovery() is one middleware the merchant mounts once next to the gates,
and it serves GET /.well-known/x402.json: merchant id, protocol, quote/pay
endpoints, wallet onboarding, and every gated resource with its price and scope.

What it deliberately does NOT inline: chain and asset. Those depend on the
merchant's payout config and change without the resource changing, so the
document points at /v1/quote for them rather than publishing a guess that goes
stale — the same discipline the 402 challenge already follows.

Minor bump (0.2.4 → 0.3.0): new export, nothing changed for existing callers.

Gate suite: 115 passing across 17 files.
The agent's secret sat in ~/.aifinpay/agent.json as plaintext. Mode 600 keeps
it from OTHER users on the box; it does nothing against malware running as the
same user — which is exactly the threat the OSINT write-up of a compromised dev
machine described: an infostealer that grabs local keys. A card/cloud key would
be behind an OS keychain; ours was a base58 string in a JSON file.

Set AIFINPAY_WALLET_PASSPHRASE and the keystore is encrypted at rest:
scrypt (N=2^15) to stretch the passphrase, AES-256-GCM so a tampered file is
detected rather than decrypting to a wrong-but-valid-looking key. Salt and IV
are stored alongside; they are uniqueness, not secrets.

Opt-in and env-supplied, both deliberate. Prompting interactively would break
the non-interactive `npx @aifinpay/mcp` path the MCP client launches, and
forcing encryption would break every existing plaintext keystore. No passphrase
=> plaintext, byte-for-byte as before, and the init output says which mode it
wrote.

The failure modes are where the danger is, so they are the tests:

  - a wrong passphrase THROWS and leaves the keystore untouched. The silent
    version — treat decrypt failure as "no wallet", generate a fresh one — turns
    a typo into a permanently unreachable funded key. Verified the file is
    byte-identical after the failed attempt.
  - an encrypted keystore with no passphrase set refuses to start rather than
    falling back to a throwaway identity that looks like the wallet vanished.
  - the same passphrase reproduces the same address (encryption that locks the
    owner out is worthless).
  - the plaintext secret does not appear anywhere in the encrypted file.

scrypt at N=2^15 exceeds Node's default scrypt maxmem, so both call sites pass
maxmem explicitly — without it the whole thing throws at runtime, which the
tests caught before it shipped.

mcp suite: 79 passing. Version 2.0.0-rc.3 -> rc.4.
…ount (#56)

Agent-flow audit, point 6. The 402 and the quote answer "1.06 POL" and leave the
agent — and whoever is watching it — to work out FOR WHAT: how many requests,
which paths, until when, at what fee. A payment prompt that states an amount
without the terms is one a careful agent should refuse and a careless one
over-pays on.

Every field needed already lives in the quote. describeQuote assembles them into
the sentence a reasonable payer needs before signing:

  Pay 1.055375555391386 POL ($0.10) for 200 requests to /api/agent/genres
  (incl. 1.00% fee), valid until 2026-09-04T13:00:00Z.

Three things it makes legible that the raw fields do not:

  - the on-chain figure AND the USD, side by side, so neither is a surprise
  - the fee as a RATE, computed from the split the quote carries, so an agent
    can tell 1% from 50% rather than seeing only a total
  - the scope in words — "any path under /api/agent" vs one exact path vs the
    whole merchant. Scope is the field the wildcard bug proved everyone misreads,
    and it decides what the money actually buys.

wei is converted with no float, so "1 POL" is exactly one. No native settlement
falls back to the USD amount rather than inventing a token figure.

Pure function, no I/O, so an MCP tool or a CLI can put it in front of an LLM or
a human unchanged. node suite: 215 passing. Version rc.7 -> rc.8.
Covers install (agent vs mcp), merchant discovery from a bare domain, what init
prints and where the key lives, at-rest encryption and derivation, double-pay
protection, and reading what a payment buys. Each section states what the code
does today, not what it should — points 4 and 6 reference the PRs that added
the encryption and describeQuote.
…dit demanded (#58)

AIFINP-220 §3 sets the rule — the private key must never reach chat or logs —
and asks for an automated test that proves it. This adds both the missing
recovery prompt and that test, and they are not in tension once the CHANNEL is
the thing you look at.

The recovery print is on the TERMINAL, on a fresh plaintext init, once. That is
the one-time backup every wallet CLI shows, on a channel the human running init
controls. It is the opposite of the leak the audit forbids, which is a secret
written into a CHAT transcript an LLM provider retains or a log a shipper keeps.
Same string, opposite exposure — so the distinction the tests enforce is where
it appears, not whether.

  - fresh plaintext init prints RECOVERY KEY once, with the secret and a
    "do not paste into chat" warning
  - a second init does NOT reprint it — shown once means once
  - an ENCRYPTED init prints no recovery line at all: recovery there is the
    keystore plus the passphrase, and reprinting the plaintext secret would undo
    the encryption the user just chose
  - the ephemeral / no-init start (the autonomous path — an agent launched with
    no wallet) never prints a secret, only addresses. The agent can announce its
    addresses without the key ever reaching the transcript.

The autonomous requirement — the agent gets its instructions itself and does not
overpay — is already met elsewhere and unchanged here: the MCP tools are
self-documenting (payable_fetch tells the agent to use it and not WebFetch), the
402 carries how_to_pay, describeQuote states what a payment buys, and
orderIdHash/nonce stop a double-pay. This commit closes the one gap those left:
a human-run init had no off-machine backup line, and nothing tested that the
secret stays off chat.

mcp suite: 82 passing. Version rc.4 -> rc.5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants