Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs by bussyjd · Pull Request #608 · ObolNetwork/obol-stack · GitHub
Skip to content

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs - #608

Closed
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production
Closed

[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docs#608
bussyjd wants to merge 1 commit into
feat/mpp-card-verifier-seam-spikefrom
feat/mpp-card-production

Conversation

@bussyjd

@bussyjdbussyjd commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Takes the credit-card spike (#606) to a production-shaped implementation. Stacked on #606; flows into integration/v0.11.0-rc1.

1. Wire it up

routeRuleFromOffer (internal/x402/serviceoffer_source.go) populates RouteRule.Card from spec.payment.card when method=card, so the verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch on rule.IsCard()). Crypto offers untouched (test asserts a crypto offer produces no card route); card routes still require RoutePublished like crypto.

2. Auth/capture split + SPT replay defense

Two-phase cardGateway: authorize a manual-capture Stripe PaymentIntent before serving → capture only after a <400 upstream response → cancel the hold on upstream failure, capture failure, or an upstream that never responds (panic / no write). Per-pod SPT replay guard rejects reuse of a single-use Shared Payment Token. Capture/cancel run on detached contexts so a client disconnect can't cancel a money operation; a deferred reconcile cancels + releases on panic (and re-panics to preserve http.ErrAbortHandler).

3. Stripe key + docs

The verifier reads STRIPE_SECRET_KEY from the x402-secrets Secret via an optionalsecretKeyRef env — no new RBAC, crypto-only stacks unaffected. Added the key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)" README section. CLI gains obol sell http --pay-with card --stripe-network-id (env default STRIPE_NETWORK_ID).

4. Non-2-decimal currencies

currencyMinorUnits() maps ISO-4217 minor units (jpy=0, bhd/kwd=3, default 2); the Stripe amount uses it instead of a hardcoded 2. The SPT is the top-level shared_payment_granted_token per the cp0x-org/mppx reference (documented for live-Stripe validation).


Architecture

PR stack (all merge into the rc)

flowchart BT
p3["#608 feat/mpp-card-production<br/>wire + authorize/capture/cancel + replay + docs"]
p2["#606 feat/mpp-card-verifier-seam-spike<br/>buildCardRequirement + cardSettleFunc"]
p1["#605 feat/mpp-card-payment-method<br/>CRD method+card+CEL · CLI --pay-with card"]
rc["integration/v0.11.0-rc1"]
main["main"]
p3 --> p2 --> p1 --> rc --> main
Loading

Component view — one route, two settlement engines

flowchart TB
buyer["Buyer / agent"] -->|"HTTPS via Cloudflare tunnel"| traefik["Traefik (Gateway API)<br/>HTTPRoute /services/&lt;name&gt;/*"]
traefik -->|backendRef| HP
subgraph V["x402-verifier · x402 ns · replicas:1"]
HP["HandleProxy"] --> MR{"matchPaidRouteFull<br/>rule.IsCard()?"}
MR -->|"no — crypto (unchanged)"| C["ForwardAuth +<br/>facilitator verify/settle"]
MR -->|"yes — card"| D["serveCardGated<br/>authorize / capture / cancel"]
end
D -->|HTTPS| stripe["api.stripe.com<br/>/v1/payment_intents (+ /capture + /cancel)"]
C --> upstream["upstream Service<br/>ollama / litellm / any svc"]
D --> upstream
secret["x402-secrets Secret<br/>STRIPE_SECRET_KEY"] -.->|"optional env (no RBAC change)"| V
Loading

Control plane — how a card offer becomes a live route

flowchart TB
cli["obol sell http --pay-with card<br/>resolveCardPayment()"] -->|kubectl apply| so["ServiceOffer CR<br/>payment.method=card<br/>payment.card{account,currency,networkId}"]
so -->|"CEL admission: card ⇒ card.account required"| ctrl["serviceoffer-controller<br/>ModelReady→…→RoutePublished→Ready"]
ctrl -->|"HTTPRoute → verifier + Middleware"| live["live /services/&lt;name&gt;/* route"]
so -.->|informer| src["serviceoffer_source.go<br/>routeRuleFromOffer()"]
src -->|"method=card ⇒ rule.Card = CardRoute{…}<br/>Decimals = currencyMinorUnits(currency)"| rule["RouteRule (in-memory table)"]
rule --> match["matchPaidRouteFull → IsCard()"]
Loading

Request sequence — the money path

sequenceDiagram
autonumber
participant B as Buyer
participant V as x402-verifier
participant S as Stripe
participant U as Upstream
B->>V: GET /services/my-api (no X-PAYMENT)
V-->>B: 402 accepts[card] {amount(minor), currency, networkId}
Note over B: mint Shared Payment Token spt_…
B->>V: retry with X-PAYMENT = base64({spt})
V->>V: guard.tryReserve(spt)
V->>S: POST /payment_intents (manual capture, confirm, spt)
S-->>V: requires_capture (pi_…)
V->>U: proxy request
alt upstream 2xx/3xx
U-->>V: 200 + body
V->>S: POST /payment_intents/pi_/capture
S-->>V: succeeded
V->>V: guard.consume(spt)
V-->>B: 200 + body + X-PAYMENT-RESPONSE(pi_)
else upstream 4xx/5xx · capture fails · panic / no-write
V->>S: POST /payment_intents/pi_/cancel
V->>V: guard.release(spt)
V-->>B: error status (buyer NOT charged)
end
Loading

Authorize → capture / cancel state machine

