mppx 0.6.12 → 0.6.14
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 +14 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +24 -1
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +22 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +26 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +11 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +71 -6
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +53 -10
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +80 -29
- package/dist/tempo/server/Session.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/server/internal/request-body.d.ts +1 -1
- package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
- package/dist/tempo/server/internal/request-body.js +3 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +7 -0
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +1 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +28 -11
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +6 -0
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +1 -0
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +34 -12
- package/dist/tempo/session/Sse.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/plugins/tempo.ts +28 -1
- package/src/middlewares/express.test.ts +27 -0
- package/src/middlewares/express.ts +24 -0
- package/src/tempo/client/SessionManager.ts +26 -1
- package/src/tempo/internal/fee-payer.test.ts +139 -0
- package/src/tempo/internal/fee-payer.ts +85 -6
- package/src/tempo/server/Charge.test.ts +119 -0
- package/src/tempo/server/Charge.ts +70 -10
- package/src/tempo/server/Session.test.ts +327 -0
- package/src/tempo/server/Session.ts +91 -39
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/request-body.test.ts +26 -0
- package/src/tempo/server/internal/request-body.ts +4 -1
- package/src/tempo/server/internal/transport.test.ts +28 -2
- package/src/tempo/server/internal/transport.ts +23 -0
- package/src/tempo/session/Chain.test.ts +140 -1
- package/src/tempo/session/Chain.ts +34 -10
- package/src/tempo/session/ChannelStore.test.ts +21 -0
- package/src/tempo/session/ChannelStore.ts +6 -0
- package/src/tempo/session/Sse.test.ts +52 -0
- package/src/tempo/session/Sse.ts +22 -2
package/src/cli/plugins/tempo.ts
CHANGED
|
@@ -247,6 +247,7 @@ export function tempo() {
|
|
|
247
247
|
const channelId = parsed.payload.channelId
|
|
248
248
|
const escrowContract = sessionMd?.escrowContract as Address | undefined
|
|
249
249
|
const chainId = sessionMd?.chainId ?? 0
|
|
250
|
+
const tickCost = BigInt(challengeRequest.amount as string)
|
|
250
251
|
let cumulativeAmount =
|
|
251
252
|
'cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount
|
|
252
253
|
? BigInt(parsed.payload.cumulativeAmount)
|
|
@@ -287,11 +288,11 @@ export function tempo() {
|
|
|
287
288
|
if (receiptHeader) {
|
|
288
289
|
try {
|
|
289
290
|
const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record<string, unknown>
|
|
291
|
+
assertReceiptWithinCliState(receiptJson, cumulativeAmount)
|
|
290
292
|
if (
|
|
291
293
|
typeof receiptJson.acceptedCumulative === 'string' &&
|
|
292
294
|
receiptJson.acceptedCumulative
|
|
293
295
|
) {
|
|
294
|
-
cumulativeAmount = BigInt(receiptJson.acceptedCumulative)
|
|
295
296
|
writeChannelCumulative(channelId, cumulativeAmount)
|
|
296
297
|
}
|
|
297
298
|
if (verbose >= 1)
|
|
@@ -315,6 +316,7 @@ export function tempo() {
|
|
|
315
316
|
escrowContract,
|
|
316
317
|
chainId,
|
|
317
318
|
cumulativeAmount,
|
|
319
|
+
tickCost,
|
|
318
320
|
fetchUrl,
|
|
319
321
|
fetchInit,
|
|
320
322
|
session: _session,
|
|
@@ -415,6 +417,24 @@ function printReceipt(
|
|
|
415
417
|
if (opts.prefix) opts.info('\n')
|
|
416
418
|
}
|
|
417
419
|
|
|
420
|
+
function assertReceiptWithinCliState(
|
|
421
|
+
receiptJson: Record<string, unknown>,
|
|
422
|
+
cumulativeAmount: bigint,
|
|
423
|
+
) {
|
|
424
|
+
if (typeof receiptJson.acceptedCumulative !== 'string') return
|
|
425
|
+
const acceptedCumulative = BigInt(receiptJson.acceptedCumulative)
|
|
426
|
+
const spent = typeof receiptJson.spent === 'string' ? BigInt(receiptJson.spent) : 0n
|
|
427
|
+
if (spent > acceptedCumulative) {
|
|
428
|
+
throw new Error('receipt spent exceeds accepted cumulative voucher amount')
|
|
429
|
+
}
|
|
430
|
+
if (acceptedCumulative > cumulativeAmount) {
|
|
431
|
+
throw new Error('receipt accepted cumulative exceeds locally signed voucher amount')
|
|
432
|
+
}
|
|
433
|
+
if (spent > cumulativeAmount) {
|
|
434
|
+
throw new Error('receipt spent exceeds locally signed voucher amount')
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
418
438
|
async function handleSseStream(
|
|
419
439
|
response: Response,
|
|
420
440
|
opts: {
|
|
@@ -423,6 +443,7 @@ async function handleSseStream(
|
|
|
423
443
|
escrowContract: Address | undefined
|
|
424
444
|
chainId: number
|
|
425
445
|
cumulativeAmount: bigint
|
|
446
|
+
tickCost: bigint
|
|
426
447
|
fetchUrl: string
|
|
427
448
|
fetchInit: RequestInit
|
|
428
449
|
session: {
|
|
@@ -500,7 +521,13 @@ async function handleSseStream(
|
|
|
500
521
|
channelId: string
|
|
501
522
|
requiredCumulative: string
|
|
502
523
|
}
|
|
524
|
+
if (event.channelId !== opts.channelId) {
|
|
525
|
+
throw new Error('payment-need-voucher channelId does not match current session')
|
|
526
|
+
}
|
|
503
527
|
const required = BigInt(event.requiredCumulative)
|
|
528
|
+
if (required > cumulativeAmount + opts.tickCost) {
|
|
529
|
+
throw new Error('payment-need-voucher exceeds next locally billable amount')
|
|
530
|
+
}
|
|
504
531
|
cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
|
|
505
532
|
|
|
506
533
|
const voucherCred = await opts.session.signVoucher({
|
|
@@ -253,6 +253,33 @@ describe('session', () => {
|
|
|
253
253
|
})
|
|
254
254
|
|
|
255
255
|
describe('payment', () => {
|
|
256
|
+
test('short-circuits management responses', async () => {
|
|
257
|
+
let handlerRan = false
|
|
258
|
+
const managementResponse = new Response(null, {
|
|
259
|
+
status: 204,
|
|
260
|
+
headers: { 'Payment-Receipt': 'management-receipt' },
|
|
261
|
+
})
|
|
262
|
+
const intent = () => async () => ({
|
|
263
|
+
status: 200 as const,
|
|
264
|
+
withReceipt: () => managementResponse,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const app = express()
|
|
268
|
+
app.get('/', payment(intent as any, {} as any), (_req, res) => {
|
|
269
|
+
handlerRan = true
|
|
270
|
+
res.json({ data: 'content' })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const server = await createServer(app)
|
|
274
|
+
const response = await globalThis.fetch(server.url)
|
|
275
|
+
expect(response.status).toBe(204)
|
|
276
|
+
expect(response.headers.get('Payment-Receipt')).toBe('management-receipt')
|
|
277
|
+
expect(await response.text()).toBe('')
|
|
278
|
+
expect(handlerRan).toBe(false)
|
|
279
|
+
|
|
280
|
+
server.close()
|
|
281
|
+
})
|
|
282
|
+
|
|
256
283
|
test('returns 402 when no credential', async () => {
|
|
257
284
|
const { mppx } = createCoreChargeHarness(false)
|
|
258
285
|
|
|
@@ -76,6 +76,30 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
|
|
|
76
76
|
return
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const managementResponse = (() => {
|
|
80
|
+
try {
|
|
81
|
+
return (result.withReceipt as () => Response)()
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (
|
|
84
|
+
error instanceof Error &&
|
|
85
|
+
error.message === 'withReceipt() requires a response argument'
|
|
86
|
+
)
|
|
87
|
+
return null
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
})()
|
|
91
|
+
|
|
92
|
+
if (managementResponse) {
|
|
93
|
+
res.status(managementResponse.status)
|
|
94
|
+
for (const [key, value] of managementResponse.headers) res.setHeader(key, value)
|
|
95
|
+
if (managementResponse.body === null) {
|
|
96
|
+
res.end()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
res.send(Buffer.from(await managementResponse.arrayBuffer()))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
const originalJson = res.json.bind(res)
|
|
80
104
|
res.json = (body: any) => {
|
|
81
105
|
const wrapped = result.withReceipt(Response.json(body))
|
|
@@ -138,10 +138,28 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
138
138
|
|
|
139
139
|
function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) {
|
|
140
140
|
if (!receipt || receipt.channelId !== channel?.channelId) return
|
|
141
|
+
assertReceiptWithinLocalState(receipt)
|
|
141
142
|
const next = BigInt(receipt.spent)
|
|
142
143
|
spent = spent > next ? spent : next
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
function assertReceiptWithinLocalState(receipt: SessionReceipt) {
|
|
147
|
+
if (!channel || receipt.channelId !== channel.channelId) return
|
|
148
|
+
const acceptedCumulative = BigInt(receipt.acceptedCumulative)
|
|
149
|
+
const receiptSpent = BigInt(receipt.spent)
|
|
150
|
+
if (receiptSpent > acceptedCumulative) {
|
|
151
|
+
throw new Error('receipt spent exceeds accepted cumulative voucher amount')
|
|
152
|
+
}
|
|
153
|
+
if (acceptedCumulative > channel.cumulativeAmount) {
|
|
154
|
+
throw new Error('receipt accepted cumulative exceeds local voucher state')
|
|
155
|
+
}
|
|
156
|
+
if (receiptSpent > channel.cumulativeAmount) {
|
|
157
|
+
throw new Error('receipt spent exceeds local voucher state')
|
|
158
|
+
}
|
|
159
|
+
assertVoucherWithinLocalLimit(acceptedCumulative)
|
|
160
|
+
assertVoucherWithinLocalLimit(receiptSpent)
|
|
161
|
+
}
|
|
162
|
+
|
|
145
163
|
function waitForReceipt(predicate: (receipt: SessionReceipt) => boolean = () => true) {
|
|
146
164
|
if (receiptWaiter) throw new Error('receipt wait already in progress')
|
|
147
165
|
return new Promise<SessionReceipt>((resolve, reject) => {
|
|
@@ -762,7 +780,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
762
780
|
context: {
|
|
763
781
|
action: 'close',
|
|
764
782
|
channelId: closeChannelId,
|
|
765
|
-
cumulativeAmountRaw:
|
|
783
|
+
cumulativeAmountRaw: (() => {
|
|
784
|
+
const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId))
|
|
785
|
+
if (closeAmount > channel.cumulativeAmount) {
|
|
786
|
+
throw new Error('fallback close amount exceeds local voucher state')
|
|
787
|
+
}
|
|
788
|
+
assertVoucherWithinLocalLimit(closeAmount)
|
|
789
|
+
return closeAmount.toString()
|
|
790
|
+
})(),
|
|
766
791
|
},
|
|
767
792
|
})
|
|
768
793
|
|
|
@@ -123,6 +123,145 @@ describe('validateCalls', () => {
|
|
|
123
123
|
).not.toThrow()
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
+
test('accepts approve + buy + exact expected split transfers', () => {
|
|
127
|
+
expect(() =>
|
|
128
|
+
validateCalls(
|
|
129
|
+
[
|
|
130
|
+
{
|
|
131
|
+
to: swapTokenIn,
|
|
132
|
+
data: encodeFunctionData({
|
|
133
|
+
abi: Abis.tip20,
|
|
134
|
+
functionName: 'approve',
|
|
135
|
+
args: [Addresses.stablecoinDex, 100n],
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
to: Addresses.stablecoinDex,
|
|
140
|
+
data: swapData,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
to: swapTokenOut,
|
|
144
|
+
data: encodeFunctionData({
|
|
145
|
+
abi: Abis.tip20,
|
|
146
|
+
functionName: 'transfer',
|
|
147
|
+
args: [bogus, 90n],
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
to: swapTokenOut,
|
|
152
|
+
data: encodeFunctionData({
|
|
153
|
+
abi: Abis.tip20,
|
|
154
|
+
functionName: 'transferWithMemo',
|
|
155
|
+
args: [
|
|
156
|
+
'0x0000000000000000000000000000000000000002',
|
|
157
|
+
10n,
|
|
158
|
+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
|
159
|
+
],
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
details,
|
|
164
|
+
{
|
|
165
|
+
currency: swapTokenOut,
|
|
166
|
+
expectedTransfers: [
|
|
167
|
+
{ amount: '90', recipient: bogus },
|
|
168
|
+
{
|
|
169
|
+
amount: '10',
|
|
170
|
+
memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
|
171
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
),
|
|
176
|
+
).not.toThrow()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('error: rejects extra transfers when expected payments are supplied', () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
validateCalls(
|
|
182
|
+
[
|
|
183
|
+
{
|
|
184
|
+
to: swapTokenOut,
|
|
185
|
+
data: encodeFunctionData({
|
|
186
|
+
abi: Abis.tip20,
|
|
187
|
+
functionName: 'transfer',
|
|
188
|
+
args: [bogus, 100n],
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
to: swapTokenOut,
|
|
193
|
+
data: encodeFunctionData({
|
|
194
|
+
abi: Abis.tip20,
|
|
195
|
+
functionName: 'transfer',
|
|
196
|
+
args: ['0x0000000000000000000000000000000000000002', 1n],
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
details,
|
|
201
|
+
{
|
|
202
|
+
currency: swapTokenOut,
|
|
203
|
+
expectedTransfers: [{ amount: '100', recipient: bogus }],
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
).toThrow('disallowed call pattern')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('error: rejects expected transfers to the wrong token', () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
validateCalls(
|
|
212
|
+
[
|
|
213
|
+
{
|
|
214
|
+
to: swapTokenIn,
|
|
215
|
+
data: encodeFunctionData({
|
|
216
|
+
abi: Abis.tip20,
|
|
217
|
+
functionName: 'transfer',
|
|
218
|
+
args: [bogus, 100n],
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
details,
|
|
223
|
+
{
|
|
224
|
+
currency: swapTokenOut,
|
|
225
|
+
expectedTransfers: [{ amount: '100', recipient: bogus }],
|
|
226
|
+
},
|
|
227
|
+
),
|
|
228
|
+
).toThrow('payment transfer does not match challenge')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('error: rejects swaps whose output token does not fund the payment', () => {
|
|
232
|
+
expect(() =>
|
|
233
|
+
validateCalls(
|
|
234
|
+
[
|
|
235
|
+
{
|
|
236
|
+
to: swapTokenIn,
|
|
237
|
+
data: encodeFunctionData({
|
|
238
|
+
abi: Abis.tip20,
|
|
239
|
+
functionName: 'approve',
|
|
240
|
+
args: [Addresses.stablecoinDex, 100n],
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
to: Addresses.stablecoinDex,
|
|
245
|
+
data: swapData,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
to: bogus,
|
|
249
|
+
data: encodeFunctionData({
|
|
250
|
+
abi: Abis.tip20,
|
|
251
|
+
functionName: 'transfer',
|
|
252
|
+
args: [bogus, 100n],
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
details,
|
|
257
|
+
{
|
|
258
|
+
currency: bogus,
|
|
259
|
+
expectedTransfers: [{ amount: '100', recipient: bogus }],
|
|
260
|
+
},
|
|
261
|
+
),
|
|
262
|
+
).toThrow('swap tokenOut does not match payment currency')
|
|
263
|
+
})
|
|
264
|
+
|
|
126
265
|
test('error: rejects empty calls', () => {
|
|
127
266
|
expect(() => validateCalls([], details)).toThrow(FeePayerValidationError)
|
|
128
267
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TempoAddress } from 'ox/tempo'
|
|
2
2
|
import { TxEnvelopeTempo } from 'ox/tempo'
|
|
3
|
+
import type { Hex } from 'viem'
|
|
3
4
|
import type { Account } from 'viem'
|
|
4
5
|
import { decodeFunctionData } from 'viem'
|
|
5
6
|
import { Abis, Addresses, Transaction } from 'viem/tempo'
|
|
@@ -42,6 +43,13 @@ export type Policy = {
|
|
|
42
43
|
// Tempo transaction fields.
|
|
43
44
|
type SponsoredTransaction = ReturnType<(typeof Transaction)['deserialize']>
|
|
44
45
|
|
|
46
|
+
type ExpectedTransfer = {
|
|
47
|
+
amount: string
|
|
48
|
+
allowAnyMemo?: boolean | undefined
|
|
49
|
+
memo?: Hex | undefined
|
|
50
|
+
recipient: TempoAddress.Address
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const preservedTransactionKeys = [
|
|
46
54
|
'accessList',
|
|
47
55
|
'calls',
|
|
@@ -122,6 +130,10 @@ function getPolicy(chainId: number, overrides: Partial<Policy> | undefined): Pol
|
|
|
122
130
|
export function validateCalls(
|
|
123
131
|
calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
|
|
124
132
|
details: Record<string, string>,
|
|
133
|
+
options?: {
|
|
134
|
+
currency?: TempoAddress.Address | undefined
|
|
135
|
+
expectedTransfers?: readonly ExpectedTransfer[] | undefined
|
|
136
|
+
},
|
|
125
137
|
) {
|
|
126
138
|
if (calls.length === 0)
|
|
127
139
|
throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
|
|
@@ -137,15 +149,19 @@ export function validateCalls(
|
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
const transferSelectors = callSelectors.slice(hasSwapPrefix ? 2 : 0)
|
|
152
|
+
if (transferSelectors.length === 0)
|
|
153
|
+
throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
|
|
154
|
+
|
|
155
|
+
const expectedTransfers = options?.expectedTransfers
|
|
156
|
+
const transferLimit = expectedTransfers?.length ?? 11
|
|
140
157
|
if (
|
|
141
|
-
transferSelectors.length
|
|
142
|
-
transferSelectors.length > 11 ||
|
|
158
|
+
transferSelectors.length > transferLimit ||
|
|
143
159
|
transferSelectors.some(
|
|
144
160
|
(selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
|
|
145
|
-
)
|
|
146
|
-
|
|
161
|
+
) ||
|
|
162
|
+
(expectedTransfers && transferSelectors.length !== expectedTransfers.length)
|
|
163
|
+
)
|
|
147
164
|
throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
|
|
148
|
-
}
|
|
149
165
|
|
|
150
166
|
// Bind the swap approval to the token the DEX call will actually spend.
|
|
151
167
|
const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
|
|
@@ -161,16 +177,79 @@ export function validateCalls(
|
|
|
161
177
|
const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
|
|
162
178
|
if (approveCall) {
|
|
163
179
|
const { args } = decodeFunctionData({ abi: Abis.tip20, data: approveCall.data! })
|
|
180
|
+
const [spender, amount] = args as [`0x${string}`, bigint]
|
|
164
181
|
if (!approveCall.to || (buyArgs && !TempoAddress_internal.isEqual(approveCall.to, buyArgs[0])))
|
|
165
182
|
throw new FeePayerValidationError('approve target does not match swap tokenIn', details)
|
|
166
|
-
if (!TempoAddress_internal.isEqual(
|
|
183
|
+
if (!TempoAddress_internal.isEqual(spender, Addresses.stablecoinDex))
|
|
167
184
|
throw new FeePayerValidationError('approve spender is not the DEX', details)
|
|
185
|
+
if (buyArgs && amount !== buyArgs[3])
|
|
186
|
+
throw new FeePayerValidationError('approve amount does not match swap max input', details)
|
|
168
187
|
}
|
|
169
188
|
if (
|
|
170
189
|
buyCall &&
|
|
171
190
|
(!buyCall.to || !TempoAddress_internal.isEqual(buyCall.to, Addresses.stablecoinDex))
|
|
172
191
|
)
|
|
173
192
|
throw new FeePayerValidationError('buy target is not the DEX', details)
|
|
193
|
+
|
|
194
|
+
if (!expectedTransfers) return
|
|
195
|
+
|
|
196
|
+
const currency = options?.currency ?? (details.currency as TempoAddress.Address | undefined)
|
|
197
|
+
if (!currency) throw new FeePayerValidationError('missing payment currency', details)
|
|
198
|
+
|
|
199
|
+
if (buyArgs) {
|
|
200
|
+
const [, tokenOut, amountOut] = buyArgs
|
|
201
|
+
const expectedAmountOut = expectedTransfers.reduce(
|
|
202
|
+
(sum, transfer) => sum + BigInt(transfer.amount),
|
|
203
|
+
0n,
|
|
204
|
+
)
|
|
205
|
+
if (!TempoAddress_internal.isEqual(tokenOut, currency))
|
|
206
|
+
throw new FeePayerValidationError('swap tokenOut does not match payment currency', details)
|
|
207
|
+
if (amountOut !== expectedAmountOut)
|
|
208
|
+
throw new FeePayerValidationError('swap output does not match payment amount', details)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const transferCalls = calls.slice(hasSwapPrefix ? 2 : 0)
|
|
212
|
+
const sorted = [...expectedTransfers].sort((a, b) => {
|
|
213
|
+
if (a.memo && !b.memo) return -1
|
|
214
|
+
if (!a.memo && b.memo) return 1
|
|
215
|
+
return 0
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const used = new Set<number>()
|
|
219
|
+
for (const expected of sorted) {
|
|
220
|
+
const matchIndex = transferCalls.findIndex((call, index) => {
|
|
221
|
+
if (used.has(index)) return false
|
|
222
|
+
if (!call.to || !TempoAddress_internal.isEqual(call.to, currency) || !call.data) return false
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const selector = call.data.slice(0, 10)
|
|
226
|
+
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
227
|
+
if (selector === Selectors.transfer) {
|
|
228
|
+
const [recipient, amount] = args as [`0x${string}`, bigint]
|
|
229
|
+
if (!TempoAddress_internal.isEqual(recipient, expected.recipient)) return false
|
|
230
|
+
if (amount.toString() !== expected.amount) return false
|
|
231
|
+
return expected.memo === undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (selector === Selectors.transferWithMemo) {
|
|
235
|
+
const [recipient, amount, memo] = args as [`0x${string}`, bigint, Hex]
|
|
236
|
+
if (!TempoAddress_internal.isEqual(recipient, expected.recipient)) return false
|
|
237
|
+
if (amount.toString() !== expected.amount) return false
|
|
238
|
+
if (expected.memo) return memo.toLowerCase() === expected.memo.toLowerCase()
|
|
239
|
+
return expected.allowAnyMemo === true
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return false
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
if (matchIndex === -1)
|
|
249
|
+
throw new FeePayerValidationError('payment transfer does not match challenge', details)
|
|
250
|
+
|
|
251
|
+
used.add(matchIndex)
|
|
252
|
+
}
|
|
174
253
|
}
|
|
175
254
|
|
|
176
255
|
export function prepareSponsoredTransaction(parameters: {
|
|
@@ -1579,6 +1579,104 @@ describe('tempo', () => {
|
|
|
1579
1579
|
httpServer.close()
|
|
1580
1580
|
})
|
|
1581
1581
|
|
|
1582
|
+
test('behavior: fee payer rejects concurrent in-flight transactions from one sender', async () => {
|
|
1583
|
+
let releaseSimulation!: () => void
|
|
1584
|
+
let resolveSimulationStarted!: () => void
|
|
1585
|
+
const simulationStarted = new Promise<void>((resolve) => {
|
|
1586
|
+
resolveSimulationStarted = resolve
|
|
1587
|
+
})
|
|
1588
|
+
const releaseSimulationPromise = new Promise<void>((resolve) => {
|
|
1589
|
+
releaseSimulation = resolve
|
|
1590
|
+
})
|
|
1591
|
+
let heldFirstSimulation = false
|
|
1592
|
+
|
|
1593
|
+
const interceptingClient = createClient({
|
|
1594
|
+
account: accounts[0],
|
|
1595
|
+
chain: client.chain,
|
|
1596
|
+
transport: custom({
|
|
1597
|
+
async request(args: any) {
|
|
1598
|
+
if (args.method === 'eth_call' && !heldFirstSimulation) {
|
|
1599
|
+
heldFirstSimulation = true
|
|
1600
|
+
resolveSimulationStarted()
|
|
1601
|
+
await releaseSimulationPromise
|
|
1602
|
+
}
|
|
1603
|
+
return client.transport.request(args)
|
|
1604
|
+
},
|
|
1605
|
+
}),
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
const sponsoredStore = Store.memory()
|
|
1609
|
+
const serverWithSlowSimulation = Mppx_server.create({
|
|
1610
|
+
methods: [
|
|
1611
|
+
tempo_server.charge({
|
|
1612
|
+
getClient() {
|
|
1613
|
+
return interceptingClient
|
|
1614
|
+
},
|
|
1615
|
+
currency: asset,
|
|
1616
|
+
account: accounts[0],
|
|
1617
|
+
store: sponsoredStore,
|
|
1618
|
+
}),
|
|
1619
|
+
],
|
|
1620
|
+
realm,
|
|
1621
|
+
secretKey,
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
const mppx = Mppx_client.create({
|
|
1625
|
+
polyfill: false,
|
|
1626
|
+
methods: [
|
|
1627
|
+
tempo_client({
|
|
1628
|
+
account: accounts[1],
|
|
1629
|
+
getClient() {
|
|
1630
|
+
return client
|
|
1631
|
+
},
|
|
1632
|
+
}),
|
|
1633
|
+
],
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
1637
|
+
const result = await Mppx_server.toNodeListener(
|
|
1638
|
+
serverWithSlowSimulation.charge({
|
|
1639
|
+
feePayer: accounts[0],
|
|
1640
|
+
amount: '1',
|
|
1641
|
+
currency: asset,
|
|
1642
|
+
recipient: accounts[0].address,
|
|
1643
|
+
}),
|
|
1644
|
+
)(req, res)
|
|
1645
|
+
if (result.status === 402) return
|
|
1646
|
+
res.end('OK')
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
const [challengeResponse1, challengeResponse2] = await Promise.all([
|
|
1650
|
+
fetch(httpServer.url),
|
|
1651
|
+
fetch(httpServer.url),
|
|
1652
|
+
])
|
|
1653
|
+
expect(challengeResponse1.status).toBe(402)
|
|
1654
|
+
expect(challengeResponse2.status).toBe(402)
|
|
1655
|
+
|
|
1656
|
+
const [credential1, credential2] = await Promise.all([
|
|
1657
|
+
mppx.createCredential(challengeResponse1),
|
|
1658
|
+
mppx.createCredential(challengeResponse2),
|
|
1659
|
+
])
|
|
1660
|
+
|
|
1661
|
+
const first = fetch(httpServer.url, { headers: { Authorization: credential1 } })
|
|
1662
|
+
await simulationStarted
|
|
1663
|
+
const second = fetch(httpServer.url, { headers: { Authorization: credential2 } })
|
|
1664
|
+
|
|
1665
|
+
try {
|
|
1666
|
+
const secondResponse = await second
|
|
1667
|
+
expect(secondResponse.status).toBe(402)
|
|
1668
|
+
const body = (await secondResponse.json()) as { detail: string }
|
|
1669
|
+
expect(body.detail).toContain('Sponsored transaction from this sender is already in flight')
|
|
1670
|
+
} finally {
|
|
1671
|
+
releaseSimulation()
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const firstResponse = await first
|
|
1675
|
+
expect(firstResponse.status).toBe(200)
|
|
1676
|
+
|
|
1677
|
+
httpServer.close()
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1582
1680
|
test('behavior: fee payer with splits', async () => {
|
|
1583
1681
|
const mppx = Mppx_client.create({
|
|
1584
1682
|
polyfill: false,
|
|
@@ -3338,6 +3436,27 @@ describe('tempo', () => {
|
|
|
3338
3436
|
})
|
|
3339
3437
|
expect(method.defaults?.recipient).toBe(accounts[0].address)
|
|
3340
3438
|
})
|
|
3439
|
+
|
|
3440
|
+
test('request keeps explicit feePayer false disabled during verification', async () => {
|
|
3441
|
+
const method = tempo_server.charge({
|
|
3442
|
+
getClient: () => client,
|
|
3443
|
+
account: accounts[0],
|
|
3444
|
+
currency: asset,
|
|
3445
|
+
feePayer: accounts[0],
|
|
3446
|
+
})
|
|
3447
|
+
|
|
3448
|
+
const normalized = await method.request!({
|
|
3449
|
+
credential: { challenge: {}, payload: {} } as never,
|
|
3450
|
+
request: {
|
|
3451
|
+
amount: '1',
|
|
3452
|
+
currency: asset,
|
|
3453
|
+
decimals: 6,
|
|
3454
|
+
feePayer: false,
|
|
3455
|
+
},
|
|
3456
|
+
} as never)
|
|
3457
|
+
|
|
3458
|
+
expect(normalized.feePayer).toBe(false)
|
|
3459
|
+
})
|
|
3341
3460
|
})
|
|
3342
3461
|
|
|
3343
3462
|
describe('default currency resolution', () => {
|