Skip to content

feat: x402 for gno.land — the mechanism, a keyless facilitator, and a payment the chain confirms - #1

Merged
gfanton merged 30 commits into
mainfrom
gno-mechanism
Aug 12, 2026
Merged

feat: x402 for gno.land — the mechanism, a keyless facilitator, and a payment the chain confirms#1
gfanton merged 30 commits into
mainfrom
gno-mechanism

Conversation

@gfanton

@gfanton gfanton commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Initializes the repository: gno.land as an x402 payment mechanism.

x402 is the payment protocol behind HTTP 402. The protocol, route matching, header encoding and the
retry come from the ecosystem's own middleware; this supplies the three chain-specific parts.

Component Path Consumed as
Seller scheme — upstream's SchemeNetworkServer go/mechanisms/gno/exact/server/ Go library
Facilitator — /verify, /settle, /supported, holds no keys go/cmd/gnofacilitator/, go/facilitator/ binary + ghcr.io/gnoverse/gnofacilitator
Client mechanism — SchemeNetworkClient js/src/exact/client.ts TypeScript, built from source

Go module github.com/gnoverse/x402; go/e2e is a second module so go test ./... never starts a
chain. Sources split by language then by mechanism role, mirroring x402-foundation/x402.

Protocol decisions

  • exact scheme, authorization flow (verify → resource → settle). That flow is the
    specification's default and may be omitted, so no paymentFlow key is emitted.
  • gno:<chain-id> as the CAIP-2 network. A chain-id containing : is rejected — CAIP-2 names
    split into exactly two parts.
  • Prices in native units. A dollar amount errors rather than resolving: gno has no default asset.
    Amounts are validated with the chain's own std.ParseCoins.
  • extra.areFeesSponsored: false. gno has no fee delegation — the payer pays the network fee
    inside the transaction it signs, and the facilitator only broadcasts.
  • extra.memo for payment references, following the shipped SVM mechanism: same key, same
    256-byte cap, same purpose. XRPL binds invoices through a dedicated ledger field instead and
    forbids memos for it; tm2 signs a memo as part of the transaction, so SVM's approach is the one
    that maps.
  • The payload is one opaque field, transaction: base64 of a fully signed, unbroadcast std.Tx
    carrying a single bank.MsgSend.

Testing

make test the libraries, make js-test the client, make test-e2e one real payment — an in-memory
gno node via gno.land's txtar harness, the facilitator, a priced endpoint, and a stock @x402/fetch
client, asserting the seller's on-chain balance. Scenarios are txtar files carrying their own seller
config, so adding one is a new file.

Not done

  • Unaudited, and no gno scheme document is submitted upstream.
  • The client mechanism has no registry release; no CI job publishes it, and its version is not tied to the
    git tag.
  • Dollar pricing, a browser paywall, payload extensions.
  • The client signs for the chain an offer names, but still reads the account sequence from the wallet's
    provider, so paying a second gno chain means connecting to a node on it.

gfanton and others added 15 commits August 12, 2026 17:30
Wire types and header codec, static verification, signature and sequence checks
against the signer's on-chain account, a keyless facilitator with a per-peer
throttle, the seller's RequirePayment middleware and its optional on-chain
settlement confirmation, and a gnoclient adapter for both roles.

js/ is the buyer half in the other language: mechanism.mjs is a SchemeNetworkClient
for @x402/core, so a stock @x402/fetch client does every protocol step unmodified;
buy-usdc.mjs is the other direction, a stock @x402/evm buyer holding neither GNOT
nor ETH paying a gno seller. The lockfile is committed — an unpinned dependency
tree is how a buyer starts failing for reasons that are not ours.

Co-Authored-By: Claude <noreply@anthropic.com>
Signing a gno payment reads the chain id from /status, whose decode runs the
validator's address through @cosmjs/encoding's fromBech32. That function has
always defaulted its length limit to Infinity — the usual way to say "no limit".
@scure/base 2.3.0 began rejecting it, since Number.isSafeInteger(Infinity) is
false, from inside the ^2.0.0 range the encoder asks for.

So every buyer failed with "limit: expected safe integer, got Infinity" before it
signed anything. Nothing on our side changed; a transitive minor did.

There is no forward version to move to: 0.39.0 of the encoder still passes
Infinity. 2.2.0 is the last release that accepts it.

