Skip to content

feat(lore-0136): add the Rust seller crate, part 1 - #177

Open
adamkoot wants to merge 6 commits into
developfrom
lore-0136-rust-seller-crate-part-1
Open

adamkoot wants to merge 6 commits into
developfrom
lore-0136-rust-seller-crate-part-1

Conversation

@adamkoot

@adamkoot adamkoot commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

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 bazaar block and settles on testnet through our facilitator, paid by upstream's unmodified client.

  • The 402 answer (packages/sdk/rust/src/terms.rs, price.rs, network.rs): terms of the exact scheme in USDC or any SEP-41 token, completed from the facilitator's /supported, with the PAYMENT-REQUIRED header. A test holds it equal to what @x402/express 2.28.0 sends for the same route.
  • The buyer's payment (src/payment.rs): read from PAYMENT-SIGNATURE and matched to the seller's terms by upstream's rule. The facilitator gets the seller's terms, never the echoed ones.
  • Facilitator client (src/facilitator.rs): /supported, /verify, /settle, an API key as a bearer token on the last two (0105), a timeout, and the EXTENSION-RESPONSES header.
  • Bazaar listing (src/bazaar.rs): the bazaar block for query and JSON-body routes, built as @x402/extensions builds it.
  • axum layer (src/axum.rs, feature axum, on by default) and examples/weather.rs, the twin of examples/http-api: verify, run the route, settle only when the route answers below 400.
  • CI (.github/workflows/ci.yml, package.json): job sdk-rust lints and tests the crate with and without the layer, and checks Rust 1.81 with the lockfile.
  • Research (lore/1-tasks/active/0136_…/notes/R-rust-x402-crates.md): the Rust x402 crates on 2026-11-12, and a probe of r402 against 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:

  • @karolko9 (0102, 0041): the Rust crate takes packages/sdk/rust, which leaves packages/sdk itself, or a folder next to rust, for the TypeScript helpers; and examples/weather.rs is a twin of examples/http-api, not one of 0041's two examples.
  • @karczuRF (0132): D5's evidence is the two testnet hashes below; and one dependency, webpki-roots, is under CDLA-Permissive-2.0, a permissive licence that M1's audit list does not have.

Please confirm

  1. The crate stands alone, though r402 already works (the proposal, 0132). The proposal says no Rust x402 crate supports Stellar. r402 0.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, and r402 is 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.
  2. Place and name (0102, 0140): packages/sdk/rust, since ADR 0001 keeps the SDK in packages/sdk; the crate is stellar-x402, free on crates.io today, and publish = false until 0140 decides the release. ADR 0001 does not mention Rust yet.
  3. A refusal carries the facilitator's message (sellers, buyers): the header's error stays the reason code, as upstream sends it, and the 402's body is {"error": reason, "message": message}. Upstream's middleware and r402 drop the message, which for a missing trustline says what to do.
  4. A facilitator that gives no answer is 502 to the buyer (sellers): 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 through PaymentGate::on_event. Upstream answers 402 for most of these.
  5. Rust 1.81 holds with Cargo.lock's versions only (0140): the newest releases of a few dependencies need 1.82 to 1.87, so .cargo/config.toml keeps cargo update on versions that fit and CI checks with --locked.
  6. 0136's scope was edited before work started (pushed to develop in e6535d08): 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 test in packages/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 warnings with and without default features → clean. cargo +1.81.0 check --locked → builds.
  • Five behaviours broken on purpose (the payment timeout, USDC's decimals, settling when the route fails, matching the payment to the terms, sending the API key) → each failed the test that names it.
  • Testnet, examples/weather.rs against https://x402-facilitator.sorobanscan.com (commit 5d2e26ba), paid by @x402/core, @x402/fetch and @x402/stellar 2.28.0:
    • the 402 is equal, field for field, to the 402 of examples/http-api for the same route and seller;
    • a paid request → 200, e1ce215ff1d83658dbb64e3e0c46d7cb4aedcb172dfc9f8eda1de810b1cd40e4, 0.001 USDC moved, our signer paid the fee;
    • a paid request the route answers with 400 → 400, nothing settled, balances unchanged;
    • the same payment twice → 200 with b831d67e60389ad432746fbd46a235dd8c3fe30a5091e14d96290a5246a56afc, then 402 invalid_exact_stellar_payload_simulation_failed;
    • the seller's log shows the catalog's answer, rejected: resource_not_public, as the seller ran on localhost;
    • behind a public address (a Cloudflare quick tunnel, PUBLIC_URL) → 200, dd6113e23a83e4ba0d820cd3f5259b87724439bdb61c1c3a030c08a50812579a, the catalog's answer is processing, and /discovery/resources then lists the route with its terms and bazaar block. 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.
  • Not run: the sdk-rust CI 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.

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 karczuRF left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and dd6113e2… (15:02:49), each successful, 0.001 USDC to GDPB…AIH3, the fee paid by GAVE5V…KKRP, the signer our /supported lists. The ask above says "two testnet hashes" and Testing lists three; 0132 will name all three.
  • webpki-roots under 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 and on_event never 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) adds tokio to 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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread package.json
"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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants