Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/require-subscription-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added credential-required Tempo subscription reuse and signed-source lookup support so active subscriptions could be bound to the recovered payer instead of request metadata alone.
12 changes: 6 additions & 6 deletions examples/subscription/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Tempo Subscription

Recurring access-key subscription for a news app. The server charges `0.10` pathUSD per day by resolving the user to `{ key, accessKey }`, returning that dynamic Tempo access key in the MPP challenge, then requiring a `keyAuthorization` scoped to that key.
Recurring access-key subscription for a news app. The server charges `0.10` pathUSD per day by deriving the subscription key from the signed payer identity, returning a Tempo access key in the MPP challenge, then requiring a `keyAuthorization` scoped to that key.

The example keeps billing deterministic for local development: `activate` and `renew` simulate the transfer that a production app would submit with the resolved access key, then persist the subscription record and receipt.
The example opts into credential-required reuse, so access is keyed from the payer signature instead of a user-controlled header.

## Setup

Expand All @@ -27,14 +27,14 @@ pnpm client

The client:

1. Requests `/api/article` and receives a `402` challenge that includes the dynamic access key for `user-1` and the `monthly` plan.
2. Signs a `keyAuthorization` for that access key and activates the subscription.
3. Requests `/api/article` again, reusing the active subscription with the same access key.
1. Requests `/api/article` and receives a `402` challenge with a server access key.
2. Signs a `keyAuthorization`; the server derives the subscription key from the recovered payer.
3. Requests `/api/article` again, signs a fresh credential, and reuses the active subscription without another charge.

## Test with mppx CLI

With the server running, use the `mppx` CLI to inspect the challenge:

```bash
pnpm mppx localhost:5173/api/article -H 'X-User-Id: user-1'
pnpm mppx localhost:5173/api/article
```
9 changes: 4 additions & 5 deletions examples/subscription/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { Chain } from 'viem/tempo'

const baseUrl = process.env.BASE_URL ?? 'http://localhost:5173'
const userId = process.env.USER_ID ?? 'user-1'
const account = privateKeyToAccount((process.env.PRIVATE_KEY as Hex) ?? generatePrivateKey())

