feat: add tempo subscriptions#331
Conversation
992c30a to
313f314
Compare
commit: |
a09cb0e to
d651f09
Compare
| ) | ||
| const active = matches.filter((record) => isActive(record)) | ||
|
|
||
| const subscription = (() => { |
There was a problem hiding this comment.
it feels odd we have to have the client pass a subscription id in order to disambiguate, shouldn't the server be able to filter subscriptions to check if one is valid for the given route / user?
| import type { SubscriptionRecord } from './Types.js' | ||
|
|
||
| const recordPrefix = 'tempo:subscription:record:' | ||
| const resourcePrefix = 'tempo:subscription:resource:' |
There was a problem hiding this comment.
allow these to be customized
| return records.filter((record: unknown): record is SubscriptionRecord => Boolean(record)) | ||
| }, | ||
| async put(record) { | ||
| await store.put(recordKey(record.subscriptionId), record) |
There was a problem hiding this comment.
add comments about what we are doing here
| return 'minute' | ||
| case 3_600: | ||
| return 'hour' | ||
| case 86_400: |
There was a problem hiding this comment.
make these constants
| } | ||
| } | ||
|
|
||
| function formatBillingInterval(periodSeconds: string) { |
There was a problem hiding this comment.
is there no way to manually bill users e.g. in a cron? we should add this
5d333a6 to
e776f4e
Compare
|
Great addition, @brendanjryan. The main thing I’d pin down early is how a later charge proves linkage to the original authorization window. A minimal stable tuple for that would already help a lot: authorization ref, cumulative spent, remaining window, and a verifier-facing record ref. |
5929e02 to
bb873c5
Compare
|
@jithinraj not sure I follow -- the auth lives on the subscription itself. what is the verifier in this context? |
|
Got it, thanks @brendanjryan. By “verifier-facing” I meant downstream audit / reconciliation, not a separate verifier path inside this PR. My earlier framing was broader than the current scope. The useful boundary seems simpler: subscription reuse is determined by the server’s lookup key plus Might be worth a short note around that boundary, mainly to distinguish when |
eb9dc93 to
75552f0
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
bb4deca to
7fea5da
Compare
| period: toSubscriptionPeriodSeconds(request), | ||
| }, | ||
| ], | ||
| }, |
There was a problem hiding this comment.
We require consumers to also specify scopes post-T3 (only relevant here, not for local).
| activate: async ({ accessKey, request, resolved }) => { | ||
| const userId = resolved.key.split(':')[1] ?? 'anonymous' | ||
| const id = subscriptionId(userId) | ||
| const reference = chargeWithAccessKey({ | ||
| accessKey, | ||
| amount: request.amount, | ||
| periodIndex: 0, | ||
| subscriptionId: id, | ||
| }) | ||
| const record = { | ||
| amount: request.amount, | ||
| billingAnchor: new Date().toISOString(), | ||
| chainId: request.methodDetails?.chainId, | ||
| currency: request.currency, | ||
| lastChargedPeriod: 0, | ||
| lookupKey: resolved.key, | ||
| periodCount: request.periodCount, | ||
| periodUnit: request.periodUnit, | ||
| recipient: request.recipient, | ||
| reference, | ||
| subscriptionExpires: request.subscriptionExpires, | ||
| subscriptionId: id, | ||
| timestamp: new Date().toISOString(), | ||
| } satisfies Subscription.SubscriptionRecord | ||
|
|
||
| return { | ||
| receipt: Subscription.createSubscriptionReceipt(record), | ||
| subscription: record, | ||
| } | ||
| }, |
There was a problem hiding this comment.
I thought most of this could be handled internally
7fea5da to
f451b0f
Compare
f451b0f to
ae2fd65
Compare
tempoxyz-cyclops-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
PR #331 adds Tempo subscription support (recurring TIP-20 payments via signed key authorizations), an authorize hook + stableBinding plumbing on Mppx, sequential compose() evaluation, an optional Receipt.subscriptionId, and a background renewSubscription helper. The implementation is well tested for single-tier happy paths, but workers verified several reachable issues in the new lifecycle. Most severe: the built-in automatic renewal path replays the activation KeyAuthorization on every renewal and is rejected by Tempo as KeyAlreadyExists, breaking recurring collection on a real node after period 0. Two distinct economic-binding gaps (cross-tier and overdue-period reuse) converge on the same root cause: authorize() and verify()/store.activate() reuse paths only check isActive(record) without comparing route binding fields or lastChargedPeriod. Recommend not merging until the inline findings tagged Critical/High are addressed.
Reviewer Callouts
- ⚡ Manual
activatehook contract (src/tempo/server/Subscription.ts:333-417): Customparameters.activateis passed the credential'saccessKeyandresolved.keywith no built-in consistency check. Automatic mode safely fails closed viasubmitSubscriptionPayment's stored-vs-supplied address comparison (:732-737); manual mode does not. Document that customactivateimplementations MUST verifyaccessKeymatches the application's expected access key forresolved.key. - ⚡
mppx.verifyCredential(credential)is side-effectful for subscription (src/server/Mppx.ts:368→subscription.verify→store.activate): The standalone "verify" call consumescredentialKey, may submit an on-chain transfer, and persists a record. Most users will assumeverifyCredentialis read-only. Document or split into check/settle phases. - ⚡
success()helper + SSE composition (src/server/Mppx.ts:502,src/tempo/server/internal/transport.ts:81-87):success()constructs a fake credential{ challenge, payload: {} }. Currently safe because the only method shippingauthorizeissubscription(HTTP). A futureauthorize+ SSE-stylerespondReceiptcomposition would throwNo SSE context available. Either hardensuccess()or document the constraint. - ⚡
paymentOfspreads function values into proxypaymentobject (src/proxy/Service.ts:209-220):_stableBinding,authorize,verify,request,respond,transportend up on the returnedpaymentobject. Dropped byJSON.stringifyin OpenAPI serialization, but a non-JSON serializer could leak closures oversecretKey/ store-private-keys. Explicitly destructure known function fields out ofrest. - ⚡
renewSubscriptionbackground helper ignoresactivationTimeoutMs(src/tempo/server/Subscription.ts:803): The background helper builds its own defaultSubscriptionStorewrapper, so cron always uses the default 15-minute timeout regardless of how the request-pathsubscription()was configured. Plumb timeouts into the background entrypoint. - ⚡
compose()ordering is now load-bearing (src/server/Mppx.ts:1287-1294): No-credential branch evaluatesauthorizehooks sequentially; first 200 wins. Document that side effects of earlier handlers may run even when a later handler would have served the request. - ⚡
Receipt.subscriptionIdis now part of the publicPayment-Receiptschema (src/Receipt.ts:23): Spec-additive, but.strict()downstream validators will reject. Worth calling out as forward-compatible. - ⚡
assertAuthorizationScopesonly requirestransferSelector(src/tempo/subscription/KeyAuthorization.ts:324-359): Clients must sign bothtransferandtransferWithMemo; the server submitstransferWithMemo, so a single-selector authorization passesverifybut reverts at simulation. Self-DoS, but worth documenting. - ⚡
request()hook can throwVerificationFailedErrorbefore HMAC is checked (src/tempo/server/Subscription.ts:163-166, called fromMppx.ts:465): A malformed credential whosechallenge.request.methodDetails.accessKeyis missing makes the request hook throw before Tier-1 HMAC runs. Express v4 hangs; v5/Hono/Next.js/Elysia return 500. WrapresolveRouteChallengein aPaymentError-aware try/catch increateMethodFnto normalize to a 402.
tempoxyz-cyclops-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review — wevm/mppx PR #331 (Tempo Subscriptions Hardening)
Summary
PR #331 introduces the Tempo subscription payment method (key-authorization–based recurring billing), the public authorize/stableBinding server-method hooks, dynamic SDK-managed access keys, activation replay protection, and renewal idempotency. The defensive design is broadly correct, but the activation/renewal state machines still contain multiple high-severity races and a broken verifyCredential path that re-introduce double-charge / orphaned-state outcomes after the previous "fix: address cyclops subscription findings" commit. There is also a DoS in the new compose dispatcher for any custom stableBinding. None of these break protocol-level signature/HMAC bindings, but they do break the documented billing semantics under realistic concurrency and externally reachable conditions.
Inline comments below mark each finding at the relevant changed line.
Reviewer Callouts
-
⚡
subscriptionBindingexcludeschainId→ compose dispatch always misses subscription candidates.src/tempo/server/Subscription.ts:83-94does not putchainIdindefaults, so_canonicalRequest.methodDetailsis undefined while issued challenges always carrymethodDetails.chainId. InMppx.compose()(src/server/Mppx.ts:1316-1322) the candidate set ends up empty and dispatch silently falls through to "first handler with matching name+intent". Either includemethodDetails.chainIdin subscriptiondefaultsor omitchainIdfromsubscriptionBinding. -
⚡ Unauthenticated state mutation in the no-credential path.
tempo.subscription.request()callsparameters.resolve(...)before any credential is presented and, in auto mode, unconditionally callsstore.getOrCreateAccessKey(resolved.key)(src/tempo/server/Subscription.ts:153-165,src/tempo/subscription/Store.ts:207-224), persisting a fresh secp256k1 private key per uniqueresolveoutput. Following the obvious test pattern (header → resolve → key) gives a developer an unauthenticated unbounded-storage primitive. Document thatresolvemust authenticate, or move key generation to the credential-bearing path. -
⚡
accessKeyis intentionally excluded fromsubscriptionBinding. Reviewers should confirm no future schema change introduces a route where the canonicalaccessKeylegitimately differs from the stored subscription's; silently bypassing the equality check would defeat per-credential authorization scoping. -
⚡ Fragile
withReceipt()error-string contract across 5 callsites.'withReceipt() requires a response argument'(src/server/Mppx.ts:547) is sentinel-matched bysrc/middlewares/{elysia,hono,nextjs,express}.tsandsrc/proxy/Proxy.ts. Export a typed sentinel error andinstanceof-check in adapters. -
⚡
Mppx.toNodeListener()does not probewithReceipt()for management responses. Unlike Hono/Elysia/Next/Express,src/server/Mppx.ts:1494-1501always callsresult.withReceipt(new Response())and only forwards thePayment-Receiptheader. -
⚡
credentialKeyactivation markers never expire (src/tempo/subscription/Store.ts:130-139). Every activation attempt — including failed ones — leaves a permanent entry. Consider a TTL keyed off the challengeexpires. -
⚡ Latest-period-only renewal is intentional (
src/tempo/server/Subscription.ts:841-864+ integration testrenews only the latest elapsed week period when multiple periods passed). After multiple missed periods,renew()charges once for the current period and advanceslastChargedPerioddirectly to it. A user who drains their wallet for several periods and tops up later effectively pauses billing. Make a deliberate product decision and document it. -
⚡ Background
renew()hooks can mutate stored amount/recipient/currency without detection.assertSubscriptionRequestMatchonly runs whenrequestis threaded intovalidateSubscriptionSettlement; background renewals do not. Worth a JSDoc note onrenew()that handlers must preserve those fields. -
⚡ Discovery (
x-payment-info) leaks every field of_canonicalRequestfor subscription routes includingsubscriptionExpires,periodCount,periodUnit,description,externalId(src/discovery/OpenApi.ts:204-208). Warn onsubscription.Parameters.externalIdthat the value is publicly advertised.
| ) | ||
| if (started.status !== 'started') return { status: 'inFlight' } | ||
|
|
||
| const result = await create().catch(async (error) => { |
There was a problem hiding this comment.
🚨 [SECURITY] Activation TOCTOU permits two settlements for the same lookupKey
SubscriptionStore.activate() reads the active lookup pointer (getByLookupKey at line 141) before atomically claiming activationKey(lookupKey) (lines 146-163), and never re-reads the lookup after the marker is acquired. A second activation with a distinct challenge id can complete the stale lookup read while a first activation is still inside create(), wait until the first commits and deletes the marker (lines 185-190), then acquire an empty marker and run its own create() — submitting a second first-period Tempo transfer for the same lookupKey. Both calls return activated; the later one overwrites lookupRecordKey and orphans the first record.
Recommended Fix: After atomically acquiring activationKey(lookupKey), re-execute getByLookupKey(lookupKey) and short-circuit to existing when isReusable(existing) is true (releasing the marker if it is owned by this challengeId). Or model claim + active-lookup ownership as a single atomic transition.
| return { | ||
| op: 'noop', | ||
| result: { status: 'inFlight' as const, subscription }, | ||
| } |
There was a problem hiding this comment.
🚨 [SECURITY] Renewal lock bypass across period boundaries causes orphaned claimMismatch charges
The in-flight guard only blocks when existing.inFlightPeriod === periodIndex. If a renewal is in flight at the end of period N and a second caller runs after getPeriodIndex() advances to N+1, the same-period check is false, the second caller overwrites the in-flight marker with inFlightPeriod=N+1, and a second on-chain payment is submitted. The first renewal's commit (lines 285-309) then sees existing.inFlightPeriod !== N, returns claimMismatch, and settleRenewal() throws VerificationFailedError (Subscription.ts:466-468) — but the first payment has already settled on-chain. Net result: user paid twice; server records only the second period.
Recommended Fix: Block any non-stale in-flight renewal regardless of period:
if (
subscription.inFlightPeriod !== undefined &&
!isStaleRenewal(subscription, renewalTimeoutMs)
) {
return { op: 'noop', result: { status: 'inFlight' as const, subscription } }
}And/or use the per-period inFlightReference as an on-chain idempotency key.
| store, | ||
| subscription, | ||
| }) | ||
| if (!renewal) return undefined |
There was a problem hiding this comment.
🚨 [SECURITY] In-flight renewal returns 402, enabling double-charge + orphaned active subscription
When store.renew() returns { status: 'inFlight' }, settleRenewal() (lines 462-469) does not match inFlight and falls through to return null; this authorize hook converts null to undefined; Mppx then issues a fresh 402 challenge (src/server/Mppx.ts:560-595). The auto-paying client wrapper (src/client/internal/Fetch.ts:68-98) fulfills it, store.activate() sees the old record as overdue/non-reusable and submits a brand-new on-chain payment. When the original in-flight renewal later commits, Store.renew() unconditionally writes lookupRecordKey(...) = oldSubscriptionId (src/tempo/subscription/Store.ts:285-309), overwriting the freshly-activated subscription pointer and orphaning the second paid record.
Recommended Fix: Treat inFlight in settleRenewal() as a non-payment outcome — return a managed response (e.g. 409 Conflict / 503 Retry-After) rather than undefined. Throwing PaymentError is not enough; Mppx catches it and still emits a 402. Additionally, make the renewal commit at Store.ts:308 conditional on lookupRecordKey(...) still pointing at this subscription id (CAS) so a stale renewal cannot reclaim ownership of a newer activation.
|
|
||
| async verify({ credential, envelope, request }) { | ||
| const input = requestFromCaptured(envelope?.capturedRequest) | ||
| const parsedRequest = Methods.subscription.schema.request.parse(request) |
There was a problem hiding this comment.
🚨 [SECURITY] Mppx.verifyCredential(subscriptionCredential) always throws ZodError("decimals")
subscription.verify() calls Methods.subscription.schema.request.parse(request) here. The subscription request schema requires decimals: z.number() as input, and its transform consumes and drops decimals from the canonical output (src/tempo/Methods.ts:284-326). Challenges are issued in canonical form — no decimals. The documented public API Mppx.verifyCredential(credential) (src/server/Mppx.ts:285-374), which JSDoc says may "activate or renew a subscription", forwards credential.challenge.request directly when called with no route options. The parse(...) then throws before store.activate(...) runs, silently breaking subscription activation/renewal for MCP back-ends, JSON-RPC bridges, queue workers, and any out-of-band verifier.
Recommended Fix: Mirror Charge.verify/Session.verify (src/tempo/server/Charge.ts:159-165, src/tempo/server/Session.ts:191-196):
const parsed = Methods.subscription.schema.request.safeParse(request)
const parsedRequest = parsed.success
? parsed.data
: (request as z.output<typeof Methods.subscription.schema.request>)Add a regression test that calls mppx.verifyCredential(subscriptionCredential) end-to-end.
| store, | ||
| subscription: record, | ||
| }) | ||
| return renewal?.status === 'renewed' ? renewal.result : null |
There was a problem hiding this comment.
🚨 [SECURITY] Background renewSubscription() charges superseded records and rolls back the active lookup pointer
renew() (the exported background helper, src/tempo/server/Methods.ts:34-35) loads the record by raw subscriptionId and never checks store.getByKey(record.lookupKey)?.subscriptionId === record.subscriptionId. After a user activates a replacement subscription B for the same lookupKey, the old record A is left intact (src/tempo/subscription/Store.ts:226-228 only updates the lookup pointer; A is never marked superseded). A scheduled renew(A.subscriptionId) then bills the user via the renewal hook for the superseded plan, and Store.renew() unconditionally writes lookupRecordKey(lookupKey) = A (Store.ts:308), overwriting B and orphaning the newer paid subscription.
Recommended Fix: Make active-lookup ownership a renewal precondition: in renew() (and inside Store.renew() before commit), assert store.getByKey(record.lookupKey)?.subscriptionId === record.subscriptionId and short-circuit to a superseded status otherwise. When a new activation replaces an active record for the same lookupKey, mark the old record canceledAt/revokedAt so id-based renewal APIs fail closed.
| ? getRequestBindingMismatch( | ||
| getStableBinding(internal._canonicalRequest, internal._stableBinding), | ||
| getStableBinding(credReq, internal._stableBinding), | ||
| ) |
There was a problem hiding this comment.
stableBinding before HMAC/schema validation (DoS)
Method.StableBindingFn is publicly typed as (request: z.output<schema.request>) => Record<string, unknown> (src/Method.ts:195-197), telling method authors the input is parsed/canonical. But here in compose() candidate filtering, getStableBinding(credReq, internal._stableBinding) invokes the hook on the untrusted credential.challenge.request before any HMAC, expiry, or method-schema validation — the generic Credential.deserialize only enforces that the challenge request is a record. A reasonable custom binding such as request.currency.toLowerCase() will throw on a forged credential missing required fields, escaping the dispatcher and producing a 500 instead of a normal 402 challenge. The bundled subscriptionBinding is defensive, so this is primarily a footgun for downstream method authors, but the public type contract is misleading.
Recommended Fix: (a) wrap the candidate-filter stableBinding calls in try/catch and treat throwers as non-candidates, (b) safeParse credReq against the method schema before calling the hook, or (c) document and enforce that custom stableBinding must be defensive against missing fields. Pick at least one.
Summary
Added Tempo subscription support with activation, renewal, reusable subscription authorization, adapter management-response handling, compose dispatch hardening, and subscription lifecycle race protection.