mppx 0.6.19 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +15 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +190 -40
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- package/src/middlewares/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +23 -1
- package/src/server/Mppx.test.ts +73 -3
- package/src/server/Mppx.ts +291 -58
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- package/src/zod.ts +24 -0
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
import { Base64 } from 'ox'
|
|
2
|
+
import { KeyAuthorization } from 'ox/tempo'
|
|
3
|
+
import { encodeFunctionData, isAddressEqual, type Address, type Client as ViemClient } from 'viem'
|
|
4
|
+
import {
|
|
5
|
+
call as viem_call,
|
|
6
|
+
sendRawTransaction,
|
|
7
|
+
sendRawTransactionSync,
|
|
8
|
+
signTransaction,
|
|
9
|
+
} from 'viem/actions'
|
|
10
|
+
import { tempo as tempo_chain } from 'viem/chains'
|
|
11
|
+
import { Abis, Account as TempoAccount, Transaction } from 'viem/tempo'
|
|
12
|
+
|
|
13
|
+
import { VerificationFailedError } from '../../Errors.js'
|
|
14
|
+
import type { LooseOmit, MaybePromise, NoExtraKeys } from '../../internal/types.js'
|
|
15
|
+
import * as Method from '../../Method.js'
|
|
16
|
+
import * as Store from '../../Store.js'
|
|
17
|
+
import type * as Client from '../../viem/Client.js'
|
|
18
|
+
import * as ClientResolver from '../../viem/Client.js'
|
|
19
|
+
import * as Attribution from '../Attribution.js'
|
|
20
|
+
import * as Account from '../internal/account.js'
|
|
21
|
+
import * as defaults from '../internal/defaults.js'
|
|
22
|
+
import * as Proof from '../internal/proof.js'
|
|
23
|
+
import type * as types from '../internal/types.js'
|
|
24
|
+
import * as Methods from '../Methods.js'
|
|
25
|
+
import {
|
|
26
|
+
assertSubscriptionTiming,
|
|
27
|
+
toSubscriptionPeriodSeconds,
|
|
28
|
+
verifySubscriptionKeyAuthorization,
|
|
29
|
+
} from '../subscription/KeyAuthorization.js'
|
|
30
|
+
import * as SubscriptionReceipt from '../subscription/Receipt.js'
|
|
31
|
+
import * as SubscriptionStore from '../subscription/Store.js'
|
|
32
|
+
import type {
|
|
33
|
+
SubscriptionAccessKey,
|
|
34
|
+
SubscriptionCredentialPayload,
|
|
35
|
+
SubscriptionLookup,
|
|
36
|
+
SubscriptionPeriodUnit,
|
|
37
|
+
SubscriptionRecord,
|
|
38
|
+
SubscriptionReceipt as SubscriptionReceiptValue,
|
|
39
|
+
} from '../subscription/Types.js'
|
|
40
|
+
|
|
41
|
+
type SubscriptionRequest = ReturnType<typeof Methods.subscription.schema.request.parse>
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a Tempo subscription method for recurring TIP-20 token payments.
|
|
45
|
+
*
|
|
46
|
+
* The method handles activation, request-path reuse, and optional lazy renewals.
|
|
47
|
+
*/
|
|
48
|
+
export function subscription<const parameters extends subscription.Parameters>(
|
|
49
|
+
p: NoExtraKeys<parameters, subscription.Parameters>,
|
|
50
|
+
) {
|
|
51
|
+
const parameters = p as parameters
|
|
52
|
+
const rawStore = (parameters.store ?? Store.memory()) as Store.AtomicStore<
|
|
53
|
+
Record<string, unknown>
|
|
54
|
+
>
|
|
55
|
+
if (typeof rawStore.update !== 'function') {
|
|
56
|
+
throw new Error('tempo.subscription() requires an atomic store with `update`.')
|
|
57
|
+
}
|
|
58
|
+
const defaultChainId = parameters.chainId ?? defaults.chainId.testnet
|
|
59
|
+
const {
|
|
60
|
+
amount,
|
|
61
|
+
currency = defaults.resolveCurrency({ chainId: defaultChainId }),
|
|
62
|
+
decimals = defaults.decimals,
|
|
63
|
+
description,
|
|
64
|
+
externalId,
|
|
65
|
+
periodCount,
|
|
66
|
+
periodUnit,
|
|
67
|
+
subscriptionExpires,
|
|
68
|
+
waitForConfirmation = true,
|
|
69
|
+
} = parameters
|
|
70
|
+
|
|
71
|
+
const store = SubscriptionStore.fromStore(rawStore, {
|
|
72
|
+
activationTimeoutMs: parameters.activationTimeoutMs,
|
|
73
|
+
renewalTimeoutMs: parameters.renewalTimeoutMs,
|
|
74
|
+
})
|
|
75
|
+
const { recipient } = Account.resolve(parameters)
|
|
76
|
+
const getClient = ClientResolver.getResolver({
|
|
77
|
+
chain: tempo_chain,
|
|
78
|
+
getClient: parameters.getClient,
|
|
79
|
+
rpcUrl: defaults.rpcUrl,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
type Defaults = subscription.DeriveDefaults<parameters>
|
|
83
|
+
return Method.toServer<typeof Methods.subscription, Defaults>(Methods.subscription, {
|
|
84
|
+
defaults: {
|
|
85
|
+
amount,
|
|
86
|
+
currency,
|
|
87
|
+
decimals,
|
|
88
|
+
description,
|
|
89
|
+
externalId,
|
|
90
|
+
periodCount,
|
|
91
|
+
periodUnit,
|
|
92
|
+
recipient,
|
|
93
|
+
subscriptionExpires,
|
|
94
|
+
} as unknown as Defaults,
|
|
95
|
+
|
|
96
|
+
async authorize({ input, request }) {
|
|
97
|
+
const resolved = await parameters.resolve({ input, request })
|
|
98
|
+
if (!resolved) return undefined
|
|
99
|
+
|
|
100
|
+
const subscription = await store.getByKey(resolved.key)
|
|
101
|
+
if (!subscription || !isActive(subscription)) return undefined
|
|
102
|
+
if (!subscriptionMatchesRequest(subscription, request)) return undefined
|
|
103
|
+
|
|
104
|
+
const periodIndex = getPeriodIndex(subscription)
|
|
105
|
+
if (periodIndex > subscription.lastChargedPeriod) {
|
|
106
|
+
const renew = resolveRenewalHandler({
|
|
107
|
+
getClient,
|
|
108
|
+
parameters,
|
|
109
|
+
store,
|
|
110
|
+
subscription,
|
|
111
|
+
waitForConfirmation,
|
|
112
|
+
})
|
|
113
|
+
if (!renew) return undefined
|
|
114
|
+
|
|
115
|
+
const renewal = await settleRenewal({
|
|
116
|
+
expectedLookupKey: resolved.key,
|
|
117
|
+
periodIndex,
|
|
118
|
+
renew,
|
|
119
|
+
request,
|
|
120
|
+
store,
|
|
121
|
+
subscription,
|
|
122
|
+
})
|
|
123
|
+
if (!renewal) return undefined
|
|
124
|
+
if (renewal.status === 'charged') return { receipt: renewal.receipt }
|
|
125
|
+
if (renewal.status === 'inFlight') {
|
|
126
|
+
return {
|
|
127
|
+
receipt: renewal.receipt,
|
|
128
|
+
response: new Response(null, {
|
|
129
|
+
headers: { 'Retry-After': '1' },
|
|
130
|
+
status: 409,
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await parameters.hooks?.renewed?.({
|
|
136
|
+
periodIndex,
|
|
137
|
+
receipt: renewal.result.receipt,
|
|
138
|
+
subscription: renewal.result.subscription,
|
|
139
|
+
})
|
|
140
|
+
return {
|
|
141
|
+
receipt: renewal.result.receipt,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
receipt: SubscriptionReceipt.fromRecord(subscription),
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async request({ capturedRequest, credential, request }) {
|
|
151
|
+
const credentialRequest = credential?.challenge.request as SubscriptionRequest | undefined
|
|
152
|
+
const chainId =
|
|
153
|
+
request.chainId ??
|
|
154
|
+
parameters.chainId ??
|
|
155
|
+
credentialRequest?.methodDetails?.chainId ??
|
|
156
|
+
defaults.chainId.testnet
|
|
157
|
+
const parsedRequest = Methods.subscription.schema.request.parse({
|
|
158
|
+
...request,
|
|
159
|
+
chainId,
|
|
160
|
+
})
|
|
161
|
+
const input = requestFromCaptured(capturedRequest)
|
|
162
|
+
const resolved = await parameters.resolve({ input, request: parsedRequest })
|
|
163
|
+
const existing = resolved ? await store.getByKey(resolved.key) : null
|
|
164
|
+
const accessKey =
|
|
165
|
+
resolved && !credential
|
|
166
|
+
? await resolveChallengeAccessKey({
|
|
167
|
+
existing,
|
|
168
|
+
input,
|
|
169
|
+
parameters,
|
|
170
|
+
request: parsedRequest,
|
|
171
|
+
resolved,
|
|
172
|
+
store,
|
|
173
|
+
})
|
|
174
|
+
: (credentialRequest?.methodDetails?.accessKey ?? parsedRequest.methodDetails?.accessKey)
|
|
175
|
+
if (!accessKey) {
|
|
176
|
+
throw new VerificationFailedError({ reason: 'subscription accessKey is missing' })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Challenges carry the server-generated key in methodDetails so the shared request shape stays spec-compatible.
|
|
180
|
+
return {
|
|
181
|
+
...request,
|
|
182
|
+
methodDetails: {
|
|
183
|
+
...request.methodDetails,
|
|
184
|
+
accessKey,
|
|
185
|
+
},
|
|
186
|
+
chainId,
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
stableBinding: subscriptionBinding,
|
|
191
|
+
|
|
192
|
+
async verify({ credential, envelope, request }) {
|
|
193
|
+
const input = requestFromCaptured(envelope?.capturedRequest)
|
|
194
|
+
const parsed = Methods.subscription.schema.request.safeParse(request)
|
|
195
|
+
const parsedRequest = parsed.success ? parsed.data : (request as SubscriptionRequest)
|
|
196
|
+
assertSubscriptionTiming({
|
|
197
|
+
challengeExpires: credential.challenge.expires,
|
|
198
|
+
request: parsedRequest,
|
|
199
|
+
})
|
|
200
|
+
const resolved = await parameters.resolve({ input, request: parsedRequest })
|
|
201
|
+
|
|
202
|
+
if (!resolved) {
|
|
203
|
+
throw new VerificationFailedError({ reason: 'subscription could not be resolved' })
|
|
204
|
+
}
|
|
205
|
+
const challengeRequest = credential.challenge.request as SubscriptionRequest
|
|
206
|
+
const accessKey =
|
|
207
|
+
challengeRequest.methodDetails?.accessKey ??
|
|
208
|
+
parsedRequest.methodDetails?.accessKey ??
|
|
209
|
+
(await resolveAccessKey({ input, parameters, request: parsedRequest, resolved }))
|
|
210
|
+
if (!accessKey) {
|
|
211
|
+
throw new VerificationFailedError({ reason: 'subscription accessKey is missing' })
|
|
212
|
+
}
|
|
213
|
+
const verified = verifySubscriptionKeyAuthorization({
|
|
214
|
+
accessKey,
|
|
215
|
+
chainId: parsedRequest.methodDetails?.chainId ?? defaults.chainId.testnet,
|
|
216
|
+
payload: credential.payload as SubscriptionCredentialPayload,
|
|
217
|
+
request: parsedRequest,
|
|
218
|
+
})
|
|
219
|
+
const declaredSource = credential.source ? Proof.parsePkhSource(credential.source) : null
|
|
220
|
+
if (
|
|
221
|
+
declaredSource &&
|
|
222
|
+
(declaredSource.chainId !== verified.source.chainId ||
|
|
223
|
+
!isAddressEqual(declaredSource.address, verified.source.address))
|
|
224
|
+
) {
|
|
225
|
+
throw new VerificationFailedError({ reason: 'credential source does not match signature' })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const activation = await store.activate({
|
|
229
|
+
challengeId: credential.challenge.id,
|
|
230
|
+
isReusable: (subscription) => isReusableSubscription(subscription, parsedRequest),
|
|
231
|
+
lookupKey: resolved.key,
|
|
232
|
+
async create() {
|
|
233
|
+
const activation = withSubscriptionAccessKey(
|
|
234
|
+
await activateSubscription({
|
|
235
|
+
accessKey,
|
|
236
|
+
auto: {
|
|
237
|
+
challengeId: credential.challenge.id,
|
|
238
|
+
getClient,
|
|
239
|
+
keyAuthorization: (credential.payload as SubscriptionCredentialPayload).signature,
|
|
240
|
+
realm: credential.challenge.realm,
|
|
241
|
+
store,
|
|
242
|
+
waitForConfirmation,
|
|
243
|
+
},
|
|
244
|
+
credential: credential as typeof credential & {
|
|
245
|
+
payload: SubscriptionCredentialPayload
|
|
246
|
+
},
|
|
247
|
+
input,
|
|
248
|
+
parameters,
|
|
249
|
+
request: parsedRequest,
|
|
250
|
+
resolved,
|
|
251
|
+
source: verified.source,
|
|
252
|
+
}),
|
|
253
|
+
accessKey,
|
|
254
|
+
)
|
|
255
|
+
validateSubscriptionSettlement(activation, {
|
|
256
|
+
expectedLookupKey: resolved.key,
|
|
257
|
+
expectedPeriodIndex: 0,
|
|
258
|
+
request: parsedRequest,
|
|
259
|
+
})
|
|
260
|
+
return activation
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
if (activation.status === 'replayed') {
|
|
264
|
+
throw new VerificationFailedError({
|
|
265
|
+
reason: 'subscription credential has already been used',
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
if (activation.status === 'inFlight') {
|
|
269
|
+
throw new VerificationFailedError({
|
|
270
|
+
reason: 'subscription activation is already in flight',
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
if (activation.status === 'claimMismatch') {
|
|
274
|
+
throw new VerificationFailedError({ reason: 'subscription activation claim mismatch' })
|
|
275
|
+
}
|
|
276
|
+
if (activation.status === 'existing') {
|
|
277
|
+
return SubscriptionReceipt.fromRecord(activation.subscription)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await parameters.hooks?.activated?.({
|
|
281
|
+
receipt: activation.result.receipt,
|
|
282
|
+
subscription: activation.result.subscription,
|
|
283
|
+
})
|
|
284
|
+
return activation.result.receipt
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function requestFromCaptured(capturedRequest: Method.CapturedRequest | undefined): Request {
|
|
290
|
+
if (!capturedRequest) return new Request('https://subscription.invalid')
|
|
291
|
+
return new Request(capturedRequest.url, {
|
|
292
|
+
headers: capturedRequest.headers,
|
|
293
|
+
method: capturedRequest.method,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function resolveAccessKey(parameters: {
|
|
298
|
+
input: Request
|
|
299
|
+
parameters: subscription.Parameters
|
|
300
|
+
request: SubscriptionRequest
|
|
301
|
+
resolved: subscription.ResolvedSubscription
|
|
302
|
+
}) {
|
|
303
|
+
const { input, parameters: subscriptionParameters, request, resolved } = parameters
|
|
304
|
+
return (
|
|
305
|
+
resolved.accessKey ??
|
|
306
|
+
(subscriptionParameters.accessKey
|
|
307
|
+
? await subscriptionParameters.accessKey({ input, request, resolved })
|
|
308
|
+
: undefined)
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolveChallengeAccessKey(parameters: {
|
|
313
|
+
existing: SubscriptionRecord | null
|
|
314
|
+
input: Request
|
|
315
|
+
parameters: subscription.Parameters
|
|
316
|
+
request: SubscriptionRequest
|
|
317
|
+
resolved: subscription.ResolvedSubscription
|
|
318
|
+
store: SubscriptionStore.SubscriptionStore
|
|
319
|
+
}) {
|
|
320
|
+
const {
|
|
321
|
+
existing,
|
|
322
|
+
input,
|
|
323
|
+
parameters: subscriptionParameters,
|
|
324
|
+
request,
|
|
325
|
+
resolved,
|
|
326
|
+
store,
|
|
327
|
+
} = parameters
|
|
328
|
+
if (!subscriptionParameters.activate) {
|
|
329
|
+
// In automatic mode, the SDK owns the server access key so apps can issue
|
|
330
|
+
// challenges from only their resolved subscription lookup key.
|
|
331
|
+
const accessKey = await store.getOrCreateAccessKey(resolved.key)
|
|
332
|
+
return {
|
|
333
|
+
accessKeyAddress: accessKey.accessKeyAddress,
|
|
334
|
+
keyType: accessKey.keyType,
|
|
335
|
+
} satisfies SubscriptionAccessKey
|
|
336
|
+
}
|
|
337
|
+
// Manual activation keeps the lower-level API: callers can provide the
|
|
338
|
+
// access key for new challenges, while active subscriptions reuse the stored key.
|
|
339
|
+
return (
|
|
340
|
+
(await resolveAccessKey({ input, parameters: subscriptionParameters, request, resolved })) ??
|
|
341
|
+
(existing && isActive(existing) ? existing.accessKey : undefined)
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function activateSubscription(parameters: {
|
|
346
|
+
accessKey: SubscriptionAccessKey
|
|
347
|
+
auto: {
|
|
348
|
+
challengeId: string
|
|
349
|
+
getClient: (parameters: { chainId?: number | undefined }) => MaybePromise<ViemClient>
|
|
350
|
+
keyAuthorization: `0x${string}`
|
|
351
|
+
realm: string
|
|
352
|
+
store: SubscriptionStore.SubscriptionStore
|
|
353
|
+
waitForConfirmation: boolean
|
|
354
|
+
}
|
|
355
|
+
credential: {
|
|
356
|
+
payload: SubscriptionCredentialPayload
|
|
357
|
+
source?: string | undefined
|
|
358
|
+
}
|
|
359
|
+
input: Request
|
|
360
|
+
parameters: subscription.Parameters
|
|
361
|
+
request: SubscriptionRequest
|
|
362
|
+
resolved: subscription.ResolvedSubscription
|
|
363
|
+
source: { address: Address; chainId: number } | null
|
|
364
|
+
}) {
|
|
365
|
+
const {
|
|
366
|
+
accessKey,
|
|
367
|
+
auto,
|
|
368
|
+
credential,
|
|
369
|
+
input,
|
|
370
|
+
parameters: subscriptionParameters,
|
|
371
|
+
request,
|
|
372
|
+
resolved,
|
|
373
|
+
source,
|
|
374
|
+
} = parameters
|
|
375
|
+
if (subscriptionParameters.activate) {
|
|
376
|
+
// A custom activate hook owns settlement and record creation.
|
|
377
|
+
return subscriptionParameters.activate({
|
|
378
|
+
accessKey,
|
|
379
|
+
credential,
|
|
380
|
+
input,
|
|
381
|
+
request,
|
|
382
|
+
resolved,
|
|
383
|
+
source,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
if (!source) {
|
|
387
|
+
throw new VerificationFailedError({ reason: 'subscription payer is missing' })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Automatic activation bills the first period and persists the recurring
|
|
391
|
+
// billing authority needed for request-path and background renewals.
|
|
392
|
+
const reference = await submitSubscriptionPayment({
|
|
393
|
+
accessKey,
|
|
394
|
+
getClient: auto.getClient,
|
|
395
|
+
keyAuthorization: auto.keyAuthorization,
|
|
396
|
+
lookupKey: resolved.key,
|
|
397
|
+
request,
|
|
398
|
+
settlementReference: auto.challengeId,
|
|
399
|
+
source,
|
|
400
|
+
store: auto.store,
|
|
401
|
+
waitForConfirmation: auto.waitForConfirmation,
|
|
402
|
+
})
|
|
403
|
+
const timestamp = new Date().toISOString()
|
|
404
|
+
const subscription = {
|
|
405
|
+
accessKey,
|
|
406
|
+
amount: request.amount,
|
|
407
|
+
billingAnchor: timestamp,
|
|
408
|
+
chainId: request.methodDetails?.chainId,
|
|
409
|
+
currency: request.currency,
|
|
410
|
+
externalId: request.externalId,
|
|
411
|
+
keyAuthorization: auto.keyAuthorization,
|
|
412
|
+
lastChargedPeriod: 0,
|
|
413
|
+
lookupKey: resolved.key,
|
|
414
|
+
payer: source,
|
|
415
|
+
periodCount: request.periodCount,
|
|
416
|
+
periodUnit: request.periodUnit,
|
|
417
|
+
recipient: request.recipient,
|
|
418
|
+
reference,
|
|
419
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
420
|
+
subscriptionId: createSubscriptionId(),
|
|
421
|
+
timestamp,
|
|
422
|
+
} satisfies SubscriptionRecord
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
receipt: SubscriptionReceipt.createSubscriptionReceipt(subscription),
|
|
426
|
+
subscription,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function settleRenewal(parameters: {
|
|
431
|
+
expectedLookupKey: string
|
|
432
|
+
periodIndex: number
|
|
433
|
+
renew: (parameters: {
|
|
434
|
+
inFlightReference: string
|
|
435
|
+
periodIndex: number
|
|
436
|
+
subscription: SubscriptionRecord
|
|
437
|
+
}) => Promise<subscription.RenewalResult>
|
|
438
|
+
request?: SubscriptionRequest | undefined
|
|
439
|
+
store: SubscriptionStore.SubscriptionStore
|
|
440
|
+
subscription: SubscriptionRecord
|
|
441
|
+
}): Promise<
|
|
442
|
+
| { status: 'charged'; receipt: SubscriptionReceiptValue }
|
|
443
|
+
| { status: 'inFlight'; receipt: SubscriptionReceiptValue }
|
|
444
|
+
| { status: 'renewed'; result: subscription.RenewalResult }
|
|
445
|
+
| null
|
|
446
|
+
> {
|
|
447
|
+
const { expectedLookupKey, periodIndex, renew, request, store, subscription } = parameters
|
|
448
|
+
const inFlightReference = renewalReference(subscription.subscriptionId, periodIndex)
|
|
449
|
+
const renewal = await store.renew({
|
|
450
|
+
inFlightReference,
|
|
451
|
+
periodIndex,
|
|
452
|
+
async renew({ inFlightReference, periodIndex, subscription: started }) {
|
|
453
|
+
const renewed = withSubscriptionAccessKey(
|
|
454
|
+
await renew({
|
|
455
|
+
inFlightReference,
|
|
456
|
+
periodIndex,
|
|
457
|
+
subscription: started,
|
|
458
|
+
}),
|
|
459
|
+
started.accessKey,
|
|
460
|
+
)
|
|
461
|
+
validateSubscriptionSettlement(renewed, {
|
|
462
|
+
expectedLookupKey,
|
|
463
|
+
expectedPeriodIndex: periodIndex,
|
|
464
|
+
expectedSubscriptionId: subscription.subscriptionId,
|
|
465
|
+
previous: started,
|
|
466
|
+
request,
|
|
467
|
+
})
|
|
468
|
+
return renewed
|
|
469
|
+
},
|
|
470
|
+
subscriptionId: subscription.subscriptionId,
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
if (renewal.status === 'charged') {
|
|
474
|
+
return { receipt: SubscriptionReceipt.fromRecord(renewal.subscription), status: 'charged' }
|
|
475
|
+
}
|
|
476
|
+
if (renewal.status === 'inFlight') {
|
|
477
|
+
return { receipt: SubscriptionReceipt.fromRecord(renewal.subscription), status: 'inFlight' }
|
|
478
|
+
}
|
|
479
|
+
if (renewal.status === 'renewed') return { result: renewal.result, status: 'renewed' }
|
|
480
|
+
if (renewal.status === 'claimMismatch') {
|
|
481
|
+
throw new VerificationFailedError({ reason: 'subscription renewal claim mismatch' })
|
|
482
|
+
}
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function renewalReference(subscriptionId: string, periodIndex: number): string {
|
|
487
|
+
// This stable identifier is persisted before the billing hook runs so apps can
|
|
488
|
+
// use it as an idempotency/reconciliation key if a renewal crashes mid-flight.
|
|
489
|
+
return `renewal:${subscriptionId}:${periodIndex}`
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function withSubscriptionAccessKey<
|
|
493
|
+
result extends subscription.ActivationResult | subscription.RenewalResult,
|
|
494
|
+
>(result: result, accessKey: SubscriptionAccessKey | undefined): result {
|
|
495
|
+
if (!accessKey || result.subscription.accessKey) return result
|
|
496
|
+
return {
|
|
497
|
+
...result,
|
|
498
|
+
subscription: {
|
|
499
|
+
...result.subscription,
|
|
500
|
+
accessKey,
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function getPeriodIndex(subscription: SubscriptionRecord): number {
|
|
506
|
+
const anchor = new Date(subscription.billingAnchor).getTime()
|
|
507
|
+
const expires = new Date(subscription.subscriptionExpires).getTime()
|
|
508
|
+
const now = Date.now()
|
|
509
|
+
if (!Number.isFinite(anchor) || !Number.isFinite(expires) || now >= expires) {
|
|
510
|
+
return Number.POSITIVE_INFINITY
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let periodSeconds: number
|
|
514
|
+
try {
|
|
515
|
+
periodSeconds = toSubscriptionPeriodSeconds(subscription)
|
|
516
|
+
} catch {
|
|
517
|
+
return Number.POSITIVE_INFINITY
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return Math.max(0, Math.floor((now - anchor) / (periodSeconds * 1_000)))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function isActive(subscription: SubscriptionRecord): boolean {
|
|
524
|
+
if (subscription.canceledAt || subscription.revokedAt) return false
|
|
525
|
+
return new Date(subscription.subscriptionExpires).getTime() > Date.now()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function isReusableSubscription(
|
|
529
|
+
subscription: SubscriptionRecord,
|
|
530
|
+
request: SubscriptionRequest,
|
|
531
|
+
): boolean {
|
|
532
|
+
return (
|
|
533
|
+
isActive(subscription) &&
|
|
534
|
+
getPeriodIndex(subscription) <= subscription.lastChargedPeriod &&
|
|
535
|
+
subscriptionMatchesRequest(subscription, request)
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function subscriptionMatchesRequest(
|
|
540
|
+
subscription: SubscriptionRecord,
|
|
541
|
+
request: SubscriptionRequest | SubscriptionRecord,
|
|
542
|
+
): boolean {
|
|
543
|
+
const actual = comparableSubscriptionBinding(subscription)
|
|
544
|
+
const expected = comparableSubscriptionBinding(request)
|
|
545
|
+
return (Object.keys(expected) as (keyof typeof expected)[]).every(
|
|
546
|
+
(key) => actual[key] === expected[key],
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function comparableSubscriptionBinding(value: SubscriptionRecord | SubscriptionRequest) {
|
|
551
|
+
const chainId =
|
|
552
|
+
'chainId' in value ? value.chainId : (value as SubscriptionRequest).methodDetails?.chainId
|
|
553
|
+
return {
|
|
554
|
+
amount: value.amount,
|
|
555
|
+
chainId,
|
|
556
|
+
currency: value.currency.toLowerCase(),
|
|
557
|
+
externalId: value.externalId,
|
|
558
|
+
periodCount: value.periodCount,
|
|
559
|
+
periodUnit: value.periodUnit,
|
|
560
|
+
recipient: value.recipient.toLowerCase(),
|
|
561
|
+
subscriptionExpires: value.subscriptionExpires,
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function validateSubscriptionSettlement(
|
|
566
|
+
result: subscription.ActivationResult | subscription.RenewalResult,
|
|
567
|
+
options: {
|
|
568
|
+
expectedLookupKey: string
|
|
569
|
+
expectedPeriodIndex: number
|
|
570
|
+
expectedSubscriptionId?: string | undefined
|
|
571
|
+
previous?: SubscriptionRecord | undefined
|
|
572
|
+
request?: SubscriptionRequest | undefined
|
|
573
|
+
},
|
|
574
|
+
) {
|
|
575
|
+
const { receipt, subscription } = result
|
|
576
|
+
assertSubscriptionReceipt(receipt, subscription)
|
|
577
|
+
assertSubscriptionRecord(subscription, options)
|
|
578
|
+
|
|
579
|
+
if (options.request) {
|
|
580
|
+
assertSubscriptionRequestMatch(subscription, options.request)
|
|
581
|
+
} else if (options.previous) {
|
|
582
|
+
assertSubscriptionRequestMatch(subscription, options.previous)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function assertSubscriptionReceipt(
|
|
587
|
+
receipt: SubscriptionReceiptValue,
|
|
588
|
+
subscription: SubscriptionRecord,
|
|
589
|
+
) {
|
|
590
|
+
if (receipt.method !== 'tempo' || receipt.status !== 'success') {
|
|
591
|
+
throw new VerificationFailedError({ reason: 'subscription receipt is invalid' })
|
|
592
|
+
}
|
|
593
|
+
if (receipt.subscriptionId !== subscription.subscriptionId) {
|
|
594
|
+
throw new VerificationFailedError({ reason: 'subscription receipt id mismatch' })
|
|
595
|
+
}
|
|
596
|
+
if (receipt.reference !== subscription.reference) {
|
|
597
|
+
throw new VerificationFailedError({ reason: 'subscription receipt reference mismatch' })
|
|
598
|
+
}
|
|
599
|
+
if (receipt.timestamp !== subscription.timestamp) {
|
|
600
|
+
throw new VerificationFailedError({ reason: 'subscription receipt timestamp mismatch' })
|
|
601
|
+
}
|
|
602
|
+
assertTransactionHash(receipt.reference, 'subscription reference must be a transaction hash')
|
|
603
|
+
assertValidDate(receipt.timestamp, 'subscription receipt timestamp is invalid')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function assertSubscriptionRecord(
|
|
607
|
+
subscription: SubscriptionRecord,
|
|
608
|
+
options: {
|
|
609
|
+
expectedLookupKey: string
|
|
610
|
+
expectedPeriodIndex: number
|
|
611
|
+
expectedSubscriptionId?: string | undefined
|
|
612
|
+
},
|
|
613
|
+
) {
|
|
614
|
+
assertBase64Url(subscription.subscriptionId, 'subscriptionId must be base64url')
|
|
615
|
+
assertTransactionHash(subscription.reference, 'subscription reference must be a transaction hash')
|
|
616
|
+
const billingAnchor = assertValidDate(
|
|
617
|
+
subscription.billingAnchor,
|
|
618
|
+
'subscription billingAnchor is invalid',
|
|
619
|
+
)
|
|
620
|
+
const subscriptionExpires = assertValidDate(
|
|
621
|
+
subscription.subscriptionExpires,
|
|
622
|
+
'subscriptionExpires is invalid',
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
assertEqual(subscription.lookupKey, options.expectedLookupKey, {
|
|
626
|
+
reason: 'subscription lookupKey does not match the resolved key',
|
|
627
|
+
})
|
|
628
|
+
assertEqual(subscription.lastChargedPeriod, options.expectedPeriodIndex, {
|
|
629
|
+
reason: 'subscription lastChargedPeriod does not match the settled period',
|
|
630
|
+
})
|
|
631
|
+
if (options.expectedSubscriptionId) {
|
|
632
|
+
assertEqual(subscription.subscriptionId, options.expectedSubscriptionId, {
|
|
633
|
+
reason: 'subscriptionId does not match the active subscription',
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
if (billingAnchor >= subscriptionExpires) {
|
|
637
|
+
throw new VerificationFailedError({
|
|
638
|
+
reason: 'subscription billingAnchor must be before subscriptionExpires',
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function assertSubscriptionRequestMatch(
|
|
644
|
+
subscription: SubscriptionRecord,
|
|
645
|
+
request: SubscriptionRequest | SubscriptionRecord,
|
|
646
|
+
) {
|
|
647
|
+
if (!subscriptionMatchesRequest(subscription, request)) {
|
|
648
|
+
throw new VerificationFailedError({ reason: 'subscription record does not match request' })
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function assertBase64Url(value: string, reason: string) {
|
|
653
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
654
|
+
throw new VerificationFailedError({ reason })
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function assertTransactionHash(value: string, reason: string) {
|
|
659
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
660
|
+
throw new VerificationFailedError({ reason })
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function assertValidDate(value: string, reason: string) {
|
|
665
|
+
const milliseconds = new Date(value).getTime()
|
|
666
|
+
if (!Number.isFinite(milliseconds)) {
|
|
667
|
+
throw new VerificationFailedError({ reason })
|
|
668
|
+
}
|
|
669
|
+
return milliseconds
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function assertEqual<value>(actual: value, expected: value, options: { reason: string }) {
|
|
673
|
+
if (actual !== expected) {
|
|
674
|
+
throw new VerificationFailedError(options)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function subscriptionBinding(request: SubscriptionRequest) {
|
|
679
|
+
return {
|
|
680
|
+
amount: request.amount,
|
|
681
|
+
chainId: request.methodDetails?.chainId,
|
|
682
|
+
currency: request.currency,
|
|
683
|
+
externalId: request.externalId,
|
|
684
|
+
periodCount: request.periodCount,
|
|
685
|
+
periodUnit: request.periodUnit,
|
|
686
|
+
recipient: request.recipient,
|
|
687
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function resolveRenewalHandler(parameters: {
|
|
692
|
+
getClient: (parameters: { chainId?: number | undefined }) => MaybePromise<ViemClient>
|
|
693
|
+
parameters: {
|
|
694
|
+
renew?:
|
|
695
|
+
| ((parameters: {
|
|
696
|
+
inFlightReference: string
|
|
697
|
+
periodIndex: number
|
|
698
|
+
subscription: SubscriptionRecord
|
|
699
|
+
}) => Promise<subscription.RenewalResult>)
|
|
700
|
+
| undefined
|
|
701
|
+
}
|
|
702
|
+
store: SubscriptionStore.SubscriptionStore
|
|
703
|
+
subscription: SubscriptionRecord
|
|
704
|
+
waitForConfirmation: boolean
|
|
705
|
+
}):
|
|
706
|
+
| ((parameters: {
|
|
707
|
+
inFlightReference: string
|
|
708
|
+
periodIndex: number
|
|
709
|
+
subscription: SubscriptionRecord
|
|
710
|
+
}) => Promise<subscription.RenewalResult>)
|
|
711
|
+
| undefined {
|
|
712
|
+
const {
|
|
713
|
+
getClient,
|
|
714
|
+
parameters: subscriptionParameters,
|
|
715
|
+
store,
|
|
716
|
+
subscription,
|
|
717
|
+
waitForConfirmation,
|
|
718
|
+
} = parameters
|
|
719
|
+
if (subscriptionParameters.renew) return subscriptionParameters.renew
|
|
720
|
+
if (!subscription.accessKey || !subscription.payer) return undefined
|
|
721
|
+
return async ({ inFlightReference, periodIndex, subscription }) => {
|
|
722
|
+
const reference = await submitSubscriptionPayment({
|
|
723
|
+
accessKey: subscription.accessKey!,
|
|
724
|
+
getClient,
|
|
725
|
+
lookupKey: subscription.lookupKey,
|
|
726
|
+
request: subscription,
|
|
727
|
+
settlementReference: inFlightReference,
|
|
728
|
+
source: subscription.payer!,
|
|
729
|
+
store,
|
|
730
|
+
waitForConfirmation,
|
|
731
|
+
})
|
|
732
|
+
const record = {
|
|
733
|
+
...subscription,
|
|
734
|
+
lastChargedPeriod: periodIndex,
|
|
735
|
+
reference,
|
|
736
|
+
timestamp: new Date().toISOString(),
|
|
737
|
+
} satisfies SubscriptionRecord
|
|
738
|
+
return {
|
|
739
|
+
receipt: SubscriptionReceipt.createSubscriptionReceipt(record),
|
|
740
|
+
subscription: record,
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function submitSubscriptionPayment(parameters: {
|
|
746
|
+
accessKey: SubscriptionAccessKey
|
|
747
|
+
getClient: (parameters: { chainId?: number | undefined }) => MaybePromise<ViemClient>
|
|
748
|
+
keyAuthorization?: `0x${string}` | undefined
|
|
749
|
+
lookupKey: string
|
|
750
|
+
request: Pick<SubscriptionRequest, 'amount'> & {
|
|
751
|
+
methodDetails?: { chainId?: number | undefined } | undefined
|
|
752
|
+
} & { currency: Address | string; recipient: Address | string }
|
|
753
|
+
settlementReference: string
|
|
754
|
+
source: { address: Address; chainId: number }
|
|
755
|
+
store: SubscriptionStore.SubscriptionStore
|
|
756
|
+
waitForConfirmation: boolean
|
|
757
|
+
}) {
|
|
758
|
+
const {
|
|
759
|
+
accessKey,
|
|
760
|
+
getClient,
|
|
761
|
+
keyAuthorization,
|
|
762
|
+
lookupKey,
|
|
763
|
+
request,
|
|
764
|
+
settlementReference,
|
|
765
|
+
source,
|
|
766
|
+
store,
|
|
767
|
+
waitForConfirmation,
|
|
768
|
+
} = parameters
|
|
769
|
+
const stored = await store.getAccessKey(lookupKey)
|
|
770
|
+
if (!stored) {
|
|
771
|
+
throw new VerificationFailedError({ reason: 'subscription access key is missing' })
|
|
772
|
+
}
|
|
773
|
+
const rawAccessAccount = TempoAccount.fromSecp256k1(stored.privateKey)
|
|
774
|
+
if (!isAddressEqual(rawAccessAccount.address, accessKey.accessKeyAddress)) {
|
|
775
|
+
throw new VerificationFailedError({
|
|
776
|
+
reason: 'subscription access key does not match stored key',
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const chainId = request.methodDetails?.chainId ?? source.chainId
|
|
781
|
+
const client = await getClient({ chainId })
|
|
782
|
+
const account = TempoAccount.fromSecp256k1(stored.privateKey, {
|
|
783
|
+
access: source.address,
|
|
784
|
+
})
|
|
785
|
+
const memo = Attribution.encode({
|
|
786
|
+
challengeId: settlementReference,
|
|
787
|
+
serverId: lookupKey,
|
|
788
|
+
})
|
|
789
|
+
const serializedTransaction = await signTransaction(client, {
|
|
790
|
+
account,
|
|
791
|
+
calls: [
|
|
792
|
+
{
|
|
793
|
+
data: encodeFunctionData({
|
|
794
|
+
abi: Abis.tip20,
|
|
795
|
+
functionName: 'transferWithMemo',
|
|
796
|
+
args: [request.recipient as Address, BigInt(request.amount), memo],
|
|
797
|
+
}),
|
|
798
|
+
to: request.currency as Address,
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
chainId,
|
|
802
|
+
...(keyAuthorization
|
|
803
|
+
? { keyAuthorization: KeyAuthorization.deserialize(keyAuthorization) }
|
|
804
|
+
: {}),
|
|
805
|
+
} as never)
|
|
806
|
+
const transaction = Transaction.deserialize(
|
|
807
|
+
serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
808
|
+
)
|
|
809
|
+
await viem_call(client, {
|
|
810
|
+
...transaction,
|
|
811
|
+
account: transaction.from,
|
|
812
|
+
calls: transaction.calls,
|
|
813
|
+
} as never)
|
|
814
|
+
|
|
815
|
+
if (!waitForConfirmation) {
|
|
816
|
+
return sendRawTransaction(client, {
|
|
817
|
+
serializedTransaction: serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const receipt = await sendRawTransactionSync(client, {
|
|
822
|
+
serializedTransaction: serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
823
|
+
})
|
|
824
|
+
if (receipt.status !== 'success') {
|
|
825
|
+
throw new VerificationFailedError({
|
|
826
|
+
reason: `subscription transaction reverted: ${receipt.transactionHash}`,
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
return receipt.transactionHash
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function createSubscriptionId() {
|
|
833
|
+
const bytes = new Uint8Array(18)
|
|
834
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
835
|
+
return Base64.fromBytes(bytes, { url: true }).replace(/=+$/, '')
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Renews an overdue subscription outside of the HTTP request path.
|
|
840
|
+
* Intended for cron jobs or background workers that bill subscriptions on a schedule.
|
|
841
|
+
*
|
|
842
|
+
* Returns the renewal result if the subscription was overdue, or `null` if already current.
|
|
843
|
+
*/
|
|
844
|
+
export async function renew(parameters: renew.Parameters): Promise<renew.Result | null> {
|
|
845
|
+
const { store: rawStore, waitForConfirmation = true } = parameters
|
|
846
|
+
const store = SubscriptionStore.fromStore(rawStore, {
|
|
847
|
+
renewalTimeoutMs: parameters.renewalTimeoutMs,
|
|
848
|
+
})
|
|
849
|
+
const getClient = ClientResolver.getResolver({
|
|
850
|
+
chain: tempo_chain,
|
|
851
|
+
getClient: parameters.getClient,
|
|
852
|
+
rpcUrl: defaults.rpcUrl,
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
const record = await store.get(parameters.subscriptionId)
|
|
856
|
+
if (!record) return null
|
|
857
|
+
if (!isActive(record)) return null
|
|
858
|
+
const active = await store.getByKey(record.lookupKey)
|
|
859
|
+
if (active?.subscriptionId !== record.subscriptionId) return null
|
|
860
|
+
|
|
861
|
+
const periodIndex = getPeriodIndex(record)
|
|
862
|
+
if (periodIndex <= record.lastChargedPeriod) return null
|
|
863
|
+
|
|
864
|
+
const renew = resolveRenewalHandler({
|
|
865
|
+
getClient,
|
|
866
|
+
parameters,
|
|
867
|
+
store,
|
|
868
|
+
subscription: record,
|
|
869
|
+
waitForConfirmation,
|
|
870
|
+
})
|
|
871
|
+
if (!renew) return null
|
|
872
|
+
|
|
873
|
+
const renewal = await settleRenewal({
|
|
874
|
+
expectedLookupKey: record.lookupKey,
|
|
875
|
+
periodIndex,
|
|
876
|
+
renew,
|
|
877
|
+
store,
|
|
878
|
+
subscription: record,
|
|
879
|
+
})
|
|
880
|
+
return renewal?.status === 'renewed' ? renewal.result : null
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export declare namespace renew {
|
|
884
|
+
/** Parameters for renewing an overdue subscription outside the request path. */
|
|
885
|
+
type Parameters = {
|
|
886
|
+
/** The subscription to renew. */
|
|
887
|
+
subscriptionId: string
|
|
888
|
+
/** Billing callback — same signature as the `renew` hook on {@link subscription}. */
|
|
889
|
+
renew?:
|
|
890
|
+
| ((parameters: {
|
|
891
|
+
/** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */
|
|
892
|
+
inFlightReference: string
|
|
893
|
+
periodIndex: number
|
|
894
|
+
subscription: SubscriptionRecord
|
|
895
|
+
}) => Promise<subscription.RenewalResult>)
|
|
896
|
+
| undefined
|
|
897
|
+
/** Store containing subscription records. */
|
|
898
|
+
store: Store.AtomicStore<Record<string, unknown>>
|
|
899
|
+
/**
|
|
900
|
+
* Milliseconds before an in-flight renewal lock can be replaced.
|
|
901
|
+
* Keeps concurrent renewal safe while allowing recovery from abandoned attempts.
|
|
902
|
+
*/
|
|
903
|
+
renewalTimeoutMs?: number | undefined
|
|
904
|
+
waitForConfirmation?: boolean | undefined
|
|
905
|
+
} & Client.getResolver.Parameters
|
|
906
|
+
|
|
907
|
+
/** Renewal result returned by {@link renew}. */
|
|
908
|
+
type Result = subscription.RenewalResult
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export declare namespace subscription {
|
|
912
|
+
/** Request-scoped lookup key used to find the active subscription. */
|
|
913
|
+
type ResolvedSubscription = SubscriptionLookup
|
|
914
|
+
|
|
915
|
+
/** Activation result returned after the initial credential is verified. */
|
|
916
|
+
type ActivationResult = {
|
|
917
|
+
receipt: SubscriptionReceiptValue
|
|
918
|
+
subscription: SubscriptionRecord
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Renewal result returned when an overdue subscription is charged. */
|
|
922
|
+
type RenewalResult = {
|
|
923
|
+
receipt: SubscriptionReceiptValue
|
|
924
|
+
subscription: SubscriptionRecord
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Request defaults supported by the subscription method. */
|
|
928
|
+
type Defaults = LooseOmit<
|
|
929
|
+
Method.RequestDefaults<typeof Methods.subscription>,
|
|
930
|
+
'accessKey' | 'recipient'
|
|
931
|
+
>
|
|
932
|
+
|
|
933
|
+
/** Parameters for configuring a Tempo subscription method. */
|
|
934
|
+
type Parameters = Account.resolve.Parameters &
|
|
935
|
+
Client.getResolver.Parameters & {
|
|
936
|
+
accessKey?:
|
|
937
|
+
| ((parameters: {
|
|
938
|
+
input: Request
|
|
939
|
+
request: SubscriptionRequest
|
|
940
|
+
resolved: ResolvedSubscription
|
|
941
|
+
}) => MaybePromise<SubscriptionAccessKey>)
|
|
942
|
+
| undefined
|
|
943
|
+
/**
|
|
944
|
+
* Milliseconds before an in-flight activation lock can be replaced.
|
|
945
|
+
* Keeps concurrent activation safe while allowing recovery from abandoned attempts.
|
|
946
|
+
*/
|
|
947
|
+
activationTimeoutMs?: number | undefined
|
|
948
|
+
/**
|
|
949
|
+
* Milliseconds before an in-flight renewal lock can be replaced.
|
|
950
|
+
* Keeps concurrent renewal safe while allowing recovery from abandoned attempts.
|
|
951
|
+
*/
|
|
952
|
+
renewalTimeoutMs?: number | undefined
|
|
953
|
+
activate?:
|
|
954
|
+
| ((parameters: {
|
|
955
|
+
/** Custom activation must verify this access key matches the resolved subscription. */
|
|
956
|
+
accessKey: SubscriptionAccessKey
|
|
957
|
+
credential: {
|
|
958
|
+
payload: SubscriptionCredentialPayload
|
|
959
|
+
source?: string | undefined
|
|
960
|
+
}
|
|
961
|
+
input: Request
|
|
962
|
+
request: SubscriptionRequest
|
|
963
|
+
resolved: ResolvedSubscription
|
|
964
|
+
source: { address: Address; chainId: number } | null
|
|
965
|
+
}) => Promise<ActivationResult>)
|
|
966
|
+
| undefined
|
|
967
|
+
hooks?:
|
|
968
|
+
| {
|
|
969
|
+
activated?:
|
|
970
|
+
| ((parameters: {
|
|
971
|
+
receipt: SubscriptionReceiptValue
|
|
972
|
+
subscription: SubscriptionRecord
|
|
973
|
+
}) => MaybePromise<void>)
|
|
974
|
+
| undefined
|
|
975
|
+
renewed?:
|
|
976
|
+
| ((parameters: {
|
|
977
|
+
periodIndex: number
|
|
978
|
+
receipt: SubscriptionReceiptValue
|
|
979
|
+
subscription: SubscriptionRecord
|
|
980
|
+
}) => MaybePromise<void>)
|
|
981
|
+
| undefined
|
|
982
|
+
}
|
|
983
|
+
| undefined
|
|
984
|
+
periodCount?: Methods.SubscriptionPeriodCountInput | undefined
|
|
985
|
+
periodUnit?: SubscriptionPeriodUnit | undefined
|
|
986
|
+
/**
|
|
987
|
+
* Resolves the request identity. This callback must authenticate and
|
|
988
|
+
* authorize the caller before returning a key; automatic mode may create
|
|
989
|
+
* a server-owned access key for that key while issuing a challenge.
|
|
990
|
+
*/
|
|
991
|
+
resolve: (parameters: {
|
|
992
|
+
input: Request
|
|
993
|
+
request: SubscriptionRequest
|
|
994
|
+
}) => MaybePromise<ResolvedSubscription | null>
|
|
995
|
+
renew?: (parameters: {
|
|
996
|
+
/** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */
|
|
997
|
+
inFlightReference: string
|
|
998
|
+
periodIndex: number
|
|
999
|
+
/** Custom renewal hooks must preserve amount, currency, recipient, period, expiry, and lookup key. */
|
|
1000
|
+
subscription: SubscriptionRecord
|
|
1001
|
+
}) => Promise<RenewalResult>
|
|
1002
|
+
store?: Store.AtomicStore<Record<string, unknown>> | undefined
|
|
1003
|
+
testnet?: boolean | undefined
|
|
1004
|
+
waitForConfirmation?: boolean | undefined
|
|
1005
|
+
} & Defaults
|
|
1006
|
+
|
|
1007
|
+
/** Derived defaults after account and chain configuration are applied. */
|
|
1008
|
+
type DeriveDefaults<parameters extends Parameters> = types.DeriveDefaults<
|
|
1009
|
+
parameters,
|
|
1010
|
+
Defaults
|
|
1011
|
+
> & {
|
|
1012
|
+
decimals: number
|
|
1013
|
+
}
|
|
1014
|
+
}
|