This is what the committed lockfile is for — an unpinned tree resolves a working
buyer and a broken one from the same package.json.

Co-Authored-By: Claude <noreply@anthropic.com>
A CAIP-2 network name splits into exactly two parts, and every x402 client
splits it that way to choose the mechanism that handles the payment. Reading a
chain-id out of "gno:test:14" produced "test:14" without complaint, so the
module would verify and settle against a network name no conformant client can
parse, register, or address.

The form is settled as gno:<chain-id> and it is now enforced where it matters:
at the settle-time parse, which reads the network out of the requirements the
seller publishes rather than from a second configuration field. Deciding this
before publishing is the point — the string is the client-side registry key and
it travels inside every signed accepted object, so a later change invalidates
third-party registrations.
…ware

The seller snippet from x402.org's front page now works for gno. A route lists
gno in accepts[] and the canonical middleware prices it, offers it and reads the
payment — no gno-specific code in the seller's hands beyond the network string
and one registered scheme.

This is a mechanism inside x402 rather than a second implementation of it. The
module reimplemented the wire types, the seller middleware and the facilitator,
and required neither the upstream SDK nor @x402/core, so nothing about gno was
installable or discoverable by anyone already speaking the protocol. ParsePrice
and EnhancePaymentRequirements are the two seams upstream reserves for a chain,
and they are enough.

The mechanism states the two things a buyer cannot infer. Fees are declared
unsponsored, because the payer pays the network fee inside the transaction they
sign and the facilitator holds no key — XRPL ships the identical model and makes
the flag mandatory. No paymentFlow key is emitted: the ordering is
authorization, the specification's default, which may be omitted.

Prices are named in their denomination. gno has no default asset, so a dollar
amount resolves to nothing and is refused rather than priced in a token nobody
agreed on; a fractional or unparsable amount is refused for the same reason,
since a coin holds whole units. Both the struct a seller writes in Go and the
map a decoded route document carries resolve to one price — upstream's own EVM
mechanism handles only the map, so the struct form would have failed silently.

The test that stands up the snippet verbatim is the claim. It asserts on the
PAYMENT-REQUIRED header, where v2 puts the requirements, and its node panics on
every method: an unpaid request must be answered without touching the chain.
The flow is verify, then the resource, then settle — the specification's default,
and the reason for adopting the ecosystem's middleware instead of reordering our
own. A property that arrives for free is still a property the mechanism depends
on, so it is asserted here.

The second test is the one that matters. authorization moves the exposure onto
the seller, and what bounds it is that a resource which failed is never charged
for: the middleware buffers the response and settles only on success. Without
that the buyer pays for an error, which is the single loss the specification
gives them no way to undo — "refund" appears nowhere in it.

The first test is the control for the second: same seller, same payment, same
facilitator, and only the resource's status differs, so settle disappearing is
the handler's doing rather than a payment that never worked.

The facilitator is a recorder, not a chain. When the middleware verifies and
when it settles is the whole claim; whether a transaction satisfies gno is what
the facilitator's own tests already cover.
The mechanism's test proves the canonical snippet works, but a test is not
something a stranger can run. This is the same seller as a program: three
processes, a documented command line, and a 402 you can look at.

It was run end to end before being committed. Against a facilitator on a dev
chain it answers 402 with the terms in the PAYMENT-REQUIRED header — ugnot,
250000, the seller's address, and extra.areFeesSponsored false with no
paymentFlow key. The facilitator is the one that talks to the chain; the seller
never does.

The price is parsed by the chain's own coin parser, so -price takes whatever
gnokey would and this example invents no second syntax for an amount.

Both settlement seams are wired, because they are the part of the authorization
flow a seller has to understand: SettlementHandler is where you learn a payment
landed, ErrorHandler is where you learn it did not. The README states what that
second case costs, which is less than it sounds — the middleware buffers the
resource and discards it when settlement fails, so a failure costs the work
already done rather than the data.

It also states what is missing: no Go client mechanism exists yet, so paying
this needs the JS buyer.
The gno mechanism becomes @gnoverse/x402-gno, written in TypeScript, and stops
being a private file a buyer had to copy. A stranger installs it and adds one
line to a stock client.

