Skip to content

Security remediation: x402 SDK / MCP audit 2026-08-06 - #12

Draft
coinsecuritiescompany wants to merge 84 commits into
mainfrom
security/audit-2026-08-06-remediation
Draft

Security remediation: x402 SDK / MCP audit 2026-08-06#12
coinsecuritiescompany wants to merge 84 commits into
mainfrom
security/audit-2026-08-06-remediation

Conversation

@coinsecuritiescompany

@coinsecuritiescompany coinsecuritiescompany commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Security and settlement candidate

  • operator spend caps cannot be raised by model/tool input;
  • x402 v2 authorization binds nonce, agent, method, resource, expiry, amount floor and agreement hash;
  • settlement targets and contract versions come from the verified registry, not server input;
  • EVM settlement is v1.3 fee-on-top only; every legacy v1.1/v1.2 entry is disabled;
  • Solana Node/Python builders use the exact v0.6 discriminator, payment ID, receipt PDA, merchant amount, creator flag and account order;
  • active MCP invoice scope is restricted to backend-supported solana|polygon;
  • creator fee is optional and explicit.

Evidence

Candidate SHA: 1f400fc84930842f1b93246e2f10f24817d069a3

GitHub SDK CI run 31280955506 — success.

Clean local gates:

  • Node: 169 tests + TypeScript build;
  • MCP: 34 tests + TypeScript build;
  • Python: 107 tests.

Release boundary

Nothing is published. Polygon, Solana and Casper settlement remain fail-closed until the registry is backed by a reviewed matching deployment and controlled paid E2E evidence. Address/balance inventory is not treated as settlement support.

@syedhassan-aifinpay

Copy link
Copy Markdown
Member

Review — contract and deployment assumptions

Reviewed from the deployed-architecture side, as asked. The security work here is strong: runtime codehash pinning plus live treasury()/owner()/BPS reads is a better check than matching function selectors, and C-1/H-12 are properly closed — splitter, merchant, version and chain are all resolved against the registry before calldata is derived. The cross-denomination guard and the adversarial tests for wrong codehash, RPC timeout, wrong chain and EOA/empty code are all the right shape.

One blocking issue, and it is a sequencing problem rather than a flaw in the logic.

There is currently no payable route on any chain

validateQuotedNativePayment rejects anything that is not v1.3:

if (target.version !== "1.3") reject("fee_inclusive_splitter_disabled");

The only entry in SPLITTER_DEPLOYMENTS with enabled: true is Polygon, and it is version: "1.2". Every other chain is enabled: false. B2BSplitter v1.3 is not deployed anywhere yet — AiFinPay/evm-contract#9 is still a draft with CI green and no deployment.

There is exactly one writeContract call in the settlement path and it sits behind this validator, so there is no alternate route. The result is that after this PR merges, every EVM payment rejects with fee_inclusive_splitter_disabled.

The documentation added in this PR states "Only Polygon v1.2 is enabled", which contradicts the validator in the same change. One of the two needs to move:

  • if the intent is "everything blocked until v1.3 is live", then Polygon's enabled: true is misleading and the doc sentence should say so explicitly;
  • if the intent is that Polygon keeps working, then the v1.3 gate is stricter than intended.

Blocking fee-inclusive routes is the right call — paying a v1.1/v1.2 contract with fee-on-top amounts mis-settles. The issue is only that the current state is not what the docs describe.

Release order is now load-bearing

Because the registry pins runtimeCodeHash, it cannot be populated before deployment exists. That fixes the order:

  1. deploy B2BSplitter v1.3;
  2. read runtime codehash, treasury(), owner(), treasuryBps(), ipCreatorBps() from each chain;
  3. write those into the registry with the validity window;
  4. then release the SDK.

Publishing the SDK before step 1 ships a package that cannot pay anyone.

The evidence window expires on 3 September 2026