const client = createClient({
Expand All @@ -29,9 +28,7 @@ const mppx = Mppx.create({
})

async function readArticle(label: string) {
const response = await mppx.fetch(`${baseUrl}/api/article`, {
headers: { 'X-User-Id': userId },
})
const response = await mppx.fetch(`${baseUrl}/api/article`)
if (!response.ok) throw new Error(`article request failed: ${response.status}`)

const receipt = Receipt.fromResponse(response)
Expand All @@ -50,6 +47,8 @@ console.log('Run the server with an overdue stored subscription to exercise rene

await readArticle('Reused access')

const subscriptionResponse = await fetch(`${baseUrl}/api/subscription?userId=${userId}`)
const subscriptionResponse = await fetch(
`${baseUrl}/api/subscription?address=${account.address}&chainId=${client.chain.id}`,
)
const subscription = (await subscriptionResponse.json()) as Subscription.SubscriptionRecord
console.log(`lastChargedPeriod=${subscription.lastChargedPeriod}`)
25 changes: 14 additions & 11 deletions examples/subscription/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ const account = privateKeyToAccount(generatePrivateKey())
const store = Store.memory()
const subscriptions = Subscription.fromStore(store)

function subscriptionKey(userId: string) {
return `news:${userId}:${planId}`
function subscriptionKey(source: { address: string; chainId: number }) {
return `news:eip155:${source.chainId}:${source.address.toLowerCase()}:${planId}`
}

function getUserId(request: Request) {
return request.headers.get('X-User-Id') ?? new URL(request.url).searchParams.get('userId')
function subscriptionKeyFromUrl(url: URL) {
const address = url.searchParams.get('address')
const chainId = Number(url.searchParams.get('chainId') ?? '4217')
if (!address || !Number.isSafeInteger(chainId)) return null
return subscriptionKey({ address, chainId })
}

const mppx = Mppx.create({
Expand All @@ -32,10 +35,10 @@ const mppx = Mppx.create({
periodCount,
periodUnit,
recipient: account.address,
resolve: async ({ input }) => {
const userId = getUserId(input)
if (!userId) return null
return { key: subscriptionKey(userId) }
requireCredential: true,
resolve: async ({ source }) => {
if (!source) return null
return { key: subscriptionKey(source) }
},
store,
subscriptionExpires,
Expand All @@ -57,9 +60,9 @@ export async function handler(request: Request): Promise<Response | null> {
if (url.pathname === '/api/health') return Response.json({ status: 'ok' })

if (url.pathname === '/api/subscription') {
const userId = getUserId(request)
if (!userId) return Response.json({ error: 'missing userId' }, { status: 400 })
return Response.json(await subscriptions.getByKey(subscriptionKey(userId)))
const key = subscriptionKeyFromUrl(url)
if (!key) return Response.json({ error: 'missing address' }, { status: 400 })
return Response.json(await subscriptions.getByKey(key))
}

if (url.pathname === '/api/article') {
Expand Down
211 changes: 210 additions & 1 deletion src/tempo/server/Subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const subscriptionRecipient = '0x1234567890abcdef1234567890abcdef12345678'
const rootAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000001',
)
const otherRootAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000004',
)
const accessAccount = privateKeyToAccount(
'0x0000000000000000000000000000000000000000000000000000000000000002',
)
Expand Down Expand Up @@ -81,10 +84,11 @@ async function createCredential(
challenge: Challenge.Challenge,
source = rootAccount.address,
key: SubscriptionAccessKey = accessKey,
account = rootAccount,
) {
const keyAuthorization = await signSubscriptionKeyAuthorization({
accessKey: key,
account: rootAccount,
account,
chainId,
request: challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>,
})
Expand Down Expand Up @@ -271,6 +275,211 @@ describe('tempo.subscription', () => {
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1)
})

test('requires a fresh credential for active subscription reuse when configured', async () => {
const store = Store.memory()
const subscriptions = SubscriptionStore.fromStore(store)
await subscriptions.put(
createRecord({
accessKey,
payer: { address: rootAccount.address, chainId },
}),
)
const method = subscription({
activate: async () => {
throw new Error('active subscription reuse should not activate')
},
amount: subscriptionAmount,
chainId,
currency: subscriptionCurrency,
periodCount: subscriptionPeriodCount,
periodUnit: subscriptionPeriodUnit,
recipient: subscriptionRecipient,
requireCredential: true,
resolve: async () => ({ accessKey, key: subscriptionKey }),
store,
subscriptionExpires: activeSubscriptionExpires,
})

const mppx = Mppx.create({ methods: [method], realm, secretKey })
const unauthenticated = await mppx.tempo.subscription({})(
new Request('https://example.com/resource'),
)

expect(unauthenticated.status).toBe(402)
if (unauthenticated.status !== 402) throw new Error('expected reuse challenge')

const challenge = Challenge.fromResponse(unauthenticated.challenge)
const credential = await createCredential(challenge)
const reused = await mppx.tempo.subscription({})(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(reused.status).toBe(200)
})

test('can derive the subscription lookup key only from the signed credential source', async () => {
const store = Store.memory()
const subscriptions = SubscriptionStore.fromStore(store)
const { client, rpcMethods } = createBillingClient([hashActivate])
const sourceKey = (source: { address: string; chainId: number }) =>
`payer:${source.chainId}:${source.address.toLowerCase()}:pro`
const method = subscription({
amount: subscriptionAmount,
chainId,
currency: subscriptionCurrency,
getClient: async () => client,
periodCount: subscriptionPeriodCount,
periodUnit: subscriptionPeriodUnit,
recipient: subscriptionRecipient,
requireCredential: true,
resolve: async ({ source }) => {
if (!source) return null
return { key: sourceKey(source) }
},
store,
subscriptionExpires: activeSubscriptionExpires,
waitForConfirmation: false,
})

const mppx = Mppx.create({ methods: [method], realm, secretKey })
const challengeResult = await mppx.tempo.subscription({})(
new Request('https://example.com/resource'),
)

expect(challengeResult.status).toBe(402)
if (challengeResult.status !== 402) throw new Error('expected source challenge')

const challenge = Challenge.fromResponse(challengeResult.challenge)
const challengeAccessKey = (
challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>
).methodDetails?.accessKey
if (!challengeAccessKey) throw new Error('expected challenge access key')

const credential = await createCredential(challenge, rootAccount.address, challengeAccessKey)
const activated = await mppx.tempo.subscription({})(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(activated.status).toBe(200)
const lookupKey = sourceKey({ address: rootAccount.address, chainId })
const record = await subscriptions.getByKey(lookupKey)
expect(record?.lookupKey).toBe(lookupKey)
expect(record?.payer?.address.toLowerCase()).toBe(rootAccount.address.toLowerCase())
expect(record?.accessKey).toEqual(challengeAccessKey)
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1)
})

test('rejects credential reuse when the signer does not match the stored payer', async () => {
const store = Store.memory()
const subscriptions = SubscriptionStore.fromStore(store)
await subscriptions.put(
createRecord({
accessKey,
payer: { address: rootAccount.address, chainId },
}),
)
const method = subscription({
activate: async () => {
throw new Error('active subscription reuse should not activate')
},
amount: subscriptionAmount,
chainId,
currency: subscriptionCurrency,
periodCount: subscriptionPeriodCount,
periodUnit: subscriptionPeriodUnit,
recipient: subscriptionRecipient,
requireCredential: true,
resolve: async () => ({ accessKey, key: subscriptionKey }),
store,
subscriptionExpires: activeSubscriptionExpires,
})

const mppx = Mppx.create({ methods: [method], realm, secretKey })
const challengeResult = await mppx.tempo.subscription({})(
new Request('https://example.com/resource'),
)
if (challengeResult.status !== 402) throw new Error('expected reuse challenge')

const challenge = Challenge.fromResponse(challengeResult.challenge)
const credential = await createCredential(
challenge,
otherRootAccount.address,
accessKey,
otherRootAccount,
)
const rejected = await mppx.tempo.subscription({})(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(rejected.status).toBe(402)
if (rejected.status !== 402) throw new Error('expected payer mismatch challenge')
const body = await rejected.challenge.json()
expect(body.detail).toBe('Payment verification failed: subscription payer mismatch.')
})

test('renews an overdue active subscription after credential-required payer proof', async () => {
const store = Store.memory()
const subscriptions = SubscriptionStore.fromStore(store)
await subscriptions.put(
createRecord({
accessKey,
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
lastChargedPeriod: 0,
payer: { address: rootAccount.address, chainId },
reference: hashStale,
}),
)
const method = subscription({
activate: async () => {
throw new Error('active subscription renewal should not activate')
},
amount: subscriptionAmount,
chainId,
currency: subscriptionCurrency,
periodCount: subscriptionPeriodCount,
periodUnit: subscriptionPeriodUnit,
recipient: subscriptionRecipient,
renew: async ({ periodIndex, subscription }) => ({
receipt: createReceipt(subscription.subscriptionId, hashRenewed),
subscription: {
...subscription,
lastChargedPeriod: periodIndex,
reference: hashRenewed,
},
}),
requireCredential: true,
resolve: async () => ({ accessKey, key: subscriptionKey }),
store,
subscriptionExpires: activeSubscriptionExpires,
})

const mppx = Mppx.create({ methods: [method], realm, secretKey })
const challengeResult = await mppx.tempo.subscription({})(
new Request('https://example.com/resource'),
)
if (challengeResult.status !== 402) throw new Error('expected reuse challenge')

const challenge = Challenge.fromResponse(challengeResult.challenge)
const credential = await createCredential(challenge)
const renewed = await mppx.tempo.subscription({})(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(renewed.status).toBe(200)
if (renewed.status !== 200) throw new Error('expected credential renewal')
const receipt = Receipt.fromResponse(renewed.withReceipt(new Response('OK')))
expect(receipt.reference).toBe(hashRenewed)
expect((await subscriptions.getByKey(subscriptionKey))?.lastChargedPeriod).toBeGreaterThan(0)
})

test('verifyCredential activates a subscription credential with a canonical challenge request', async () => {
const store = Store.memory()
const { client } = createBillingClient([hashActivate])
Expand Down
Loading
Loading