mppx 0.5.13 → 0.5.16
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 +23 -0
- package/dist/Method.d.ts +5 -2
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +8 -2
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +17 -10
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.js +5 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +4 -0
- package/dist/server/Transport.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.map +1 -1
- package/dist/tempo/Methods.js +4 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +20 -10
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +99 -23
- 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 +6 -0
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +4 -0
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +79 -48
- 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/transport.d.ts +0 -7
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +84 -13
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +5 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +202 -63
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +1 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +38 -15
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/package.json +2 -2
- package/src/Method.ts +5 -2
- package/src/internal/changeset.test.ts +106 -0
- package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +10 -2
- package/src/proxy/Proxy.test.ts +149 -1
- package/src/server/Mppx.test.ts +120 -0
- package/src/server/Mppx.ts +27 -11
- package/src/server/Request.test.ts +46 -1
- package/src/server/Request.ts +6 -1
- package/src/server/Transport.test.ts +2 -0
- package/src/server/Transport.ts +4 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +13 -0
- package/src/tempo/Methods.ts +23 -16
- package/src/tempo/client/SessionManager.ts +32 -9
- package/src/tempo/internal/fee-payer.test.ts +88 -16
- package/src/tempo/internal/fee-payer.ts +118 -23
- package/src/tempo/server/Charge.test.ts +73 -0
- package/src/tempo/server/Charge.ts +6 -0
- package/src/tempo/server/Session.test.ts +934 -47
- package/src/tempo/server/Session.ts +100 -52
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +321 -10
- package/src/tempo/server/internal/transport.ts +101 -14
- package/src/tempo/session/Chain.test.ts +225 -2
- package/src/tempo/session/Chain.ts +250 -65
- package/src/tempo/session/ChannelStore.test.ts +23 -0
- package/src/tempo/session/ChannelStore.ts +46 -13
- package/src/viem/Client.test.ts +52 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem'
|
|
2
|
-
import { waitForTransactionReceipt } from 'viem/actions'
|
|
1
|
+
import { type Address, encodeFunctionData, erc20Abi, type Hex, zeroAddress } from 'viem'
|
|
2
|
+
import { prepareTransactionRequest, signTransaction, waitForTransactionReceipt } from 'viem/actions'
|
|
3
3
|
import { Addresses, Transaction } from 'viem/tempo'
|
|
4
4
|
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
5
5
|
import { nodeEnv } from '~test/config.js'
|
|
@@ -17,10 +17,12 @@ import {
|
|
|
17
17
|
broadcastOpenTransaction,
|
|
18
18
|
broadcastTopUpTransaction,
|
|
19
19
|
closeOnChain,
|
|
20
|
+
escrowAbi,
|
|
20
21
|
getOnChainChannel,
|
|
21
22
|
settleOnChain,
|
|
22
23
|
verifyTopUpTransaction,
|
|
23
24
|
} from './Chain.js'
|
|
25
|
+
import * as Channel from './Channel.js'
|
|
24
26
|
import { signVoucher } from './Voucher.js'
|
|
25
27
|
|
|
26
28
|
const isLocalnet = nodeEnv === 'localnet'
|
|
@@ -397,6 +399,116 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
397
399
|
).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
|
|
398
400
|
})
|
|
399
401
|
|
|
402
|
+
test('fee-payer: rejects transactions whose gas budget exceeds sponsor policy', async () => {
|
|
403
|
+
const salt = nextSalt()
|
|
404
|
+
const deposit = 5_000_000n
|
|
405
|
+
|
|
406
|
+
const approveData = encodeFunctionData({
|
|
407
|
+
abi: erc20Abi,
|
|
408
|
+
functionName: 'approve',
|
|
409
|
+
args: [escrowContract, deposit],
|
|
410
|
+
})
|
|
411
|
+
const openData = encodeFunctionData({
|
|
412
|
+
abi: escrowAbi,
|
|
413
|
+
functionName: 'open',
|
|
414
|
+
args: [recipient, currency, deposit, salt, zeroAddress],
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
const channelId = Channel.computeId({
|
|
418
|
+
authorizedSigner: zeroAddress,
|
|
419
|
+
chainId: chain.id,
|
|
420
|
+
escrowContract,
|
|
421
|
+
payee: recipient,
|
|
422
|
+
payer: payer.address,
|
|
423
|
+
salt,
|
|
424
|
+
token: currency,
|
|
425
|
+
}) as Hex
|
|
426
|
+
|
|
427
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
428
|
+
account: payer,
|
|
429
|
+
calls: [
|
|
430
|
+
{ to: currency, data: approveData },
|
|
431
|
+
{ to: escrowContract, data: openData },
|
|
432
|
+
],
|
|
433
|
+
feePayer: true,
|
|
434
|
+
feeToken: currency,
|
|
435
|
+
} as never)
|
|
436
|
+
prepared.gas = 2_000_001n
|
|
437
|
+
|
|
438
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
439
|
+
|
|
440
|
+
await expect(
|
|
441
|
+
broadcastOpenTransaction({
|
|
442
|
+
client,
|
|
443
|
+
serializedTransaction: serializedTransaction as Hex,
|
|
444
|
+
escrowContract,
|
|
445
|
+
channelId,
|
|
446
|
+
recipient,
|
|
447
|
+
currency,
|
|
448
|
+
feePayer: accounts[0],
|
|
449
|
+
}),
|
|
450
|
+
).rejects.toThrow('gas exceeds sponsor policy')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('fee-payer: rejects smuggled second open call', async () => {
|
|
454
|
+
const deposit = 5_000_000n
|
|
455
|
+
const smuggledDeposit = 7_000_000n
|
|
456
|
+
const salt = nextSalt()
|
|
457
|
+
const smuggledSalt = nextSalt()
|
|
458
|
+
|
|
459
|
+
const approveData = encodeFunctionData({
|
|
460
|
+
abi: erc20Abi,
|
|
461
|
+
functionName: 'approve',
|
|
462
|
+
args: [escrowContract, deposit + smuggledDeposit],
|
|
463
|
+
})
|
|
464
|
+
const openData = encodeFunctionData({
|
|
465
|
+
abi: escrowAbi,
|
|
466
|
+
functionName: 'open',
|
|
467
|
+
args: [recipient, currency, deposit, salt, zeroAddress],
|
|
468
|
+
})
|
|
469
|
+
const smuggledOpenData = encodeFunctionData({
|
|
470
|
+
abi: escrowAbi,
|
|
471
|
+
functionName: 'open',
|
|
472
|
+
args: [accounts[3].address, currency, smuggledDeposit, smuggledSalt, zeroAddress],
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const channelId = Channel.computeId({
|
|
476
|
+
authorizedSigner: zeroAddress,
|
|
477
|
+
chainId: chain.id,
|
|
478
|
+
escrowContract,
|
|
479
|
+
payee: recipient,
|
|
480
|
+
payer: payer.address,
|
|
481
|
+
salt,
|
|
482
|
+
token: currency,
|
|
483
|
+
}) as Hex
|
|
484
|
+
|
|
485
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
486
|
+
account: payer,
|
|
487
|
+
calls: [
|
|
488
|
+
{ to: currency, data: approveData },
|
|
489
|
+
{ to: escrowContract, data: openData },
|
|
490
|
+
{ to: escrowContract, data: smuggledOpenData },
|
|
491
|
+
],
|
|
492
|
+
feePayer: true,
|
|
493
|
+
feeToken: currency,
|
|
494
|
+
} as never)
|
|
495
|
+
prepared.gas = prepared.gas! + 5_000n
|
|
496
|
+
|
|
497
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
498
|
+
|
|
499
|
+
await expect(
|
|
500
|
+
broadcastOpenTransaction({
|
|
501
|
+
client,
|
|
502
|
+
serializedTransaction: serializedTransaction as Hex,
|
|
503
|
+
escrowContract,
|
|
504
|
+
channelId,
|
|
505
|
+
recipient,
|
|
506
|
+
currency,
|
|
507
|
+
feePayer: accounts[0],
|
|
508
|
+
}),
|
|
509
|
+
).rejects.toThrow('fee-sponsored open transaction contains a smuggled call')
|
|
510
|
+
})
|
|
511
|
+
|
|
400
512
|
test('duplicate broadcast returns fallback with txHash undefined', async () => {
|
|
401
513
|
const salt = nextSalt()
|
|
402
514
|
const deposit = 5_000_000n
|
|
@@ -727,6 +839,117 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
727
839
|
}),
|
|
728
840
|
).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
|
|
729
841
|
})
|
|
842
|
+
|
|
843
|
+
test('fee-payer: rejects topUp transactions whose gas budget exceeds sponsor policy', async () => {
|
|
844
|
+
const salt = nextSalt()
|
|
845
|
+
const deposit = 5_000_000n
|
|
846
|
+
const topUpAmount = 3_000_000n
|
|
847
|
+
|
|
848
|
+
const { channelId } = await openChannel({
|
|
849
|
+
escrow: escrowContract,
|
|
850
|
+
payer,
|
|
851
|
+
payee: recipient,
|
|
852
|
+
token: currency,
|
|
853
|
+
deposit,
|
|
854
|
+
salt,
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
const approveData = encodeFunctionData({
|
|
858
|
+
abi: erc20Abi,
|
|
859
|
+
functionName: 'approve',
|
|
860
|
+
args: [escrowContract, topUpAmount],
|
|
861
|
+
})
|
|
862
|
+
const topUpData = encodeFunctionData({
|
|
863
|
+
abi: escrowAbi,
|
|
864
|
+
functionName: 'topUp',
|
|
865
|
+
args: [channelId, topUpAmount],
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
869
|
+
account: payer,
|
|
870
|
+
calls: [
|
|
871
|
+
{ to: currency, data: approveData },
|
|
872
|
+
{ to: escrowContract, data: topUpData },
|
|
873
|
+
],
|
|
874
|
+
feePayer: true,
|
|
875
|
+
feeToken: currency,
|
|
876
|
+
} as never)
|
|
877
|
+
prepared.gas = 2_000_001n
|
|
878
|
+
|
|
879
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
880
|
+
|
|
881
|
+
await expect(
|
|
882
|
+
broadcastTopUpTransaction({
|
|
883
|
+
client,
|
|
884
|
+
serializedTransaction: serializedTransaction as Hex,
|
|
885
|
+
escrowContract,
|
|
886
|
+
channelId,
|
|
887
|
+
currency: asset,
|
|
888
|
+
declaredDeposit: topUpAmount,
|
|
889
|
+
previousDeposit: deposit,
|
|
890
|
+
feePayer: accounts[0],
|
|
891
|
+
}),
|
|
892
|
+
).rejects.toThrow('gas exceeds sponsor policy')
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
test('fee-payer: rejects smuggled second topUp call', async () => {
|
|
896
|
+
const salt = nextSalt()
|
|
897
|
+
const deposit = 5_000_000n
|
|
898
|
+
const topUpAmount = 3_000_000n
|
|
899
|
+
const smuggledAmount = 4_000_000n
|
|
900
|
+
|
|
901
|
+
const { channelId } = await openChannel({
|
|
902
|
+
escrow: escrowContract,
|
|
903
|
+
payer,
|
|
904
|
+
payee: recipient,
|
|
905
|
+
token: currency,
|
|
906
|
+
deposit,
|
|
907
|
+
salt,
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
const approveData = encodeFunctionData({
|
|
911
|
+
abi: erc20Abi,
|
|
912
|
+
functionName: 'approve',
|
|
913
|
+
args: [escrowContract, topUpAmount + smuggledAmount],
|
|
914
|
+
})
|
|
915
|
+
const topUpData = encodeFunctionData({
|
|
916
|
+
abi: escrowAbi,
|
|
917
|
+
functionName: 'topUp',
|
|
918
|
+
args: [channelId, topUpAmount],
|
|
919
|
+
})
|
|
920
|
+
const smuggledTopUpData = encodeFunctionData({
|
|
921
|
+
abi: escrowAbi,
|
|
922
|
+
functionName: 'topUp',
|
|
923
|
+
args: [channelId, smuggledAmount],
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
const prepared = await prepareTransactionRequest(client, {
|
|
927
|
+
account: payer,
|
|
928
|
+
calls: [
|
|
929
|
+
{ to: currency, data: approveData },
|
|
930
|
+
{ to: escrowContract, data: topUpData },
|
|
931
|
+
{ to: escrowContract, data: smuggledTopUpData },
|
|
932
|
+
],
|
|
933
|
+
feePayer: true,
|
|
934
|
+
feeToken: currency,
|
|
935
|
+
} as never)
|
|
936
|
+
prepared.gas = prepared.gas! + 5_000n
|
|
937
|
+
|
|
938
|
+
const serializedTransaction = await signTransaction(client, prepared as never)
|
|
939
|
+
|
|
940
|
+
await expect(
|
|
941
|
+
broadcastTopUpTransaction({
|
|
942
|
+
client,
|
|
943
|
+
serializedTransaction: serializedTransaction as Hex,
|
|
944
|
+
escrowContract,
|
|
945
|
+
channelId,
|
|
946
|
+
currency: asset,
|
|
947
|
+
declaredDeposit: topUpAmount,
|
|
948
|
+
previousDeposit: deposit,
|
|
949
|
+
feePayer: accounts[0],
|
|
950
|
+
}),
|
|
951
|
+
).rejects.toThrow('fee-sponsored topUp transaction contains a smuggled call')
|
|
952
|
+
})
|
|
730
953
|
})
|
|
731
954
|
|
|
732
955
|
describe('settleOnChain', () => {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Client,
|
|
5
5
|
decodeFunctionData,
|
|
6
6
|
encodeFunctionData,
|
|
7
|
+
erc20Abi,
|
|
7
8
|
getAbiItem,
|
|
8
9
|
type Hex,
|
|
9
10
|
type ReadContractReturnType,
|
|
@@ -23,7 +24,7 @@ import { Transaction } from 'viem/tempo'
|
|
|
23
24
|
import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
|
|
24
25
|
import * as TempoAddress from '../internal/address.js'
|
|
25
26
|
import * as defaults from '../internal/defaults.js'
|
|
26
|
-
import
|
|
27
|
+
import * as FeePayer from '../internal/fee-payer.js'
|
|
27
28
|
import * as Channel from './Channel.js'
|
|
28
29
|
import { escrowAbi } from './escrow.abi.js'
|
|
29
30
|
import type { SignedVoucher } from './Types.js'
|
|
@@ -230,6 +231,159 @@ export type BroadcastResult = {
|
|
|
230
231
|
onChain: OnChainChannel
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
type TempoCall = NonNullable<ReturnType<(typeof Transaction)['deserialize']>['calls']>[number]
|
|
235
|
+
|
|
236
|
+
function assertCallHasTargetAndData(call: TempoCall): { to: Address; data: Hex } {
|
|
237
|
+
if (!call.to || !call.data) {
|
|
238
|
+
throw new BadRequestError({
|
|
239
|
+
reason: 'fee-sponsored transactions must not contain calls without target or data',
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
return { to: call.to, data: call.data }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateSponsoredApproveCall(parameters: {
|
|
246
|
+
action: 'open' | 'topUp'
|
|
247
|
+
call: TempoCall
|
|
248
|
+
currency: Address
|
|
249
|
+
escrowContract: Address
|
|
250
|
+
expectedAmount: bigint
|
|
251
|
+
}) {
|
|
252
|
+
const { action, call, currency, escrowContract, expectedAmount } = parameters
|
|
253
|
+
const { to, data } = assertCallHasTargetAndData(call)
|
|
254
|
+
|
|
255
|
+
if (!TempoAddress.isEqual(to, currency) || data.slice(0, 10) !== erc20ApproveSelector) {
|
|
256
|
+
throw new BadRequestError({
|
|
257
|
+
reason: `fee-sponsored ${action} transaction contains an unauthorized call`,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const { args } = decodeFunctionData({ abi: erc20Abi, data })
|
|
262
|
+
const [spender, amount] = args as readonly [Address, bigint]
|
|
263
|
+
|
|
264
|
+
if (!TempoAddress.isEqual(spender, escrowContract)) {
|
|
265
|
+
throw new BadRequestError({
|
|
266
|
+
reason: `fee-sponsored ${action} transaction approve spender does not match escrow contract`,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (amount !== expectedAmount) {
|
|
271
|
+
throw new BadRequestError({
|
|
272
|
+
reason: `fee-sponsored ${action} transaction approve amount does not match requested amount`,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function validateSponsoredOpenCalls(parameters: {
|
|
278
|
+
calls: readonly TempoCall[]
|
|
279
|
+
currency: Address
|
|
280
|
+
escrowContract: Address
|
|
281
|
+
deposit: bigint
|
|
282
|
+
}) {
|
|
283
|
+
const { calls, currency, escrowContract, deposit } = parameters
|
|
284
|
+
|
|
285
|
+
let openCall: TempoCall | undefined
|
|
286
|
+
let approveCall: TempoCall | undefined
|
|
287
|
+
|
|
288
|
+
for (const call of calls) {
|
|
289
|
+
const { to, data } = assertCallHasTargetAndData(call)
|
|
290
|
+
const selector = data.slice(0, 10)
|
|
291
|
+
const isOpen = TempoAddress.isEqual(to, escrowContract) && selector === escrowOpenSelector
|
|
292
|
+
const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
|
|
293
|
+
|
|
294
|
+
if (isApprove) {
|
|
295
|
+
if (approveCall || openCall) {
|
|
296
|
+
throw new BadRequestError({
|
|
297
|
+
reason: 'fee-sponsored open transaction contains a smuggled call',
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
approveCall = call
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (isOpen) {
|
|
305
|
+
if (openCall) {
|
|
306
|
+
throw new BadRequestError({
|
|
307
|
+
reason: 'fee-sponsored open transaction contains a smuggled call',
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
openCall = call
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw new BadRequestError({
|
|
315
|
+
reason: 'fee-sponsored open transaction contains an unauthorized call',
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (approveCall) {
|
|
320
|
+
validateSponsoredApproveCall({
|
|
321
|
+
action: 'open',
|
|
322
|
+
call: approveCall,
|
|
323
|
+
currency,
|
|
324
|
+
escrowContract,
|
|
325
|
+
expectedAmount: deposit,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return openCall
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateSponsoredTopUpCalls(parameters: {
|
|
333
|
+
calls: readonly TempoCall[]
|
|
334
|
+
currency: Address
|
|
335
|
+
escrowContract: Address
|
|
336
|
+
topUpAmount: bigint
|
|
337
|
+
}) {
|
|
338
|
+
const { calls, currency, escrowContract, topUpAmount } = parameters
|
|
339
|
+
|
|
340
|
+
let topUpCall: TempoCall | undefined
|
|
341
|
+
let approveCall: TempoCall | undefined
|
|
342
|
+
|
|
343
|
+
for (const call of calls) {
|
|
344
|
+
const { to, data } = assertCallHasTargetAndData(call)
|
|
345
|
+
const selector = data.slice(0, 10)
|
|
346
|
+
const isTopUp = TempoAddress.isEqual(to, escrowContract) && selector === escrowTopUpSelector
|
|
347
|
+
const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
|
|
348
|
+
|
|
349
|
+
if (isApprove) {
|
|
350
|
+
if (approveCall || topUpCall) {
|
|
351
|
+
throw new BadRequestError({
|
|
352
|
+
reason: 'fee-sponsored topUp transaction contains a smuggled call',
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
approveCall = call
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (isTopUp) {
|
|
360
|
+
if (topUpCall) {
|
|
361
|
+
throw new BadRequestError({
|
|
362
|
+
reason: 'fee-sponsored topUp transaction contains a smuggled call',
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
topUpCall = call
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
throw new BadRequestError({
|
|
370
|
+
reason: 'fee-sponsored topUp transaction contains an unauthorized call',
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (approveCall) {
|
|
375
|
+
validateSponsoredApproveCall({
|
|
376
|
+
action: 'topUp',
|
|
377
|
+
call: approveCall,
|
|
378
|
+
currency,
|
|
379
|
+
escrowContract,
|
|
380
|
+
expectedAmount: topUpAmount,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return topUpCall
|
|
385
|
+
}
|
|
386
|
+
|
|
233
387
|
export async function broadcastOpenTransaction(parameters: {
|
|
234
388
|
client: Client
|
|
235
389
|
serializedTransaction: Hex
|
|
@@ -237,6 +391,8 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
237
391
|
channelId: Hex
|
|
238
392
|
recipient: Address
|
|
239
393
|
currency: Address
|
|
394
|
+
challengeExpires?: string | undefined
|
|
395
|
+
feePayerPolicy?: Partial<FeePayer.Policy> | undefined
|
|
240
396
|
feePayer?: Account | undefined
|
|
241
397
|
/** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
|
|
242
398
|
waitForConfirmation?: boolean | undefined
|
|
@@ -248,11 +404,13 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
248
404
|
channelId,
|
|
249
405
|
recipient,
|
|
250
406
|
currency,
|
|
407
|
+
challengeExpires,
|
|
408
|
+
feePayerPolicy,
|
|
251
409
|
feePayer,
|
|
252
410
|
waitForConfirmation = true,
|
|
253
411
|
} = parameters
|
|
254
412
|
|
|
255
|
-
if (feePayer && !isTempoTransaction(serializedTransaction))
|
|
413
|
+
if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
|
|
256
414
|
throw new BadRequestError({
|
|
257
415
|
reason: 'Only Tempo (0x76/0x78) transactions are supported',
|
|
258
416
|
})
|
|
@@ -265,37 +423,40 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
265
423
|
|
|
266
424
|
const calls = transaction.calls ?? []
|
|
267
425
|
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
426
|
+
const sponsoredOpenCall = feePayer
|
|
427
|
+
? validateSponsoredOpenCalls({
|
|
428
|
+
calls,
|
|
429
|
+
currency,
|
|
430
|
+
escrowContract,
|
|
431
|
+
deposit: (() => {
|
|
432
|
+
const candidate = calls.find((call) => {
|
|
433
|
+
if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
|
|
434
|
+
if (!call.data) return false
|
|
435
|
+
return call.data.slice(0, 10) === escrowOpenSelector
|
|
436
|
+
})
|
|
437
|
+
if (!candidate?.data)
|
|
438
|
+
throw new BadRequestError({
|
|
439
|
+
reason: 'transaction does not contain a valid escrow open call',
|
|
440
|
+
})
|
|
441
|
+
const { args } = decodeFunctionData({ abi: escrowAbi, data: candidate.data })
|
|
442
|
+
return (args as readonly [Address, Address, bigint, Hex, Address])[2]
|
|
443
|
+
})(),
|
|
444
|
+
})
|
|
445
|
+
: undefined
|
|
446
|
+
|
|
447
|
+
const openCall =
|
|
448
|
+
sponsoredOpenCall ??
|
|
449
|
+
calls.find((call) => {
|
|
450
|
+
if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
|
|
451
|
+
if (!call.data) return false
|
|
452
|
+
return call.data.slice(0, 10) === escrowOpenSelector
|
|
453
|
+
})
|
|
273
454
|
|
|
274
455
|
if (!openCall)
|
|
275
456
|
throw new BadRequestError({
|
|
276
457
|
reason: 'transaction does not contain a valid escrow open call',
|
|
277
458
|
})
|
|
278
459
|
|
|
279
|
-
if (feePayer) {
|
|
280
|
-
for (const call of calls) {
|
|
281
|
-
if (!call.to || !call.data) {
|
|
282
|
-
throw new BadRequestError({
|
|
283
|
-
reason: 'fee-sponsored transactions must not contain calls without target or data',
|
|
284
|
-
})
|
|
285
|
-
}
|
|
286
|
-
const selector = call.data.slice(0, 10)
|
|
287
|
-
const isEscrowOpen =
|
|
288
|
-
TempoAddress.isEqual(call.to, escrowContract) && selector === escrowOpenSelector
|
|
289
|
-
const isTokenApprove =
|
|
290
|
-
TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
|
|
291
|
-
if (!isEscrowOpen && !isTokenApprove) {
|
|
292
|
-
throw new BadRequestError({
|
|
293
|
-
reason: 'fee-sponsored open transaction contains an unauthorized call',
|
|
294
|
-
})
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
460
|
const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
|
|
300
461
|
const [payee, token, deposit, salt, authorizedSigner] = openArgs as readonly [
|
|
301
462
|
Address,
|
|
@@ -337,12 +498,24 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
337
498
|
|
|
338
499
|
const serializedTransaction_final = await (async () => {
|
|
339
500
|
if (feePayer) {
|
|
340
|
-
|
|
341
|
-
|
|
501
|
+
if (!sponsoredOpenCall)
|
|
502
|
+
throw new BadRequestError({
|
|
503
|
+
reason: 'transaction does not contain a valid escrow open call',
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const sponsored = FeePayer.prepareSponsoredTransaction({
|
|
342
507
|
account: feePayer,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
508
|
+
challengeExpires,
|
|
509
|
+
chainId: client.chain!.id,
|
|
510
|
+
details: { channelId, currency, recipient },
|
|
511
|
+
expectedFeeToken: defaults.currency[client.chain?.id as keyof typeof defaults.currency],
|
|
512
|
+
policy: feePayerPolicy,
|
|
513
|
+
transaction: {
|
|
514
|
+
...transaction,
|
|
515
|
+
...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
|
|
516
|
+
},
|
|
517
|
+
})
|
|
518
|
+
return signTransaction(client, sponsored as never)
|
|
346
519
|
}
|
|
347
520
|
return serializedTransaction
|
|
348
521
|
})()
|
|
@@ -407,6 +580,8 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
407
580
|
currency: Address
|
|
408
581
|
declaredDeposit: bigint
|
|
409
582
|
previousDeposit: bigint
|
|
583
|
+
challengeExpires?: string | undefined
|
|
584
|
+
feePayerPolicy?: Partial<FeePayer.Policy> | undefined
|
|
410
585
|
feePayer?: Account | undefined
|
|
411
586
|
}): Promise<{ txHash: Hex; newDeposit: bigint }> {
|
|
412
587
|
const {
|
|
@@ -417,10 +592,12 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
417
592
|
currency,
|
|
418
593
|
declaredDeposit,
|
|
419
594
|
previousDeposit,
|
|
595
|
+
challengeExpires,
|
|
596
|
+
feePayerPolicy,
|
|
420
597
|
feePayer,
|
|
421
598
|
} = parameters
|
|
422
599
|
|
|
423
|
-
if (feePayer && !isTempoTransaction(serializedTransaction))
|
|
600
|
+
if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
|
|
424
601
|
throw new BadRequestError({
|
|
425
602
|
reason: 'Only Tempo (0x76/0x78) transactions are supported',
|
|
426
603
|
})
|
|
@@ -433,37 +610,28 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
433
610
|
|
|
434
611
|
const calls = transaction.calls ?? []
|
|
435
612
|
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
613
|
+
const sponsoredTopUpCall = feePayer
|
|
614
|
+
? validateSponsoredTopUpCalls({
|
|
615
|
+
calls,
|
|
616
|
+
currency,
|
|
617
|
+
escrowContract,
|
|
618
|
+
topUpAmount: declaredDeposit,
|
|
619
|
+
})
|
|
620
|
+
: undefined
|
|
621
|
+
|
|
622
|
+
const topUpCall =
|
|
623
|
+
sponsoredTopUpCall ??
|
|
624
|
+
calls.find((call) => {
|
|
625
|
+
if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
|
|
626
|
+
if (!call.data) return false
|
|
627
|
+
return call.data.slice(0, 10) === escrowTopUpSelector
|
|
628
|
+
})
|
|
441
629
|
|
|
442
630
|
if (!topUpCall)
|
|
443
631
|
throw new BadRequestError({
|
|
444
632
|
reason: 'transaction does not contain a valid escrow topUp call',
|
|
445
633
|
})
|
|
446
634
|
|
|
447
|
-
if (feePayer) {
|
|
448
|
-
for (const call of calls) {
|
|
449
|
-
if (!call.to || !call.data) {
|
|
450
|
-
throw new BadRequestError({
|
|
451
|
-
reason: 'fee-sponsored transactions must not contain calls without target or data',
|
|
452
|
-
})
|
|
453
|
-
}
|
|
454
|
-
const selector = call.data.slice(0, 10)
|
|
455
|
-
const isEscrowTopUp =
|
|
456
|
-
TempoAddress.isEqual(call.to, escrowContract) && selector === escrowTopUpSelector
|
|
457
|
-
const isTokenApprove =
|
|
458
|
-
TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
|
|
459
|
-
if (!isEscrowTopUp && !isTokenApprove) {
|
|
460
|
-
throw new BadRequestError({
|
|
461
|
-
reason: 'fee-sponsored topUp transaction contains an unauthorized call',
|
|
462
|
-
})
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
635
|
const { args: topUpArgs } = decodeFunctionData({ abi: escrowAbi, data: topUpCall.data! })
|
|
468
636
|
const [txChannelId, txAmount] = topUpArgs as [Hex, bigint]
|
|
469
637
|
|
|
@@ -480,14 +648,31 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
480
648
|
|
|
481
649
|
const serializedTransaction_final = await (async () => {
|
|
482
650
|
if (feePayer) {
|
|
483
|
-
|
|
484
|
-
|
|
651
|
+
if (!sponsoredTopUpCall)
|
|
652
|
+
throw new BadRequestError({
|
|
653
|
+
reason: 'transaction does not contain a valid escrow topUp call',
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
const expectedFeeToken = defaults.currency[client.chain?.id as keyof typeof defaults.currency]
|
|
657
|
+
const sponsored = FeePayer.prepareSponsoredTransaction({
|
|
485
658
|
account: feePayer,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
659
|
+
challengeExpires,
|
|
660
|
+
chainId: client.chain!.id,
|
|
661
|
+
details: {
|
|
662
|
+
additionalDeposit: declaredDeposit.toString(),
|
|
663
|
+
channelId,
|
|
664
|
+
currency,
|
|
665
|
+
},
|
|
666
|
+
expectedFeeToken,
|
|
667
|
+
policy: feePayerPolicy,
|
|
668
|
+
transaction: {
|
|
669
|
+
...transaction,
|
|
670
|
+
...((transaction.feeToken ?? expectedFeeToken)
|
|
671
|
+
? { feeToken: transaction.feeToken ?? expectedFeeToken }
|
|
672
|
+
: {}),
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
return signTransaction(client, sponsored as never)
|
|
491
676
|
}
|
|
492
677
|
return serializedTransaction
|
|
493
678
|
})()
|
|
@@ -7,6 +7,10 @@ import * as ChannelStore from './ChannelStore.js'
|
|
|
7
7
|
|
|
8
8
|
const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
|
|
9
9
|
const channelId2 = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex
|
|
10
|
+
const lowerCaseAliasChannelId = `0x${'ab'.repeat(31)}cd` as Hex
|
|
11
|
+
const mixedCaseAliasChannelId = lowerCaseAliasChannelId.replace(/[a-f]/g, (character, index) =>
|
|
12
|
+
index % 2 === 0 ? character.toUpperCase() : character,
|
|
13
|
+
) as Hex
|
|
10
14
|
|
|
11
15
|
function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.State {
|
|
12
16
|
return {
|
|
@@ -112,6 +116,25 @@ describe('channelStore', () => {
|
|
|
112
116
|
expect(typeof loaded!.deposit).toBe('bigint')
|
|
113
117
|
expect(typeof loaded!.createdAt).toBe('string')
|
|
114
118
|
})
|
|
119
|
+
|
|
120
|
+
test('treats case-variant channelIds as the same record', async () => {
|
|
121
|
+
const cs = ChannelStore.fromStore(Store.memory())
|
|
122
|
+
await cs.updateChannel(mixedCaseAliasChannelId, () =>
|
|
123
|
+
makeChannel({ channelId: mixedCaseAliasChannelId }),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const loaded = await cs.getChannel(lowerCaseAliasChannelId)
|
|
127
|
+
expect(loaded).not.toBeNull()
|
|
128
|
+
expect(loaded!.channelId).toBe(lowerCaseAliasChannelId)
|
|
129
|
+
|
|
130
|
+
await cs.updateChannel(lowerCaseAliasChannelId, (current) =>
|
|
131
|
+
current ? { ...current, spent: 1_000_000n } : null,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const aliased = await cs.getChannel(mixedCaseAliasChannelId)
|
|
135
|
+
expect(aliased!.channelId).toBe(lowerCaseAliasChannelId)
|
|
136
|
+
expect(aliased!.spent).toBe(1_000_000n)
|
|
137
|
+
})
|
|
115
138
|
})
|
|
116
139
|
|
|
117
140
|
describe('updateChannel', () => {
|