Publishing was blocked by an npm overrides entry pinning @scure/base, and
overrides are honoured only in the root project — shipping it would have handed
every consumer a bech32 failure with no fix reachable in their own graph. The
entry is gone, and not by a packaging trick: the pin existed because signing read
/status for the chain id, and /status is the one response that decodes a
validator's bech32 address. fromBech32 appears in exactly two functions in
tm2-rpc and both are validator decoders, so a mechanism that never asks the node
which chain it is never reaches it.

Which is the behaviour worth having regardless. The chain id was already parsed
out of the network string and then used only to reject non-gno networks, while
Wallet.signTransaction took the real one from whatever node the wallet pointed
at — so a buyer could not pay a chain it was not already configured for, which
defeats the purpose of naming the chain in an offer. Registering "gno:*" now
means it.

Reimplementing a sign doc is how every payment quietly becomes invalid, so the
first test written was the one that holds ours byte-identical to the wallet's for
the same inputs. It caught the trap it was written for: the account fields are
quoted in the sign doc, so the raw strings the chain returned are used rather
than the numbers the wallet's public accessors would have given.

TypeScript earns its place beyond types on the npm page. ExactGnoScheme now
declares `implements SchemeNetworkClient`, so the compiler holds it to the
interface @x402/core actually publishes — the guarantee the Go half already had
from a var _ assertion, and the one thing JSDoc could never provide. @x402/core
is a peer dependency: only its types are used, and a mechanism must not drag in a
second copy of the client its consumer already has.

buy.mjs imports the package by name rather than by path, so a broken exports map
fails in the repo instead of after publishing. make js builds as well as
installs, because that import now resolves through dist/, and make js-test runs
the mechanism's own tests — a safety net nothing runs is not a safety net.
The weather example's README told the reader to pay it with js/buy.mjs, which
hardcoded POST while the route is GET /weather — so the documented pair could not
have worked. The method now defaults to GET, which is the ordinary case for a
paid resource, and an override covers a resource that wants something else.

The README also now says which leg has never been run: the payment itself needs a
funded account on a live chain, and this module has no in-process node to stand
one up.
The claim was assembled but never joined. The seller emitted a conformant 402,
the ordering was asserted, a JS-signed payment satisfied every static rule, and
the buyer's sign doc matched the wallet's — and not one of those could say a
payment happened, because that needs a chain.

    gnokey query bank/balances/$seller_user_addr
    stdout '1000250000ugnot'

gno.land's txtar harness turns out to work from outside the gno repo, which is
what made this cheap. gnoenv falls back to `go list -m` and resolves GNOROOT to
the module cache, the genesis files ship in the module zip, and
SetupGnolandTestscript wraps a caller's Setup and merges a caller's Cmds rather
than replacing either. So a real in-memory node, a funded account and its
mnemonic, and node on PATH are all available to a script in three seconds. The
default account's seed is the mnemonic the JS buyer already used.

Two commands are added to that harness: one stands the facilitator up against the
running node, one guards an ordinary HTTP resource with the ecosystem's own
middleware and the gno mechanism. Both run in-process and publish their URL into
the script, so there are no ports to guess and nothing to reap. The seller is
given its own account, so a payment is a balance change rather than test1 paying
itself.

The harness is a separate module deliberately. gno.land/pkg/integration calls
itself experimental and the gno pin here is a master pseudo-version, so a break
in it must not be able to block publishing the library, and it can bump gno
without changing what the library compiles against. A nested module is excluded
from the parent's ./... , which also means the root test run never starts a chain
and no build tag is needed to keep it out.
The repo is about to stand on its own, so `make help` should say what the layers
are rather than leaving a reader to infer them from filenames: test is the
library with no chain and no npm, js-test is the buyer mechanism, and test-e2e is
one real payment through a real node.

build stays phony rather than a file target with a hand-listed prerequisite set —
Go's build cache already decides what to recompile, and a manual source list goes
stale. clean spares node_modules, which is a fetched dependency rather than an
artifact and expensive to refetch.

.DELETE_ON_ERROR is the one preamble line added, because a failed `go build -o`
must not leave a partial binary a later run mistakes for finished work.
…ld it is

The facilitator could not report its own version, so an operator debugging a
refused settlement had no way to know which binary refused it. main.version
defaults to "dev" in source and is stamped by -ldflags, so an unstamped build is
recognisable rather than silently claiming to be a release. -version answers
before the required flags are enforced — asking a binary what it is must not
require knowing how to run it — and the listening line carries it too, because
that is where an operator actually looks.