stateDiagram-v2
[*] --> RESERVED: X-PAYMENT, tryReserve(spt)
RESERVED --> AUTHORIZED: authorize -> requires_capture
RESERVED --> rel_auth: authorize error
AUTHORIZED --> CAPTURED: upstream 2xx/3xx, capture -> succeeded
AUTHORIZED --> canc_fail: upstream 4xx/5xx / panic / no-write
AUTHORIZED --> canc_cap: capture error
CAPTURED --> [*]: consume(spt), 200 + receipt
rel_auth --> [*]: release(spt), 402 (retry allowed)
canc_fail --> [*]: cancel + release(spt), pass-through status
canc_cap --> [*]: cancel + release(spt), 502
Loading

Crypto vs card dispatch

flowchart TB
M["matchPaidRouteFull(uri) → RouteRule"] --> Q{"rule.IsCard()?"}
Q -->|"no (crypto, default)"| C1["BuildV2RequirementWithAsset<br/>scheme exact · USDC/OBOL · payTo 0x…"]
Q -->|"yes (card)"| D1["buildCardRequirement<br/>scheme card · stripe · payTo acct_…"]
C1 --> C2["X-PAYMENT = ERC-3009 / Permit2 voucher"]
D1 --> D2["X-PAYMENT = {spt_…}"]
C2 --> C3["facilitator /verify + /settle<br/>offline · on-chain · final"]
D2 --> D3["Stripe authorize → capture/cancel<br/>online · custodial · reversible"]
Loading

Tests / validation

Two-phase lifecycle against a mock Stripe httptest server (authorize→requires_capture, capture→succeeded, cancel); serveCardGated success / auth-failure / upstream-failure / capture-failure / panic / replay paths; replay guard; currency decimals; routeRuleFromOffer card wiring; CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.

Review

Adversarial multi-agent review (payment-lifecycle / security-abuse / integration-regression): 0 confirmed P0/P1. No free-service bypass, no card-vs-crypto scheme confusion (dispatch is from the CRD, never buyer input), secret never logged, RBAC unchanged, crypto path byte-for-byte. The leaked-authorization edge it surfaced (panicking/non-writing upstream) is fixed here (deferred reconcile + test). Documented residuals: per-pod replay guard (verifier is single-replica), single cluster-wide Stripe key (per-offer Secret is the next step, gated on widening the verifier's resourceName-scoped secret RBAC).

@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from f85ae45 to df88e03CompareJune 8, 2026 19:22
…pture + docs)
Takes the credit-card spike to a production-shaped implementation across four
fronts.
1. Wire it up (internal/x402/serviceoffer_source.go): routeRuleFromOffer now
populates RouteRule.Card from spec.payment.card when method=card, so the
verifier actually gates card offers (matchPaidRouteFull/HandleProxy dispatch
on rule.IsCard()). Currency-derived minor-unit decimals.
2. Auth/capture split + replay defense (internal/x402/card.go): the single
charge is replaced by a two-phase cardGateway — authorize a manual-capture
Stripe PaymentIntent BEFORE serving, CAPTURE only after a <400 upstream
response, and CANCEL the hold on upstream/capture failure, so a buyer is
never charged for a request that wasn't served. A per-pod SPT replay guard
rejects reuse of a single-use Shared Payment Token. Capture/cancel run on
detached contexts so a client disconnect can't cancel a money operation.
3. Stripe key + docs (item 3): the verifier reads STRIPE_SECRET_KEY from the
x402-secrets Secret (optional env, crypto-only stacks unaffected). Added the
key + STRIPE_NETWORK_ID to .env.example and a "Credit-card payments (MPP)"
README section (Stripe "Machine payments" account requirement, populate-the-
secret recipe, and the per-offer-Secret / RBAC / single-replica scope notes).
CLI gains `obol sell http --pay-with card --stripe-network-id` (env default
STRIPE_NETWORK_ID) so card offers advertise a usable network id.
4. Non-2-decimal currencies (item 4): currencyMinorUnits() maps ISO-4217 minor
units (jpy=0, bhd=3, default 2); buildCardRequirement uses it for the Stripe
amount instead of a hardcoded 2. The SPT is passed as the top-level
shared_payment_granted_token per the cp0x-org/mppx reference (documented for
live-Stripe validation).
Tests: two-phase lifecycle against a mock Stripe httptest server (authorize ->
requires_capture, capture -> succeeded, cancel), serveCardGated success/auth-
failure/upstream-failure/capture-failure/replay paths with a fake gateway, the
replay guard, currency decimals, the routeRuleFromOffer card wiring, and the
new CLI flag. go test ./... green; gofmt/vet clean; no new CI-enforced lint.
Stacked on feat/mpp-card-verifier-seam-spike; flows into integration/v0.11.0-rc1.
@bussyjd
bussyjdforce-pushed the feat/mpp-card-production branch from df88e03 to 278e85aCompareJune 9, 2026 04:28
@bussyjd
bussyjdforce-pushed the feat/mpp-card-verifier-seam-spike branch from 3d8e4e9 to 7e3df3cCompareJune 9, 2026 04:28
@bussyjdbussyjd changed the title feat(x402): productionize MPP credit-card path — wire + auth/capture + replay + docs[MPP 3/3] feat(x402): productionize credit-card path — wire + auth/capture + replay + docsJun 9, 2026
@OisinKyne

Copy link
Copy Markdown
Contributor

Can this close if hermes are gaining payments in their next version?

@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by #651, the clean MPP payments compatibility PR stacked on top of #649.

@bussyjdbussyjd closed this Jun 17, 2026
@OisinKyne
OisinKyne deleted the feat/mpp-card-production branch July 1, 2026 12:33
Sign up for freeto 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

@bussyjd@OisinKyne