validFrom: 2026-08-04 / validUntil: 2026-09-03. On 3 September every route fails closed with registry_entry_expired. That is correct behaviour, but it needs a named owner and a calendar reminder, otherwise it reads as an unexplained outage four weeks from now.

Governance note for the v1.3 deployment

The registry records Optimism, BOT Chain and XRPL EVM as controlled by the single EOA 0x1D5e…fAB9 rather than the Safe, and all three are correctly disabled. When v1.3 is deployed, please transfer ownership to the Safe as part of the deployment rather than afterwards, so these do not need disabling a second time.

v1.3 changes the function selector

v1.3 passes the merchant amount explicitly, so the signature becomes payNative(bytes32,address,uint256,address,string) and the selector changes from 8f0122bb to 894eb1f3. Any tooling that identifies a contract by its selectors needs this added, or a correct v1.3 deployment will read as "not a splitter". I have updated the verifier in the knowledge vault accordingly.

Verified on-chain, for the record

Re-read from each chain today: Polygon v1.2, Base v1.1, Optimism v1.2, Unichain v1.1, BOT Chain v1.2, XRPL EVM v1.2 — all six SDK addresses correct, every declared version matching the deployed contract. deployments.json is the stale document, not the SDK table. Evidence and tooling are in the vault at docs/10-projects/aifinpay/audit-2026-08-06/.

syedhassan-aifinpay and others added 10 commits August 9, 2026 01:14
main carried the AIFP-1 client and a refactor that extracted the splitter
settlement into settleSplitterNative(). This branch had removed the thing that
refactor was extracting: settlement is v1.3 fee-on-top only, against a splitter
resolved from the verified registry, with no server-steered version selection.
Taking main's version back would have reinstated the v1.1/v1.2 dynamic ABI
route, so it was not taken.

AIFP-1 is preserved in full and now settles through the same validated path as
call(). The gateway already quotes merchant/treasury/creator components, so
those are forwarded verbatim and validateQuotedNativePayment checks them
against the registry's fee-on-top model rather than the settling side trusting
a total. The splitter address and version are not read from the gateway — it
sends neither — so they come from the registry.

That has a consequence worth stating plainly: Polygon's registered splitter is
still v1.2, so EVM settlement — bridge and AIFP-1 alike — fails closed today
with fee_inclusive_splitter_disabled. It stays closed until v1.3 is deployed
and the registry entry is updated. A new test asserts the refusal against the
real settlement path so it cannot be relaxed quietly.

Also in this merge:

- aifp1.test.ts adapted to the branch's in-memory budget caps; the reservation
  ledger it imported was deleted here. The property it tested — a failed
  settlement must not consume budget — is unchanged and still asserted.
- splitterVersionDetect.test.ts stays deleted. It asserted the precedence
  between a declared version and on-chain detection, which is the mechanism
  that was removed; it cannot be updated to a contract that no longer exists.
  legacySplitterRouteRemoved.test.ts replaces it and asserts the removal.
- Package metadata: versions from main (1.8.0 / 1.5.0, already published, so
  the candidate cannot start lower), descriptions from this branch, because
  main's advertise settlement on rails this branch deliberately gates. MCP now
  depends on ^1.8.0 rather than ^1.7.1.
- Lockfiles regenerated from the resolved manifests. Production audit
  reproduced afterwards: agent 0 critical / 0 high (3 moderate), mcp 0/0
  (6 moderate), so the high-severity fixes from main are still in.

Node: build green, 162 tests. MCP: build green, 34 tests.
The five failures were old tests describing a protocol that no longer exists:
they called the retired _sign_nonce primitive and drove a /nonce endpoint the
v2 flow does not have. Restoring either would have meant reinstating unbound
generic nonce signing, so neither was restored — the tests were rewritten
against the AiFinPay-x402-v2 contract that replaced it.

What they assert now:

- Determinism over the whole bound payload rather than over a bare nonce.
- Every bound field is actually covered by the signature: nonce, agent,
  method, resource, expiry, min_usd and agreement hash each change it. This
  is the property that stops a signature for one request authorising another.
- A hostile origin is refused before the signer is reached, asserted by
  spying on _sign_message rather than on a network call the flow no longer
  makes. Lookalike hosts and a scheme downgrade are refused too.
- Unbound and self-contradicting challenges fail closed: no signing block, no
  nonce, legacy message_version, a resource that does not match the response
  URL, unparseable expiry, non-numeric minimum, malformed agreement hash, and
  signing terms that disagree with the body.
- Expired challenges are refused.
- A 402 reached by a redirect off the trusted origin is refused on the origin
  check, so signing cannot follow a redirect. This replaces the old /nonce
  redirect test; the risk moved with the protocol rather than disappearing.

Also removes the last active references to the retired agent_claim_self tool
from the root README, which still advertised seven MCP tools. Six are
registered. The notes in mcp/README.md and mcp/src/server.ts stay: they record
that the tool was removed and why.

The _sign_nonce / _fetch_nonce / auth_headers stubs in client.py are left
alone — they raise on use, which is the fail-closed tombstone.

Python: 105 passed, 0 failed (was 88 passed / 5 failed).
Two CI failures from the reconciliation, both mine.

Lockfiles: I regenerated them with npm 11, which writes a tree npm 10 will not
accept — CI runs Node 20, so `npm ci` failed on both packages with "Missing:
utf-8-validate@5.0.10 from lock file". It passed locally, which is exactly how
this got through. Regenerated with npm 10 and verified by running `npm ci`
under npm 10 for both packages.

Versions: the bump gate refuses published files changing under a published
version, and it was right to. Set to 1.9.0-rc.1 and 1.6.0-rc.1 — release
candidates, so no one can mistake either for a release. New feature (AIFP-1)
plus a settlement path that now refuses where it previously succeeded; there
is a fair argument that the second makes it a major, and that call belongs to
the release step rather than to this branch.

MCP's dependency stays at ^1.8.0 rather than the candidate: the RC is not
published and must not be, so a range naming it cannot resolve. ^1.8.0 is
satisfied by the eventual 1.9.0, and CI already builds MCP against this PR's
local SDK in its own step, which is what §5.4 asks for.

Verified after regeneration: npm ci (npm 10), build, test and `npm pack
--dry-run` on both. Node 162 tests, MCP 34. Production audit unchanged —
agent 0 critical / 0 high, mcp 0 critical / 0 high.
§6.2 wants proof that the SDK encodes the entrypoint the deployed contract
actually has. The registry tests already refused v1.1/v1.2 targets, but
nothing checked the bytes.

The selector is asserted as the literal 0x894eb1f3 rather than derived from
the ABI under test — deriving it from the same ABI would pass even if that ABI
drifted away from the contract. It was read off the compiled B2BSplitterV13
artifact. The argument order is checked word by word, with merchantAmount as
its own word, which is the thing that distinguishes v1.3 from v1.2 where the
merchant's share was implied by the total.

Also refuses a quote naming a legacy function_signature, and re-asserts that a
target declaring version 1.1 or 1.2 is rejected before any calldata is built.

SPLITTER_PAY_NATIVE_ABI is exported so the test encodes the same constant the
signer uses rather than a copy that could drift.

167 tests passing (was 162).
The clean-machine check §14 asks for turned up something CI could not see.
Installing both RC tarballs into an empty project resolved a nested
@aifinpay/agent@1.8.0 under @aifinpay/mcp — the published, unremediated build —
because MCP declares ^1.8.0 and npm does not admit a prerelease into a stable
range. MCP resolves to that nested copy at runtime.

Nothing failed. The install succeeded, CI was green, and MCP was quietly
running against the SDK this candidate exists to replace.