CI runs four jobs, following gnomcp's shape: pull_request rather than
pull_request_target, a read-only token, no secrets anywhere in the file, and every
action pinned by digest.

  go              tests, vet, gofmt
  js              npm ci, the TypeScript 7 typecheck, the mechanism's tests
  e2e             one real payment through a real node
  release-config  goreleaser check, then the whole matrix as a snapshot

Two of those exist because a module boundary hides things. e2e/ is its own module,
so the root ./... never reaches it and it would rot unnoticed — it is vetted
explicitly and then actually run, on every pull request, because it takes seconds
and it is the only thing that proves a payment settles. The network-tagged tests
are compile-checked for the same reason.

release-config is the one that pays for itself. It builds the release matrix as a
snapshot and then asserts the image digest is extractable from artifacts.json in
exactly the shape the release workflow's attestation reads, so a goreleaser rename
breaks a pull request instead of a release.

The release itself is manual, one component, plain semver. It runs the tests and
the end-to-end payment before tagging, and every step that can fail runs before
the tag is pushed, so a setup failure cannot strand a tag. Archives and the image
digest are both attested.

Not verified locally: goreleaser is not installed here, so `goreleaser check` has
never run against this config. Every changelog group carries its own regexp rather
than relying on catch-all behaviour I could not confirm.
The README was a topaz-1 demo runbook. Its first command built ./cmd/gnowars, a
binary this module does not contain; every ADR link pointed outside the module and
would have 404'd from a standalone repository; and it walked a chain that has since
been sunset. Anyone shown the repo read that first and concluded it was broken.

What replaces it leads with the thing that works: a seller lists gno in a route's
accepts[], and a buyer adds one register() call. Both snippets are the real ones,
and the 402 shown underneath is copied from a run rather than illustrated.

It also states what this is NOT, because that is the part people get wrong: a
mechanism inside x402, not a second implementation of it. Route matching, the 402,
the header encoding and the retry are the ecosystem's own middleware.

The fee section is the honest half. The payer pays the network fee, the facilitator
holds no key, a failed resource is never charged for, a failed settlement withholds
the response — and the sequence race between verify and settle is written down as a
risk accepted rather than left for a reader to discover.

Apache-2.0 is added as a file, matching gnomcp, so the licence the image labels and
the package declares is one the repository actually carries. The npm package gets
its own copy, because npm bundles only a LICENSE found beside the package.

Two smaller honesty fixes: engines names the floor a CONSUMER needs, derived from
the ES2023 emit target and the fact that neither runtime dependency constrains
anything, and it says so rather than borrowing the build toolchain's newer floor.
And js/README no longer opens with an install command for a package that is not
published.
Twenty Go files sat at the root of a repository whose other half is TypeScript,
which made js/ read as an afterthought. x402-foundation/x402 solves this by
language directory — go/ typescript/ python/ java/ — so this follows the layout of
the ecosystem it is a mechanism in. Neither language owns the root.

Nothing about the Go package structure changed. The module path moves from
github.com/gnoverse/x402 to github.com/gnoverse/x402/go, which is free while
nothing is published and awkward afterwards, and that asymmetry is the reason to
do it now rather than later.

Splitting the facilitator into its own package was considered and deferred to the
task that decides whether the seller middleware survives. The split cannot be done
cleanly before then: the sixteen Reason constants live in verify.go but are
consumed by both halves, and PaymentOption lives in types.go but is used only by
the seller, so it is identifier surgery on two files rather than a move — and the
right shape depends on whether there is still a seller half here at all. Package
boundaries stay cheap to add later; the module path does not.

THE MOVE BROKE THE END-TO-END TEST AND THE TEST SAID NOTHING. Its buyer lookup
walked one level up to find js/, which after the move pointed at go/js/, so it
skipped — and a skip reads as a pass. buy.mjs is committed, so its absence can
only mean the path is wrong: it now FAILS on a missing buyer and skips only when
the built output is missing, which is the one case where a Go-only checkout should
not be blocked on npm.
Cuts the framing that argued against a comparison a reader does not have, and
the editorial asides about which test matters. What is left is the seller
snippet, the 402 it emits, the layout, the payment model, and the accepted
sequence risk — each of which can be checked against the code.
go.mod and package.json move up out of the language directories. Tooling looks
for them at the root and quietly degrades when they are elsewhere: setup-go's
cache reported "Dependencies file is not found ... Supported file pattern: go.mod"
on all three Go jobs of the first CI run, so no Go module cache was restored, and
dependabot and pkg.go.dev look in the same place.

