refactor(payment)!: PaymentRails set + PaymentRouter (replace PaymentMode)#2
Conversation
…Mode)
Greenfield cleanup — the best-in-class payment primitive, no backward-compat
shims (no prod consumers yet).
BREAKING: `PaymentMode` enum (None/Shielded/Direct/Both) → `PaymentRails`, a
composable set of bool flags (`shielded`, `direct`). Rails compose freely:
enabling several lets one endpoint accept any of them. Adding a future rail is
one more flag + a `PaymentProvider` impl — never an enum cross-product (which
would need More/AllThree variants as rails grow).
`CompositeProvider` (the `Both` special case) → `PaymentRouter`, the single
universal provider: holds the enabled rails as `Option<Provider>` and dispatches
each request to the matching one by proof type, rejecting a proof for a disabled
rail up front (no chain call). `create_provider(rails, ..)` builds a NoopProvider
for the empty set or a PaymentRouter otherwise.
Config: `BillingConfig.payment_mode: PaymentMode` → `payment_rails: PaymentRails`
(default = shielded only). TOML/JSON: `payment_rails = { shielded = true, direct = true }`.
Tests: rails serde/compose, both-rails build, router rejects disabled-rail proof.
Full suite green (16 lib + 49 integration).
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved PR — 4476f077
Blanket team auto-approval is enabled for this reviewer service.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.
tangletools · auto-approval · reason: blanket_auto_approve · 2026-06-11T20:59:14Z
✅ No Blockers —
|
| deepseek | glm | aggregate | |
|---|---|---|---|
| Readiness | 57 | 47 | 47 |
| Confidence | 90 | 90 | 90 |
| Correctness | 57 | 47 | 47 |
| Security | 57 | 47 | 47 |
| Testing | 57 | 47 | 47 |
| Architecture | 57 | 47 | 47 |
Full multi-shot audit completed 6/6 planned shots over 6 changed files. Global verifier still owns final merge decision. | Full multi-shot audit completed 6/6 planned shots over 6 changed files. Global verifier still owns final merge decision.
🟠 MEDIUM Breaking config format change: old payment_mode enum values will silently deserialize to default — src/config.rs
An existing TOML/JSON config containing
payment_mode = "shielded"orpayment_mode = "both"will no longer parse. The field was renamed frompayment_modetopayment_railsand its type changed from a#[serde(rename_all = "snake_case")]enum to a struct with bool fields. Because#[serde(default)]is used on the newpayment_railsfield, an old config that only haspayment_modewill silently getPaymentRails::default()(shielded-only). For operators who hadpayment_mode = "none"orpayment_mode = "direct", this silently changes billing behavior — a"none"operator now requires shielded payment by default. Mitigation: add#[serde(alias = "payment_mode")]on the field and/or add a deny-unknown-fields guard + migration guide in the PR description. If this is a pre-1.0 cra
🟠 MEDIUM PaymentRouter::build parses operator_key redundantly — src/payment.rs
Lines 460-465:
PaymentRouter::buildparsestangle.operator_keyinto aPrivateKeySignerjust to extract the address, but every sub-provider (ShieldedProvider::new,DirectProvider::new) independently parses the same key for the same purpose. This is triple-parsing a private key material string. If any provider changes its derivation, the router's storedoperator_addresssilently diverges. Consider deriving the address once at the factory level (create_provider) and passingAddressdown, or having each provider expose its address from a single canonical derivation. Minor perf concern (secp256k1 parsing 3x) but primarily a correctness/DRY issue — any
🟠 MEDIUM PaymentRouter::build() allows empty rails (pub API sharp edge) — src/payment.rs
Line 459:
PaymentRouter::build()is apub fnthat accepts anyPaymentRailswithout validating that at least one rail is enabled. Callingbuild(PaymentRails::NONE, ...)produces a router where bothshieldedanddirectareNone,operator_address()returns a valid address, but everyauthorize()/settle()call fails with 'not enabled' errors. Thecreate_providerfactory (line 547) guards this withrails.is_empty(), butbuild()does not. Fix: add the same guard at the start ofbuild(), returning `anyhow::bail!("no payment
🟠 MEDIUM UsedTxStore::persist silently drops write errors and has no crash-recovery atomicity guarantee — src/payment.rs
Lines 355-368:
persistuses write-to-tmp + rename which is correct for atomicity on POSIX, but: (1)std::fs::write(&tmp, &data).is_ok()silently discards the write error — if the disk is full, the operator gets no error surface and the tx is already committed in-memory. On restart, the tx is gone from the persisted set but was already served, allowing replay. (2) The.tmpfile path is deterministic per-store — concurrent processes (unlikely in single-operator but possible if supervisor restarts fast) could race on the tmp file. Consider: (a) bubbling the write error up fromcommit(or at minimum logging at error level on write failure rather than silentl
🟠 MEDIUM Lost serde round-trip coverage for payment rails — tests/integration_test.rs
The old
payment_mode_serde_all_variantstest (lines 897-910 in base) verified full serialization→deserialization round-trip for every variant. The replacement tests (payment_rails_default_is_shielded_onlyat L892,payment_rails_compose_freelyat L900) only testserde_json::from_str(deserialization). If a#[serde]attribute or field name change breaks serialization, no test catches it. Addserde_json::to_string(&PaymentRails::SHIELDED)and round-trip assertions for at least SHIELDED, DIRECT, BOTH, and NONE.
🟡 LOW Example creates duplicate BillingClient (pre-existing architectural debt) — examples/minimal_operator.rs
Line 153 creates
billing = BillingClient::new(...)and passes it via.billing(Arc::new(billing)). Since.payment_provider(...)is not called,AppStateBuilder::build()(server.rs:368-387) falls back tocreate_provider(SHIELDED, ...)which constructs aShieldedProviderthat internally callsBillingClient::new(...)again, yielding two separateBillingClientinstances. Not a bug — the example doesn't exercise billing — but anyone copying this pattern gets duplicated provider state. Consider either calling.payment_provider(...)explicitly or usingAppState::from_config(...)([line 136](https://github.com/tangle-network/tangle-inference-c
🟡 LOW Hardcoded Anvil test private key in example — examples/minimal_operator.rs
Line 121:
operator_key: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"— the well-known Anvil/Hardhat account #0 key. Pre-existing, not introduced by this PR. Low severity since this is an example that 'will fail at startup' (line 14), but worth a comment or env-var substitution to prevent copy-paste into production.
🟡 LOW Missing payment_rails default assertion in billing config test — src/config.rs
test_billing_config_defaults (line 269) deserializes BillingConfig but never asserts that payment_rails defaults to SHIELDED. The only assertions are billing_required, claim_max_retries, clock_skew_tolerance_secs, and max_gas_price_gwei. Since this file's primary change is PaymentMode→PaymentRails with a Default impl, the serde default path should be explicitly verified to prevent regression. Add: assert_eq!(cfg.payment_rails, PaymentRails::SHIELDED);
🟡 LOW PaymentRails silently accepts unknown fields (no deny_unknown_fields) — src/config.rs
PaymentRails (line 86) derives Deserialize without #[serde(deny_unknown_fields)]. A config typo like {"shielded": true, "direc": true} silently deserializes as SHIELDED (direct=false), leaving the operator believing direct payments are enabled when they are not. This is a configuration footgun for security-sensitive payment settings. Add #[serde(deny_unknown_fields)] on the struct to reject typos at config load time.
🟡 LOW test_billing_config_defaults doesn't assert payment_rails default — src/config.rs
The unit test at line 269 constructs a BillingConfig from JSON with only max_spend_per_request and min_credit_balance, but never asserts
cfg.payment_rails. Thepayment_rails_default_is_shielded_onlytest in integration_test.rs covers this indirectly, but the config unit test should also verify the field since it's testing BillingConfig defaults. Minor coverage gap.
🟡 LOW No happy-path routing dispatch test for PaymentRouter with BOTH rails — src/payment.rs
The integration test
router_rejects_proof_for_disabled_rail(test line 919) tests only the rejection case: SHIELDED-only router correctly rejects a DirectTransfer proof. But there is no test thatPaymentRouter::build(PaymentRails::BOTH, ...).authorize(&SpendAuthProof)dispatches to ShieldedProvider, or that a DirectTransfer proof routes to DirectProvider. The individual providers are tested for their own authorize/settle logic, but the match-arm dispatch in PaymentRouter (lines 479-495) is unverified for the correct path. A swap in the
🟡 LOW PaymentRouter dispatch duplicates match arms in authorize and settle — src/payment.rs
Lines 480-510:
authorizeandsettleboth have identical match-on-proof-type → ok_or_else → delegate patterns with duplicated error messages ('shielded rail not enabled' / 'direct rail not enabled'). If a new proof variant is added to PaymentProof, both match arms must be updated in lockstep. Consider a helper methodfn rail_for(&self, proof: &PaymentProof) -> anyhow::Result<&dyn PaymentProvider>that centralizes the dispatch, or a macro, to eliminate the duplication. Not a bug today, but a maintenance hazard.
🟡 LOW Builder fallback silently swaps rail config on error — src/server.rs
Lines 370-386: When
create_providerfails (e.g., DIRECT rail configured but nopayment_token_address), the builder silently falls back toShieldedProviderinstead of propagating the error. This means an operator who explicitly configuredpayment_rails: { direct: true }without a token address gets shielded-only instead of a clear startup failure. This is pre-existing behavior (not introduced by this PR), but the rail-based config makes misconfiguration more likely since operators may toggle individual flags. Consider propagating the error instead of silently downgrading, or at minimum logging a warning that the fallback was triggered.
🟡 LOW BOTH-rail dispatch not tested — tests/integration_test.rs
create_provider_both_rails(L908) only assertsis_ok()on construction. It never verifies that a router built withPaymentRails::BOTHaccepts both a SpendAuth proof and a DirectTransfer proof — meaning a regression where BOTH only dispatches to one rail would not be caught by this test file. Consider adding arouter_dispatches_both_railstest.
🟡 LOW Missing serde round-trip and NONE-deserialization coverage — tests/integration_test.rs
The old payment_mode_serde_all_variants test verified serialize→deserialize round-trip for all three variants (none, shielded, direct). The replacement tests (payment_rails_default_is_shielded_only, payment_rails_compose_freely) verify deserialization of {"direct":true} and {"shielded":true,"direct":true} but never call serde_json::to_string and compare. Also no test deserializes {} or {"shielded":false,"direct":false} to verify NONE round-trips correctly. Low severity — the underlying PaymentRails bitfield type likely makes this trivial, but the old test's exhaustive coverage regressed slightly.
🟡 LOW Stale comment references removed type — tests/integration_test.rs
Line 711:
// - PaymentMode config creates the right provider— the type was renamed fromPaymentModetoPaymentRails. Should readPaymentRails config.
🟡 LOW Stale comment referencing removed PaymentMode — tests/integration_test.rs
Line 711 has a comment '// - PaymentMode config creates the right provider' referencing the now-removed type. Outside this shot's scope but worth noting for housekeeping.
tangletools · 2026-06-11T21:12:31Z · trace
…/settle) Extract the per-rail billing dance that lived in the operator handler into reusable core primitives, so any blueprint gets correct, identical billing without re-implementing it: - PaymentProof::payer_id() — stable per-account identity across rails (shielded commitment / direct sender) for the in-flight limit. - resolve_payment_proof() — header / body precedence into one PaymentProof. - authorize_request() — per-account guard -> validate -> health -> commit, rail-agnostic. Validation runs BEFORE the backend-health check so a malformed or over-limit request gets a precise 4xx, not a 503. - settle_request() + AuthorizedPayment — carries the account guard through settlement; shielded claims on-chain, direct is already paid (no-op). Tests: payer_id identity, resolve_payment_proof precedence.
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved PR — 0c7d0225
Blanket team auto-approval is enabled for this reviewer service.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.
tangletools · auto-approval · reason: blanket_auto_approve · 2026-06-11T21:28:38Z
…tores (#3) Hardening from the PR #2 review, all fail-closed / DRY (no back-compat shims — nothing consumes this crate in prod yet): - BillingConfig + PaymentRails get #[serde(deny_unknown_fields)]: a stale or mistyped key (e.g. the removed `payment_mode`) is now a loud config-load error instead of silently defaulting to shielded-only. The right answer to "old payment_mode silently deserializes to default" — reject it, don't alias it. - PaymentRouter::build() rejects an empty rail set (matches create_provider) and derives operator_address from a real sub-provider instead of re-parsing the key a third time — one canonical derivation, no divergence risk. - PaymentRouter dispatch centralized in provider_for(): authorize/settle can no longer drift, and a new PaymentProof variant fails to compile until handled. - UsedTxStore::persist + NonceStore::persist surface write failures at error level (was a silently-dropped write) — a dropped persist means a consumed tx/nonce is live in memory but gone on restart, i.e. replayable. - AppStateBuilder::build() propagates a provider-construction error instead of silently downgrading a misconfigured Direct rail to shielded-only. Tests: payment_rails serde round-trip (all four sets) + deny_unknown_fields rejection, BillingConfig default assertion, router BOTH-rail dispatch (each proof routes to its rail).
Greenfield cleanup — the best-in-class, extensible payment primitive (no prod consumers yet, so no backward-compat shims).
Why
PaymentModewas an enum cross-product —None / Shielded / Direct / Both. "Both" is a special case; a third rail would forceMore / AllThree…variants. That doesn't scale.What
PaymentRails— a composable set of bool flags (shielded,direct). Rails compose freely; enabling several lets one endpoint accept any of them. Adding a future rail = one more flag + aPaymentProviderimpl, never a new enum cross-product.PaymentRouter(replacesCompositeProvider) — the single universal provider. Holds enabled rails asOption<Provider>, dispatches each request to the matching one by proof type, and rejects a proof for a disabled rail up front (no chain call).create_provider(rails, ..)→ Noop for the empty set, else a PaymentRouter.BillingConfig.payment_mode→payment_rails(default shielded). TOML:payment_rails = { shielded = true, direct = true }.Tests
Rails serde/compose, both-rails build, router-rejects-disabled-rail. Suite green: 16 lib + 49 integration.
Consumed by llm-inference-blueprint#feat/payment-rails.