CI missed it because the local SDK was installed AFTER the build and test
steps, so the tests ran against the registry copy and the override only
affected a final rebuild. The swap now happens first, so the tests exercise
the SDK that will actually ship.

scripts/check-mcp-sdk-pin.mjs makes the manifest itself the thing that is
checked, since that is what a consumer running plain `npm install` gets. While
the SDK is an unpublished RC the manifest cannot name it — npm ci could not
resolve it — so the check reports the blocker and exits 0, and fails hard as
soon as the SDK version becomes stable. A stable release cannot ship the
mismatch.

It deliberately does not reimplement semver. It enforces the one narrow rule
that covers this failure: while the SDK is a prerelease, MCP must name it
exactly, because that is the only form npm resolves by default. If the
versioning scheme outgrows that, add the real dependency rather than widening
the guesswork.

docs/CLEAN_MACHINE_EVIDENCE.md records the packing, secret scan, import test
and the full §14 gate table, including the gates that cannot pass yet because
v1.3, Solana v0.6 and Casper v2 are not deployed.
The SDK carried a hand-written address table. Every value in it turned out to
be correct — the chain confirmed all six addresses, both treasuries and every
fee split — but nothing checked that, and the August audit lost a day to the
SDK and a stale deployments.json disagreeing with no way to tell which was
right.

SPLITTER_DEPLOYMENTS is now built from src/generated/splitter-table.json,
vendored from AiFinPay/evm-contract where it is generated from the canonical
registry and verified there against live chain state. Everything that decides
where money goes comes from that file: address, version, runtime code hash,
treasury, fee split, validity window and whether settlement is enabled.

What stays local is what a chain cannot tell you — the viem chain object, a
default RPC, the explorer, the local USDC address and which env var carries the
native price. Those are kept in a separate CHAIN_METADATA map so the split is
visible: getting one wrong degrades the experience, getting a registry value
wrong sends money to the wrong place.

Two import-time guards: a network with no registry entry throws rather than
falling back to a hardcoded address, and a registry chainId that disagrees with
the viem chain throws rather than building a target from mismatched chains.

scripts/sync-registry.mjs vendors the canonical file and --check fails on
drift, wired in as npm run registry:check. Demonstrated by editing the vendored
polygon treasury to 0xdead… — exit 1, and re-syncing restores it. The fetch
fails closed: an unreachable source is not a pass, because silently keeping the
local copy would let the SDK drift for as long as the fetch kept failing.

Versions set to 2.0.0 for both packages as approved. This also resolves the
nested-SDK blocker from the clean-machine check: a prerelease could never
satisfy a stable range, which is why MCP was pulling @aifinpay/agent 1.8.0.
With both at 2.0.0 a clean install of the two tarballs resolves exactly one
copy of the SDK, verified. check-mcp-sdk-pin.mjs is therefore a hard gate now
rather than a report.

MCP's CI builds the SDK and installs it by path, since 2.0.0 is not published
yet and npm ci cannot resolve it. That edit is CI-only and never committed, and
the pin check runs against the committed manifest before it happens.

Node 169 tests, MCP 34, both green.
The verification line I added to the MCP job did
require('@aifinpay/agent/package.json'), which the package's exports map does
not expose, so the step failed with ERR_PACKAGE_PATH_NOT_EXPORTED and took the
build down. The diagnostic, not the thing being diagnosed.

npm ls @aifinpay/agent prints the same fact without going through exports, and
also shows when the resolution is a local link rather than a registry copy,
which is exactly what this step exists to confirm.
A stray "npm test" line was left under the restore step by an earlier edit.
YAML folded it into the same scalar, so the command ran as
"git checkout -- package.json npm test" and failed on two pathspecs that are
not files. Build, install, tsc and the tests had all passed by then; only my
cleanup step broke.

Also restores package-lock.json alongside package.json. npm install rewrote
both to point at the local SDK path, and leaving the lockfile modified would
mean the tarball verified in the next step is not the publishable one.
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.

2 participants