Go import paths do not change. The module is github.com/gnoverse/x402 with its
go.mod at the root, which makes the package directory go/ resolve to
github.com/gnoverse/x402/go — exactly what it resolved to when the go.mod lived
inside it. Only the module line and go/e2e's require and replace moved.

Sources stay split by language. What the root gains is the two files that say
which languages those are.

The npm package now takes the repository's own LICENSE and README, since npm
bundles those from beside the manifest — so js/LICENSE, which existed only
because the manifest used to live in js/, is gone rather than duplicated.

goreleaser no longer needs builds[].dir, and CI no longer needs
working-directory on any Go or npm step.
@gfanton
gfanton force-pushed the gno-mechanism branch 2 times, most recently from b9d7506 to a350702 Compare August 12, 2026 16:22
gfanton added 13 commits August 12, 2026 20:28
An x402 mechanism has three roles — server, facilitator, client — and the
ecosystem names them identically in both languages: @x402/evm publishes
./exact/server, ./exact/facilitator and ./exact/client, and its Go module has
the matching directories. This repo filled one cell of that grid and scattered
another across a flat root package.

The Go side splits in two. go/facilitator holds what the facilitator is —
verification, the signer's account, the chain adapter, the rate limit and the
/verify /settle /supported service — and go/ itself now holds no package, so no
import path ends in /go and nothing has to alias x402 against upstream's. The
stutter goes with it: facilitator.New returns a *facilitator.Server, and
FacilitatorRequest is Request.

The JS side publishes @gnoverse/x402-gno/exact/client rather than the bare
package root, so a buyer arriving from @x402/evm/exact/client reaches the same
way for gno.

The homegrown seller middleware goes. RequirePayment and the on-chain Confirmer
were written before this built on upstream's SDK, and nothing shipped imports
them — the README, the weather example and the e2e all use nethttpmw.X402Payment.
Keeping both left a seller to guess which was real, and ours settles before
running the handler, which charges for a resource that then fails. That is the
ordering the authorization flow exists to avoid, and TestAResourceThatFailedIsNotCharged
already asserts against it.

Its wire types went with it: the header constants, the header codec,
PaymentOption and PaymentRequired are all things a seller emits, and upstream's
middleware emits them now. What survives is what crosses the facilitator wire.
The conformance suite keeps checking the spec's own fixtures — the accepts[]
entry against PaymentRequirements, which is half of every /verify and /settle
body.

Also drops the USDC canvas scripts, which belong to a different demo.

Verified: go test ./..., npm test, tsc, make lint, and make test-e2e — one real
payment, JS buyer through the moved facilitator to a real in-memory node, 3.0s.
A signature carrying multisig.PubKeyMultisigThreshold reached
VerifyBytes whenever its subkeys numbered one, because std.CountSubKeys
sums subkeys and answers 1 for that shape — the same as an ordinary key.

The threshold itself is the problem. NewPubKeyMultisigThreshold panics on
a threshold of zero, but amino decodes the struct whatever its fields
say, and VerifyBytes bounds the signature list against that threshold
before indexing it once per set bit of the bit array. A zero threshold
therefore admits an empty list, and the first set bit indexes out of
range: an unauthenticated POST /verify or /settle panicked the handler.
Reaching it needs an account that stores no key, which is what a first
credit to an address leaves behind.

Refuse the type, not the count. This scheme's payment is one signature
from one signer, so the single-subkey threshold key is the only shape a
count would have admitted, and the key types that remain verify without
indexing anything the payload sizes.
handleSettle branched on err != nil alone and published
invalid_exact_gno_transaction_failed for every broadcast failure, without
the chainRefused test it already applies to its two chain reads.

Upstream errors BroadcastTxCommit both for a transaction CheckTx rejected
and for one it timed out waiting to commit, and on the timeout path the
transaction sits in the mempool and will commit. The two arrive
indistinguishable except by the chain's own abci.Error, so a definitive
"this payment failed" over the second tells the seller to withhold and
discard the response body while the payer's funds move anyway. Route
anything without an abci.Error to the 503 this package already reserves
for an unknown outcome.

Broadcast now also carries the result upstream returns alongside such an
error. A delivery the chain committed and then aborted charged the payer
its fee, and that transaction's hash is the only record the charge can be
reconciled against; it reaches the log, while the wire answer keeps the
spec's empty transaction.

TestFacilitator_SettleBroadcastFailure asserted the old behavior with a
"connection refused" error — the transport case, which is exactly the one
that must not publish a verdict. It now uses an ABCI error, so it covers
the refusal it names, and the transport case has a test of its own.
tsdown and goreleaser both owned the repository root's dist/. tsdown
empties it (clean: true) and writes client.mjs; goreleaser writes
artifacts.json there and both CI workflows read it. Each run therefore
destroyed the other's output, and the failure was silent in the direction
that matters: with a goreleaser-shaped dist/ in place, the payment test
found no client and t.Skipf'd, so the only test proving a payment settles
reported PASS without settling one.

The emit moves to js/dist, beside the sources, and the make target names
the emitted client rather than the directory holding it. A directory's
mtime says nothing about whether a build finished, and .DELETE_ON_ERROR:
cannot rescue one either — it unlinks its target, which fails on a
directory — so a half-run build was indistinguishable from a finished one.

node_modules now also depends on package-lock.json, so a lockfile-only
change reinstalls instead of leaving the local tree behind what CI's
npm ci resolves.

Verified: goreleaser release --snapshot --clean leaves js/dist intact,
make test-e2e settles a real payment in 2.9s with a goreleaser dist/
present, and npm pack still ships the client (5 files, 10.1 kB).
The release workflow attests dist/checksums.txt, which goreleaser never
wrote: with no checksum block its default name template embeds the
project name and version, and this module's name resolves the project to
x402, so the file was x402_<version>_checksums.txt.

The step's placement made that unrecoverable rather than merely broken.
It runs after the tag push, because attesting needs the artifacts, so a
release ended up tagged and published with no archive provenance — and
the version guard then refuses to re-run for that tag.

Pinning the name in goreleaser rather than templating the path in the
workflow keeps one literal filename on both sides.

Verified with goreleaser 2.17.1: check validates the config, and
release --snapshot --clean writes dist/checksums.txt.
The mechanism's whole validation surface was untested: every JS test
targeted signForChain, so none of the six refusals the README advertises
was asserted, and neither was gnoChainId.

gnoChainId matters most. Dropping the seller middleware left it as the
only implementation of the CAIP-2 two-part rule in the repository — the Go
half concatenates "gno:" onto a configured chain id and string-compares
the result, so nothing there splits a network string. A chain id holding a
colon has to be refused, or one network string names two offers.

The wallet is left unconnected on purpose. Every case here is refused
before a key is used, so asserting the mechanism's own message rules out a
test that passes because the wallet failed first.

signForChain now names the missing connection instead of dereferencing an
absent provider: getProvider is the one wallet accessor that does not
check, so an unconnected wallet produced a bare TypeError that an x402
client relabels as a failure to create a payload. That message is what
lets the well-formed-offer case assert it reached signing.

Validated by mutation: breaking the two-part rule, the amount pattern, the
asset check and the memo type check fails 9 of these tests between them.
"One client pays gno:test14 and gno:dev without being reconfigured" was
not true. The chain id comes from the offer, but the account number and
sequence still come from the wallet's connected provider — only a chain
holds the next sequence for an account — so a wallet pointed at one chain
signing for another produces a sign doc the paid chain's ante cannot
reproduce, and the facilitator answers signature_invalid.

The offer decides which chain a signature commits to, not which node
supplies the account state it commits to. Say that instead, in the
package's own README, its description, the root README's summary line, and
the two comments that made the wider claim.

The test that covers this carried the overclaim as its rationale, and it
cannot detect the coupling: its provider answers with one account number
and sequence for every chain. That limit is now written next to it.
… rules

Four gaps where something published or compared was never checked against
the grammar that governs it.

A price passed a whole-number test, but the amount and asset are
concatenated into a coin string and matched with std.ParseCoins. That
grammar is narrower in both halves — a denomination is lower-case and at
least three characters, an amount carries no sign — so UGNOT, gn and
+250000 all published routes no payment could satisfy, and each refusal
arrived as an amount mismatch, blaming the payer for the seller's typo.
checkPayable now parses what the facilitator parses.

