Skip to content

refactor(payment)!: PaymentRails set + PaymentRouter (replace PaymentMode)#2

Merged
drewstone merged 2 commits into
masterfrom
feat/payment-rails
Jun 11, 2026
Merged

refactor(payment)!: PaymentRails set + PaymentRouter (replace PaymentMode)#2
drewstone merged 2 commits into
masterfrom
feat/payment-rails

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Greenfield cleanup — the best-in-class, extensible payment primitive (no prod consumers yet, so no backward-compat shims).

Why

PaymentMode was an enum cross-product — None / Shielded / Direct / Both. "Both" is a special case; a third rail would force More / 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 + a PaymentProvider impl, never a new enum cross-product.
  • PaymentRouter (replaces CompositeProvider) — the single universal provider. Holds enabled rails as Option<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.
  • Config: BillingConfig.payment_modepayment_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.

…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
tangletools previously approved these changes Jun 11, 2026

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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

@tangletools

Copy link
Copy Markdown

✅ No Blockers — 4476f077

Readiness 47/100 · Confidence 90/100 · 17 findings (5 medium, 12 low)

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" or payment_mode = "both" will no longer parse. The field was renamed from payment_mode to payment_rails and its type changed from a #[serde(rename_all = "snake_case")] enum to a struct with bool fields. Because #[serde(default)] is used on the new payment_rails field, an old config that only has payment_mode will silently get PaymentRails::default() (shielded-only). For operators who had payment_mode = "none" or payment_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::build parses tangle.operator_key into a PrivateKeySigner just 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 stored operator_address silently diverges. Consider deriving the address once at the factory level (create_provider) and passing Address down, 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 a pub fn that accepts any PaymentRails without validating that at least one rail is enabled. Calling build(PaymentRails::NONE, ...) produces a router where both shielded and direct are None, operator_address() returns a valid address, but every authorize()/settle() call fails with 'not enabled' errors. The create_provider factory (line 547) guards this with rails.is_empty(), but build() does not. Fix: add the same guard at the start of build(), 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: persist uses 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 .tmp file 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 from commit (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_variants test (lines 897-910 in base) verified full serialization→deserialization round-trip for every variant. The replacement tests (payment_rails_default_is_shielded_only at L892, payment_rails_compose_freely at L900) only test serde_json::from_str (deserialization). If a #[serde] attribute or field name change breaks serialization, no test catches it. Add serde_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 to create_provider(SHIELDED, ...) which constructs a ShieldedProvider that internally calls BillingClient::new(...) again, yielding two separate BillingClient instances. 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 using AppState::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. The payment_rails_default_is_shielded_only test 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 that PaymentRouter::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: authorize and settle both 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 method fn 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_provider fails (e.g., DIRECT rail configured but no payment_token_address), the builder silently falls back to ShieldedProvider instead of propagating the error. This means an operator who explicitly configured payment_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 asserts is_ok() on construction. It never verifies that a router built with PaymentRails::BOTH accepts 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 a router_dispatches_both_rails test.

🟡 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 from PaymentMode to PaymentRails. Should read PaymentRails 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 tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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

@drewstone drewstone merged commit bab6225 into master Jun 11, 2026
7 of 8 checks passed
drewstone added a commit that referenced this pull request Jun 11, 2026
…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).
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