feat: x402 for gno.land — the mechanism, a keyless facilitator, and a payment the chain confirms - #1
Merged
Conversation
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
force-pushed
the
gno-mechanism
branch
2 times, most recently
from
August 12, 2026 16:22
b9d7506 to
a350702
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
SchemeNetworkServergo/mechanisms/gno/exact/server//verify,/settle,/supported, holds no keysgo/cmd/gnofacilitator/,go/facilitator/ghcr.io/gnoverse/gnofacilitatorSchemeNetworkClientjs/src/exact/client.tsGo module
github.com/gnoverse/x402;go/e2eis a second module sogo test ./...never starts achain. Sources split by language then by mechanism role, mirroring
x402-foundation/x402.Protocol decisions
exactscheme,authorizationflow (verify → resource → settle). That flow is thespecification's default and may be omitted, so no
paymentFlowkey is emitted.gno:<chain-id>as the CAIP-2 network. A chain-id containing:is rejected — CAIP-2 namessplit into exactly two parts.
Amounts are validated with the chain's own
std.ParseCoins.extra.areFeesSponsored: false. gno has no fee delegation — the payer pays the network feeinside the transaction it signs, and the facilitator only broadcasts.
extra.memofor payment references, following the shipped SVM mechanism: same key, same256-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.
transaction: base64 of a fully signed, unbroadcaststd.Txcarrying a single
bank.MsgSend.Testing
make testthe libraries,make js-testthe client,make test-e2eone real payment — an in-memorygno node via gno.land's txtar harness, the facilitator, a priced endpoint, and a stock
@x402/fetchclient, 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
git tag.
extensions.provider, so paying a second gno chain means connecting to a node on it.