acceptsSameOffer compared the three fields that name a price and ignored
the payload's scheme and network, which are only ever checked on the
requirements. A payload could agree about the price while naming another
scheme or chain; upstream compares all five, and so does this now.

network() concatenated an unvalidated chain id, so a colon-bearing one
served a network string reading as three CAIP-2 parts — refused by
upstream's parser and by the JS buyer, with no refusal naming the flag.
ValidChainID states the rule beside the name it governs, and the command
checks it as it parses the flag. New keeps its signature: one caller
constructs a Server outside tests, and that caller is the one place a
chain id enters the process.

The settle refusal dropped the payer while the broadcast-failure answer
beside it reported one, and the spec's own failure fixture carries the key.
It is omitempty, so a refusal before the transaction decodes still names
nobody.

The command's three exit-2 messages also move to slog, which every other
diagnostic in the file already used.
…ller

The 256-byte cap on extra.memo had no owner. The facilitator enforced it
on every payment, the seller passed any memo through untouched, and the
number appeared only as an unexported constant the seller could not see.

A seller setting an over-cap or non-string memo therefore published a route
whose every payment was refused as invalid_payment_requirements. The code
is right — the requirements are the seller's — but it arrives once per
buyer, and never at the seller, who is the only party that can fix it.

MaxMemoBytes is exported so both halves name the same number instead of
repeating it, and EnhancePaymentRequirements refuses a memo the mechanism
will not carry while the requirements are still the seller's to correct.
The cap counts bytes on both sides, so a multibyte memo under 256
characters and over 256 bytes is refused by each.
The integration layer had nothing in it. Deleting the seller middleware
took the repository's only //go:build integration file with it, so
`make test-integration` ran exactly what `make test` runs while its help
line promised tests that need a network, and CI's tagged vet compiled
nothing extra. The e2e covers that layer for real, against a node, so the
target and its CI step go rather than gain a test invented to justify them.

go.mod recorded x402-foundation/x402/go/v2 as indirect while the seller
mechanism imports it directly. `go mod tidy -diff` now runs in CI, which
nothing else would have caught: the manifest drifting from the imports
still builds.

@x402/evm and viem were devDependencies with no importer — both are cited
in prose as a layout precedent, which needs no install — so npm ci was
fetching two unreachable trees. Twelve packages leave the lockfile.

buy.mjs imported @gnolang/tm2-rpc, which appears in no manifest and
resolved only because npm hoisted it out of a declared package, and passed
its client to a protected constructor. GnoJSONRPCProvider.create is the
public factory for exactly this and removes both problems.

`make install` was the one build path not stamping the version, so an
installed binary reported dev while make build and goreleaser both stamped.

Verified: npm ci clean-installs, 24 JS tests pass, go mod tidy -diff is
clean, and make test-e2e settles a real payment through the rewritten buyer.
chainIDCheckWindow bounded the retry loop but not a single query that
never returns, so a node accepting the connection and then going silent
held main() open before anything was listening. A context deadline cannot
fix it: tm2's RPC transport builds its request with http.NewRequest, gives
its http.Client no timeout, and discards the context in its own dialer. A
watchdog around the query bounds the whole check instead.

Three comments claimed more than the code does:

sweepInterval said a flood could not turn the sweep into a full-map scan
per request. Reaching the bucket cap forces a sweep too, exactly as
sweepFull's own comment says, so at the cap it can — a deliberate trade
that does not persist, since a bucket refills in about a tenth of a second.

The interop fixture is a frozen sample, so it detects the Go side moving
away from bytes a JS buyer once produced, not the JS SDK moving. Its
manifest also claimed exact versions while pinning only the two SDKs and
ignoring its lockfile; make test-e2e is what catches live drift, by signing
with the installed client against a real node.

The fixture's regeneration script cd'd to the script's filename when
invoked as `bash regen.sh`, and redirected node's output straight at the
committed fixture, truncating it before node ran. ShellCheck clean.

interop_test.go pointed a failure at `go generate ./x402`, a package that
does not exist; it is ./go/facilitator.
The decoded PAYMENT-REQUIRED block in both READMEs omitted two keys the
middleware actually emits: the envelope's error string, which the spec's own
fixture carries, and the resource's mimeType, which the weather example sets
itself. The blocks now match a header captured from the canonical seller
test, and that test asserts both keys, so the documents cannot drift from
the wire in silence again.

