Repository navigation
Conversation
The proposal says no Rust x402 crate supports Stellar. r402 0.21.0 does, and a probe on testnet shows it works with our facilitator and that our catalog accepts its listing. Part 1 stands alone all the same (akot); r402 stays a reference to test against.
packages/sdk/rust, crate stellar-x402: a seller with a Rust service answers 402 with Stellar terms and settles through a facilitator. - The 402 answer, held equal to what @x402/express 2.28.0 sends. - The buyer's payment, matched to the terms by upstream's rule. - A client of /supported, /verify and /settle, with an API key. - The bazaar listing for query and JSON-body routes. - An axum layer (feature axum, on by default): verify, run the route, settle only when the route answers without an error. - examples/weather.rs, the twin of examples/http-api. Beyond upstream's middleware: a refusal carries the facilitator's message, and the seller learns whether the catalog took its listing. CI job sdk-rust builds with and without the layer and checks Rust 1.81.
Two payments settled on testnet through our facilitator, paid by upstream's unmodified client; the hashes are for 0132 (D5). What part 2 (0140) still has to build is listed.
Job sdk-rust passed on PR #177, with and without the axum layer and on Rust 1.81.
… price Debug output of Facilitator printed the API key, and debug output ends up in logs. Price::token took zero and amounts above i128, which Price::usdc refuses; it now returns a Result and refuses them the same way. The root README lists packages/sdk/rust.
The first run had the seller on localhost, which the catalog refuses. Behind a public address the same seller is paid and listed.
karczuRF
left a comment
There was a problem hiding this comment.
The crate does what 0136 asks: I found no acceptance criterion contradicted, and the 402, the matching rule and the bazaar block agree with upstream 2.28.0 where I compared them. Three comments should be settled before the merge: 1 (the layer answers methods the route does not have), 2 (a failed route is tested for 400 only) and 3 (a buyer's malformed payment reads as a facilitator outage). The rest are small.
Your questions for me (0132):
- D5's evidence: confirmed, all three hashes. I looked them up on testnet Horizon:
e1ce215f…(13:08:03 UTC),b831d67e…(13:08:13) anddd6113e2…(15:02:49), each successful, 0.001 USDC toGDPB…AIH3, the fee paid byGAVE5V…KKRP, the signer our/supportedlists. The ask above says "two testnet hashes" and Testing lists three; 0132 will name all three. webpki-rootsunder CDLA-Permissive-2.0: accepted. It is the CA root list, under a permissive data licence with no copyleft. The Tranche #2 audit gets a one-line note for it, as M1's has for BlueOak-1.0.0 and Unlicense.
One thing for 0140, with a line in this PR:
- A buyer who disconnects. Verify, the route and settle run inside the request's future (
src/axum.rs:246-255), and hyper drops it when the connection closes. Dropped during/settle, the facilitator may still move the money andon_eventnever hears of it; dropped after the route ran, the route's work is never paid. Upstream's express middleware keeps running. The fix (settle on its own task) addstokioto the layer, so it is part 2's. Here: one README sentence, and a line under "Left for part 2". I have not shown this with a run.
Also for 0140, nothing to do here: events carry no route, so a seller with two paid routes cannot tell them apart in on_event; the facilitator's Retry-After (429, 503) is dropped; a muxed payTo is accepted as terms, and no run shows a payment to one settles.
I read the code at 0fa95782 and checked it against axum 0.8.9's source, @x402/core 2.28.0 and our app.ts. I ran no cargo command and no test; the three hashes are the only thing I checked outside the repo.
| // Asks the facilitator's catalog (the Bazaar) to list the route. | ||
| .listing(Listing::query(json!({ "city": "Warsaw" }))); | ||
|
|
||
| let app = Router::new().route("/weather", get(|| async { "14 °C" }).layer(paid)); |
There was a problem hiding this comment.
1. get(handler).layer(paid) also wraps the method router's "method not allowed" fallback (axum 0.8.9, method_routing.rs:990: fallback: self.fallback.map(layer_fn)). So an OPTIONS preflight or a POST without a payment gets 402 and not 405, with a bazaar block that names that method beside queryParams; a browser buyer's preflight fails; and a paid POST costs a /verify call before its 405. route_layer(paid) leaves the fallback alone. The same line is in src/axum.rs:16, examples/weather.rs:99 and README.md:94. A test for a method the route does not have would hold it.
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn a_route_that_answers_an_error_is_not_settled() { |
There was a problem hiding this comment.
2. This pins "nothing is settled" for 400 only: the test seller answers 200 or 400. Deleting || response.status().is_server_error() at src/axum.rs:251 passes every test, and a route that answers 500 would then charge the buyer, against README.md:62. One more route that answers 500, with asked() == ["/verify"]?
| ) -> Result<VerifyResponse, FacilitatorError> { | ||
| let route = "/verify"; | ||
| let answer = self.send(route, self.post(route, payment, terms)).await?; | ||
| if answer.json.get("isValid").is_none() && !answer.ok { |
There was a problem hiding this comment.
3. A payment that is base64 JSON with x402Version: 2 and nothing else passes from_header (src/payment.rs:30-42). Our facilitator answers a payload that fails its schema with 400 {error} (apps/facilitator/src/http/app.ts:238-241), this line makes it a Status error, and src/axum.rs:228-231 emits FacilitatorFailed and answers 502. So any buyer can raise a seller's "facilitator failed" count, and is told the facilitator is down for its own bad payload. Proposal: a 400 at /verify is a refusal, 402 with a reason, and not FacilitatorFailed. That is an exception to decision 12, worth a sentence there. No test covers a 400 from /verify today.
| self.emit(PaymentEvent::Settled(&settled)); | ||
| let headers = response.headers_mut(); | ||
| headers.insert(PAYMENT_RESPONSE, header_value(&settled.to_header())); | ||
| headers |
There was a problem hiding this comment.
4. or_insert("private") keeps a route's own Cache-Control: public, max-age=…, so a shared cache may keep the paid body and its PAYMENT-RESPONSE. Upstream appends , private to a value that lacks it (withPrivateCacheControl in @x402/core's server). The same here, with a test for a route that sets its own header?
| cargo fmt --all --check && cargo clippy --all-targets -- -D warnings | ||
| ``` | ||
|
|
||
| The oldest supported Rust is 1.81, with the versions in `Cargo.lock`. |
There was a problem hiding this comment.
5. A seller's build does not use our Cargo.lock, and as far as I know cargo reads incompatible-rust-versions from 1.84 on, so decision 15's "a seller on 1.81 needs the same setting" does nothing on a 1.81 toolchain (not run; please check). Could this line say what does work: resolve with a newer cargo, or pin the crates decision 15 names? Also ci.yml:87 checks 1.81 with the default features only; --no-default-features is the build the README offers to other frameworks.
| }; | ||
|
|
||
| let mut facilitator = Facilitator::new(&url)?; | ||
| if let Ok(key) = env::var("X402_API_KEY") { |
There was a problem hiding this comment.
8. X402_API_KEY= (set, empty) sends Authorization: Bearer with no key, and PUBLIC_URL= at :83 makes resource.url a bare path. The twin reads an empty variable as unset (examples/http-api/src/config.ts:7). .ok().filter(|value| !value.is_empty()) on both?
| "format:check": "prettier --check .", | ||
| "board": "node tools/scripts/generate-lore-board.mjs", | ||
| "sdk-rust:test": "cd packages/sdk/rust && cargo test && cargo test --no-default-features", | ||
| "sdk-rust:lint": "cd packages/sdk/rust && cargo fmt --all --check && cargo clippy --all-targets -- -D warnings", |
There was a problem hiding this comment.
9. sdk-rust:lint and the README's Develop block (README.md:104-107) run clippy with the default features only; CI also runs it with --no-default-features (ci.yml:79), so a clean local lint can fail there. And only CI's 1.81 step is --locked: the clippy and test steps (ci.yml:78-81) pass over a stale Cargo.lock.
| /// What happened to a payment, for the seller's logs and metrics. The buyer sees none of it | ||
| /// beyond what the answer carries. | ||
| #[derive(Debug)] | ||
| pub enum PaymentEvent<'a> { |
There was a problem hiding this comment.
10. Part 2 adds variants here (upto, new events), and each one breaks a seller's match, as the one in examples/weather.rs:46-67. #[non_exhaustive] now is free while the crate is unpublished: here, GateError (:50), FacilitatorError, PaymentHeaderError, AddressError and PriceError.
|
|
||
| ## Charge for an axum route | ||
|
|
||
| ```toml |
There was a problem hiding this comment.
11. The path = "packages/sdk/rust" line works only inside this repo. With publish = false, a seller needs git = "https://github.com/rumblefishdev/stellar-x402". Worth saying too that the layer is for axum 0.8. Same at :94-96.
| Self { url, ..fake } | ||
| } | ||
|
|
||
| pub fn supported_replies(&self, reply: Reply) { |
There was a problem hiding this comment.
12. supported_replies is never called, so /supported failing at PaymentGate::connect has no test. Four more behaviours no test would notice breaking, each one assertion: unknown /settle fields kept in PAYMENT-RESPONSE (src/facilitator.rs:102-104); a nested router's full path in resource.url (src/axum.rs:178-182); base_url with a trailing slash (src/axum.rs:108); a settle timeout through the layer and its "It may still settle" body (src/axum.rs:280-282).
Summary
Task 0136, the Milestone 2 plan's Tranche #2 item D5: a seller with a Rust service can now charge for an HTTP route with x402 on Stellar. The example server on the new crate answers 402 with a
bazaarblock and settles on testnet through our facilitator, paid by upstream's unmodified client.packages/sdk/rust/src/terms.rs,price.rs,network.rs): terms of theexactscheme in USDC or any SEP-41 token, completed from the facilitator's/supported, with thePAYMENT-REQUIREDheader. A test holds it equal to what@x402/express2.28.0 sends for the same route.src/payment.rs): read fromPAYMENT-SIGNATUREand matched to the seller's terms by upstream's rule. The facilitator gets the seller's terms, never the echoed ones.src/facilitator.rs):/supported,/verify,/settle, an API key as a bearer token on the last two (0105), a timeout, and theEXTENSION-RESPONSESheader.src/bazaar.rs): thebazaarblock for query and JSON-body routes, built as@x402/extensionsbuilds it.src/axum.rs, featureaxum, on by default) andexamples/weather.rs, the twin ofexamples/http-api: verify, run the route, settle only when the route answers below 400..github/workflows/ci.yml,package.json): jobsdk-rustlints and tests the crate with and without the layer, and checks Rust 1.81 with the lockfile.lore/1-tasks/active/0136_…/notes/R-rust-x402-crates.md): the Rust x402 crates on 2026-11-12, and a probe ofr402against our facilitator.Of the 4,822 added lines, 1,548 are
Cargo.lock, 1,275 the crate and its example, and 1,497 its tests and fixtures.Reviewers, please confirm:
packages/sdk/rust, which leavespackages/sdkitself, or a folder next torust, for the TypeScript helpers; andexamples/weather.rsis a twin ofexamples/http-api, not one of 0041's two examples.webpki-roots, is under CDLA-Permissive-2.0, a permissive licence that M1's audit list does not have.Please confirm
r402already works (the proposal, 0132). The proposal says no Rust x402 crate supports Stellar.r4020.21.0 does, and a probe shows it settles through our facilitator and that our catalog accepts its listing. Part 1 is our own crate all the same: the grant names one, andr402is a 0.x crate with one author. How the Tranche feat(lore-0003): UptoProxy Soroban contract with unit tests #2 evidence words "net-new" is for 0132.packages/sdk/rust, since ADR 0001 keeps the SDK inpackages/sdk; the crate isstellar-x402, free on crates.io today, andpublish = falseuntil 0140 decides the release. ADR 0001 does not mention Rust yet.errorstays the reason code, as upstream sends it, and the 402's body is{"error": reason, "message": message}. Upstream's middleware andr402drop the message, which for a missing trustline says what to do.facilitator_unavailable, at verify and at settle, without the cause, which may be the seller's own API key. The seller gets the cause, the refusals and the catalog's answer to its listing throughPaymentGate::on_event. Upstream answers 402 for most of these.Cargo.lock's versions only (0140): the newest releases of a few dependencies need 1.82 to 1.87, so.cargo/config.tomlkeepscargo updateon versions that fit and CI checks with--locked.developine6535d08): the crate survey became the first step, the proposal's buyer-side signing went to part 2 (0140), and a route that fails must not be settled.What part 1 leaves out is listed in the task ("Left for part 2"):
upto, the buyer's side, path parameters in a listing, more than one way to pay for a route, upstream's extension echo check.Testing
cargo testinpackages/sdk/rust→ 65 passed;cargo test --no-default-features→ 48 passed. The layer's and the client's tests run a real server and a fake facilitator on local ports.cargo fmt --all --check,cargo clippy --all-targets -- -D warningswith and without default features → clean.cargo +1.81.0 check --locked→ builds.examples/weather.rsagainsthttps://x402-facilitator.sorobanscan.com(commit5d2e26ba), paid by@x402/core,@x402/fetchand@x402/stellar2.28.0:examples/http-apifor the same route and seller;e1ce215ff1d83658dbb64e3e0c46d7cb4aedcb172dfc9f8eda1de810b1cd40e4, 0.001 USDC moved, our signer paid the fee;b831d67e60389ad432746fbd46a235dd8c3fe30a5091e14d96290a5246a56afc, then 402invalid_exact_stellar_payload_simulation_failed;rejected: resource_not_public, as the seller ran onlocalhost;PUBLIC_URL) → 200,dd6113e23a83e4ba0d820cd3f5259b87724439bdb61c1c3a030c08a50812579a, the catalog's answer isprocessing, and/discovery/resourcesthen lists the route with its terms andbazaarblock. The tunnel is closed, so that listing names a dead address.pnpm format:check(tracked files),pnpm lint,pnpm typecheck,pnpm build→ pass.pnpm test→ every package passes but the facilitator: its 19 test files that need PostgreSQL did not run, as no database was reachable on the machine. The branch changes no TypeScript; CI runs them.sdk-rustCI job before this PR; an API key against a facilitator that asks for one (0105 is not built). The buyer script and its two friendbot accounts were one-off and are not in the repo.