The weather example said its paid loop had not been run end to end. go/e2e
runs that exact configuration — same middleware, same accepts entry, same
price, same buyer — against an in-process node on every pull request. What
is genuinely unrun is this page's own by-hand sequence against a live chain,
which is what the caveat now says.

The root README told a reader to build the client package "from js/", which
has held no manifest since both moved to the repository root.

package.json gains a repository field: npm resolves the package page's
relative links against it, and the page is the root README, not js/README.md.
The archive half of the format was unused: the script named x402seller and
nothing else, while the route, the price, the description, the mime type and
the response body were hardcoded in pay_test.go. Reading the script you saw
a balance move by 250000 with the price nowhere in the file, and could not
tell what was being sold.

The seller is a fixture, not code — it serves a fixed body with a content
type — so it becomes data. seller.json travels inside the script and
x402seller reads it: one route string patterns both the mux and the priced
routes, so the resource served and the resource charged for cannot drift,
and the method reaches the buyer from the same string. A scenario is now one
readable file.

The middleware wiring stays in Go, because that is what this test exercises.
The network stays too: it is the chain the harness started, and a value in
the file could only disagree with it. The buyer stays js/buy.mjs on disk —
it is the shipped example a reader runs, imported by package name so a
broken exports map fails here, and a copy in the archive would drift from it.

Verified by mutating the archive: changing the price fails the on-chain
assertion, changing the forecast fails the served-body assertion, and a
stray key fails the decode. A key differing only in case still binds, since
encoding/json matches tags case-insensitively, so the required fields are
checked by value rather than left to DisallowUnknownFields.
…voice

Two scenarios, each a single file, for the two things the payment flow
promises that only a chain can confirm.

no_charge_when_the_resource_fails: a resource server reselling an upstream
provider answers 503, and the seller's balance does not move. The exact
scheme runs the authorization flow — verify, resource, settle — so a
handler answering 400 or above cancels the settlement and the signed
transaction is never broadcast. Upstream ships no example covering this;
its coverage is unit tests asserting the cancellation hook fired and that
settlement was not called, which cannot see whether money moved.

pay_binds_the_invoice_memo: the offer names extra.memo and the settlement
proves the client signed it in, because the facilitator refuses a mismatch.

extra.memo turns out not to be a gno invention, which is worth recording
where the cap is declared: the shipped SVM mechanism defines the same key
for the same stated purpose — a payment reference such as an invoice id, so
reconciliation needs no unique deposit address per payer — and caps it at
the same 256 bytes, while its refusal code
invalid_exact_solana_payload_memo_mismatch is the exact parallel of ours.
Canton uses the key too. gno differs only in the carrier: tm2 signs a memo
as part of the transaction, so there is no separate instruction to attach.
The comment on MaxMemoBytes now says that, rather than calling 256 this
repository's own policy.

sellerOffer gains status and memo, so both scenarios are txtar files with
no Go beyond that.

Verified by mutation, since both passed on first run: turning the 503 into
a 200 fails the unchanged-balance assertion, and a client rebuilt to
corrupt a bound memo fails only the invoice scenario, with the memo
mismatch its own comment predicts.
@gfanton gfanton self-assigned this Aug 12, 2026
js/README.md led with `npm install @gnoverse/x402-gno` under a note
admitting the install does not work — the worst of both, a command that
fails plus a disclaimer. The docs now describe the only way to get the
client mechanism today: build it from the repository with make js.

Usage snippets import the built path, and js/README.md records once that
the same import resolves through the manifest's exports map inside this
repository. That is what buy.mjs relies on, so the map stays exercised by
the payment test rather than only by a future install.

The name survives in package.json, which is the package's identity, and in
buy.mjs, which imports by it on purpose.

Also corrects a claim this pass missed earlier: the root README still said
one client pays gno:test14 and gno:dev without reconfiguration. The offer
decides which chain a signature covers, not which node supplies the account
state it covers.
@gfanton
gfanton merged commit 2c49a31 into main Aug 12, 2026
8 checks passed
@gfanton
gfanton deleted the gno-mechanism branch August 12, 2026 21:30
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.

1 participant