mppx 0.6.10 → 0.6.12
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 +12 -0
- package/dist/cli/plugins/tempo.js +1 -1
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/tempo/client/ChannelOps.js +2 -2
- package/dist/tempo/client/ChannelOps.js.map +1 -1
- package/dist/tempo/client/Session.js +4 -4
- package/dist/tempo/client/Session.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +32 -3
- package/dist/tempo/server/Charge.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/package.json +1 -1
- package/src/cli/cli.test.ts +94 -3
- package/src/cli/plugins/tempo.ts +1 -1
- package/src/tempo/client/ChannelOps.test.ts +2 -2
- package/src/tempo/client/ChannelOps.ts +1 -1
- package/src/tempo/client/Session.test.ts +60 -4
- package/src/tempo/client/Session.ts +2 -2
- package/src/tempo/server/Charge.test.ts +145 -0
- package/src/tempo/server/Charge.ts +36 -4
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,ixtcAAixtc,CAAA"}
|
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -4,13 +4,13 @@ import * as os from 'node:os'
|
|
|
4
4
|
import * as path from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
|
|
7
|
-
import { parseUnits } from 'viem'
|
|
7
|
+
import { decodeFunctionData, erc20Abi, parseUnits, type Address } from 'viem'
|
|
8
8
|
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
9
|
-
import { Addresses } from 'viem/tempo'
|
|
9
|
+
import { Addresses, Transaction } from 'viem/tempo'
|
|
10
10
|
import { afterAll, describe, expect, test } from 'vp/test'
|
|
11
11
|
import * as Http from '~test/Http.js'
|
|
12
12
|
import { rpcUrl } from '~test/tempo/prool.js'
|
|
13
|
-
import { deployEscrow } from '~test/tempo/session.js'
|
|
13
|
+
import { deployEscrow, escrowAbi } from '~test/tempo/session.js'
|
|
14
14
|
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
|
|
15
15
|
|
|
16
16
|
import * as Challenge from '../Challenge.js'
|
|
@@ -29,6 +29,10 @@ import cli from './cli.js'
|
|
|
29
29
|
const testPrivateKey = generatePrivateKey()
|
|
30
30
|
const testAccount = privateKeyToAccount(testPrivateKey)
|
|
31
31
|
const cliTestXdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-xdg-'))
|
|
32
|
+
const cliSessionFeePayerPolicy = {
|
|
33
|
+
maxGas: 2_250_000n,
|
|
34
|
+
maxTotalFee: 60_000_000_000_000_000n,
|
|
35
|
+
}
|
|
32
36
|
|
|
33
37
|
afterAll(() => {
|
|
34
38
|
fs.rmSync(cliTestXdgDataHome, { recursive: true, force: true })
|
|
@@ -698,6 +702,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
|
|
|
698
702
|
escrowContract: escrow,
|
|
699
703
|
chainId: client.chain.id,
|
|
700
704
|
feePayer: true,
|
|
705
|
+
feePayerPolicy: cliSessionFeePayerPolicy,
|
|
701
706
|
}),
|
|
702
707
|
],
|
|
703
708
|
realm: 'cli-test-multifetch',
|
|
@@ -727,6 +732,89 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
|
|
|
727
732
|
}
|
|
728
733
|
})
|
|
729
734
|
|
|
735
|
+
test('prefers CLI deposit over server suggestedDeposit', { timeout: 120_000 }, async () => {
|
|
736
|
+
await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
|
|
737
|
+
await fundAccount({ address: testAccount.address, token: asset })
|
|
738
|
+
|
|
739
|
+
const escrow = await deployEscrow()
|
|
740
|
+
let openCredential: SessionCredentialPayload | undefined
|
|
741
|
+
|
|
742
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
743
|
+
const authHeader = req.headers.authorization
|
|
744
|
+
if (!authHeader) {
|
|
745
|
+
res.writeHead(402, {
|
|
746
|
+
'WWW-Authenticate': Challenge.serialize(
|
|
747
|
+
Challenge.from({
|
|
748
|
+
id: 'cli-deposit-override',
|
|
749
|
+
realm: 'cli-test-deposit-override',
|
|
750
|
+
method: 'tempo',
|
|
751
|
+
intent: 'session',
|
|
752
|
+
request: {
|
|
753
|
+
amount: '1000000',
|
|
754
|
+
currency: asset,
|
|
755
|
+
decimals: 6,
|
|
756
|
+
recipient: accounts[0].address,
|
|
757
|
+
suggestedDeposit: '7000000',
|
|
758
|
+
unitType: 'token',
|
|
759
|
+
methodDetails: {
|
|
760
|
+
chainId: chain.id,
|
|
761
|
+
escrowContract: escrow,
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
}),
|
|
765
|
+
),
|
|
766
|
+
})
|
|
767
|
+
res.end()
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const cred = Credential.deserialize<SessionCredentialPayload>(authHeader)
|
|
773
|
+
if (cred.payload.action === 'open') openCredential = cred.payload
|
|
774
|
+
} catch {}
|
|
775
|
+
|
|
776
|
+
res.writeHead(200)
|
|
777
|
+
res.end('scraped-content')
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
await serve([httpServer.url, '--rpc-url', rpcUrl, '-s', '-M', 'deposit=10'], {
|
|
782
|
+
env: { MPPX_PRIVATE_KEY: testPrivateKey },
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
expect(openCredential).toBeDefined()
|
|
786
|
+
expect(openCredential?.action).toBe('open')
|
|
787
|
+
if (!openCredential || openCredential.action !== 'open')
|
|
788
|
+
throw new Error('missing open credential')
|
|
789
|
+
|
|
790
|
+
const transaction = Transaction.deserialize(openCredential.transaction)
|
|
791
|
+
if (!('calls' in transaction)) throw new Error('unexpected transaction type')
|
|
792
|
+
const [approveCall, openCall] = transaction.calls as readonly [
|
|
793
|
+
{ to?: Address; data?: `0x${string}` },
|
|
794
|
+
{ to?: Address; data?: `0x${string}` },
|
|
795
|
+
]
|
|
796
|
+
const approve = decodeFunctionData({ abi: erc20Abi, data: approveCall.data ?? '0x' })
|
|
797
|
+
const open = decodeFunctionData({ abi: escrowAbi, data: openCall.data ?? '0x' })
|
|
798
|
+
const approveArgs = approve.args as readonly [Address, bigint]
|
|
799
|
+
const openArgs = open.args as readonly [Address, Address, bigint, string, Address]
|
|
800
|
+
|
|
801
|
+
expect(approveCall.to).toBe(asset)
|
|
802
|
+
expect(approve.functionName).toBe('approve')
|
|
803
|
+
expect(approveArgs[0].toLowerCase()).toBe(escrow.toLowerCase())
|
|
804
|
+
expect(approveArgs[1]).toBe(10_000_000n)
|
|
805
|
+
|
|
806
|
+
expect(openCall.to?.toLowerCase()).toBe(escrow.toLowerCase())
|
|
807
|
+
expect(open.functionName).toBe('open')
|
|
808
|
+
expect(openArgs[0].toLowerCase()).toBe(accounts[0].address.toLowerCase())
|
|
809
|
+
expect(openArgs[1].toLowerCase()).toBe(asset.toLowerCase())
|
|
810
|
+
expect(openArgs[2]).toBe(10_000_000n)
|
|
811
|
+
expect(openArgs[3]).toEqual(expect.any(String))
|
|
812
|
+
expect(openArgs[4].toLowerCase()).toBe(testAccount.address.toLowerCase())
|
|
813
|
+
} finally {
|
|
814
|
+
httpServer.close()
|
|
815
|
+
}
|
|
816
|
+
})
|
|
817
|
+
|
|
730
818
|
test('bug: non-SSE open should not double-charge tick amount', { timeout: 120_000 }, async () => {
|
|
731
819
|
await fundAccount({ address: testAccount.address, token: Addresses.pathUsd })
|
|
732
820
|
await fundAccount({ address: testAccount.address, token: asset })
|
|
@@ -744,6 +832,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
|
|
|
744
832
|
escrowContract: escrow,
|
|
745
833
|
chainId: client.chain.id,
|
|
746
834
|
feePayer: true,
|
|
835
|
+
feePayerPolicy: cliSessionFeePayerPolicy,
|
|
747
836
|
}),
|
|
748
837
|
],
|
|
749
838
|
realm: 'cli-test-double-charge',
|
|
@@ -806,6 +895,7 @@ describe('session multi-fetch (examples/session/multi-fetch)', () => {
|
|
|
806
895
|
escrowContract: escrow,
|
|
807
896
|
chainId: client.chain.id,
|
|
808
897
|
feePayer: true,
|
|
898
|
+
feePayerPolicy: cliSessionFeePayerPolicy,
|
|
809
899
|
}),
|
|
810
900
|
],
|
|
811
901
|
realm: 'cli-test-close-action',
|
|
@@ -883,6 +973,7 @@ describe('session sse (examples/session/sse)', () => {
|
|
|
883
973
|
escrowContract: escrow,
|
|
884
974
|
chainId: client.chain.id,
|
|
885
975
|
feePayer: true,
|
|
976
|
+
feePayerPolicy: cliSessionFeePayerPolicy,
|
|
886
977
|
}),
|
|
887
978
|
],
|
|
888
979
|
realm: 'cli-test-sse',
|
package/src/cli/plugins/tempo.ts
CHANGED
|
@@ -164,7 +164,7 @@ export function tempo() {
|
|
|
164
164
|
.suggestedDeposit as string | undefined
|
|
165
165
|
const cliDeposit = tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
|
|
166
166
|
const resolved =
|
|
167
|
-
|
|
167
|
+
cliDeposit ?? suggestedDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
|
|
168
168
|
if (!resolved) {
|
|
169
169
|
throw new Errors.IncurError({
|
|
170
170
|
code: 'MISSING_DEPOSIT',
|
|
@@ -51,12 +51,12 @@ function makeChallenge(overrides?: Partial<Challenge>): Challenge {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
describe('resolveEscrow', () => {
|
|
54
|
-
test('prefers challenge.request.methodDetails.escrowContract', () => {
|
|
54
|
+
test('prefers escrowContractOverride over challenge.request.methodDetails.escrowContract', () => {
|
|
55
55
|
const challenge = {
|
|
56
56
|
request: { methodDetails: { escrowContract: '0xChallengeEscrow' } },
|
|
57
57
|
}
|
|
58
58
|
const result = resolveEscrow(challenge, 42431, '0xOverride' as Address)
|
|
59
|
-
expect(result).toBe('
|
|
59
|
+
expect(result).toBe('0xOverride')
|
|
60
60
|
})
|
|
61
61
|
|
|
62
62
|
test('falls back to escrowContractOverride', () => {
|
|
@@ -46,8 +46,8 @@ export function resolveEscrow(
|
|
|
46
46
|
const challengeEscrow = (challenge.request.methodDetails as { escrowContract?: string })
|
|
47
47
|
?.escrowContract as Address | undefined
|
|
48
48
|
const escrow =
|
|
49
|
-
challengeEscrow ??
|
|
50
49
|
escrowContractOverride ??
|
|
50
|
+
challengeEscrow ??
|
|
51
51
|
defaults.escrowContract[chainId as keyof typeof defaults.escrowContract]
|
|
52
52
|
if (!escrow)
|
|
53
53
|
throw new Error(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type Address, createClient, type Hex, http } from 'viem'
|
|
1
|
+
import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem'
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
|
-
import { Addresses } from 'viem/tempo'
|
|
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'
|
|
6
6
|
import { deployEscrow, openChannel } from '~test/tempo/session.js'
|
|
@@ -11,6 +11,7 @@ const isLocalnet = nodeEnv === 'localnet'
|
|
|
11
11
|
import * as Challenge from '../../Challenge.js'
|
|
12
12
|
import * as Credential from '../../Credential.js'
|
|
13
13
|
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
|
|
14
|
+
import { escrowAbi } from '../session/Chain.js'
|
|
14
15
|
import type { SessionCredentialPayload } from '../session/Types.js'
|
|
15
16
|
import { session } from './Session.js'
|
|
16
17
|
|
|
@@ -287,11 +288,11 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
|
|
|
287
288
|
expect(cred.source).toContain(`did:pkh:eip155:${chain.id}:`)
|
|
288
289
|
})
|
|
289
290
|
|
|
290
|
-
test('suggestedDeposit
|
|
291
|
+
test('suggestedDeposit used when below maxDeposit', async () => {
|
|
291
292
|
const method = session({
|
|
292
293
|
getClient: () => client,
|
|
293
294
|
account: payer,
|
|
294
|
-
|
|
295
|
+
maxDeposit: '10',
|
|
295
296
|
escrowContract,
|
|
296
297
|
})
|
|
297
298
|
|
|
@@ -306,6 +307,61 @@ describe.runIf(isLocalnet)('session (on-chain)', () => {
|
|
|
306
307
|
}
|
|
307
308
|
})
|
|
308
309
|
|
|
310
|
+
test('prefers local escrowContract and deposit over server challenge overrides', async () => {
|
|
311
|
+
const maliciousEscrow = '0x4444444444444444444444444444444444444444' as Address
|
|
312
|
+
const method = session({
|
|
313
|
+
getClient: () => client,
|
|
314
|
+
account: payer,
|
|
315
|
+
deposit: '10',
|
|
316
|
+
escrowContract,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const result = await method.createCredential({
|
|
320
|
+
challenge: makeLiveChallenge({
|
|
321
|
+
suggestedDeposit: '7000000',
|
|
322
|
+
methodDetails: {
|
|
323
|
+
chainId: chain.id,
|
|
324
|
+
escrowContract: maliciousEscrow,
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
context: {},
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const cred = deserializePayload(result)
|
|
331
|
+
expect(cred.payload.action).toBe('open')
|
|
332
|
+
if (cred.payload.action !== 'open') throw new Error('unexpected action')
|
|
333
|
+
|
|
334
|
+
const transaction = Transaction.deserialize(cred.payload.transaction)
|
|
335
|
+
if (!('calls' in transaction)) throw new Error('unexpected transaction type')
|
|
336
|
+
const [approveCall, openCall] = transaction.calls as readonly [
|
|
337
|
+
{ to?: Address; data?: Hex },
|
|
338
|
+
{ to?: Address; data?: Hex },
|
|
339
|
+
]
|
|
340
|
+
const approve = decodeFunctionData({
|
|
341
|
+
abi: erc20Abi,
|
|
342
|
+
data: approveCall.data ?? '0x',
|
|
343
|
+
})
|
|
344
|
+
const open = decodeFunctionData({
|
|
345
|
+
abi: escrowAbi,
|
|
346
|
+
data: openCall.data ?? '0x',
|
|
347
|
+
})
|
|
348
|
+
const approveArgs = approve.args as readonly [Address, bigint]
|
|
349
|
+
const openArgs = open.args as readonly [Address, Address, bigint, string, Address]
|
|
350
|
+
|
|
351
|
+
expect(approveCall.to).toBe(asset)
|
|
352
|
+
expect(approve.functionName).toBe('approve')
|
|
353
|
+
expect(approveArgs[0].toLowerCase()).toBe(escrowContract.toLowerCase())
|
|
354
|
+
expect(approveArgs[1]).toBe(10_000_000n)
|
|
355
|
+
|
|
356
|
+
expect(openCall.to?.toLowerCase()).toBe(escrowContract.toLowerCase())
|
|
357
|
+
expect(open.functionName).toBe('open')
|
|
358
|
+
expect(openArgs[0].toLowerCase()).toBe(payee.toLowerCase())
|
|
359
|
+
expect(openArgs[1].toLowerCase()).toBe(asset.toLowerCase())
|
|
360
|
+
expect(openArgs[2]).toBe(10_000_000n)
|
|
361
|
+
expect(openArgs[3]).toEqual(expect.any(String))
|
|
362
|
+
expect(openArgs[4].toLowerCase()).toBe(payer.address.toLowerCase())
|
|
363
|
+
})
|
|
364
|
+
|
|
309
365
|
test('maxDeposit alone', async () => {
|
|
310
366
|
const method = session({
|
|
311
367
|
getClient: () => client,
|
|
@@ -131,11 +131,11 @@ export function session(parameters: session.Parameters = {}) {
|
|
|
131
131
|
|
|
132
132
|
const deposit = (() => {
|
|
133
133
|
if (context?.depositRaw) return BigInt(context.depositRaw)
|
|
134
|
+
if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals)
|
|
134
135
|
if (suggestedDeposit !== undefined && maxDeposit !== undefined)
|
|
135
136
|
return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit
|
|
136
|
-
if (suggestedDeposit !== undefined) return suggestedDeposit
|
|
137
137
|
if (maxDeposit !== undefined) return maxDeposit
|
|
138
|
-
if (
|
|
138
|
+
if (suggestedDeposit !== undefined) return suggestedDeposit
|
|
139
139
|
throw new Error(
|
|
140
140
|
'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.',
|
|
141
141
|
)
|
|
@@ -890,6 +890,7 @@ describe('tempo', () => {
|
|
|
890
890
|
const splits = challenge.request.methodDetails?.splits ?? []
|
|
891
891
|
const primaryAmount =
|
|
892
892
|
BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
|
|
893
|
+
const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
|
|
893
894
|
|
|
894
895
|
const prepared = await prepareTransactionRequest(client, {
|
|
895
896
|
account: accounts[1]!,
|
|
@@ -901,6 +902,7 @@ describe('tempo', () => {
|
|
|
901
902
|
}),
|
|
902
903
|
Actions.token.transfer.call({
|
|
903
904
|
amount: primaryAmount,
|
|
905
|
+
memo: memo as Hex.Hex,
|
|
904
906
|
to: challenge.request.recipient as Hex.Hex,
|
|
905
907
|
token: challenge.request.currency as Hex.Hex,
|
|
906
908
|
}),
|
|
@@ -2139,6 +2141,7 @@ describe('tempo', () => {
|
|
|
2139
2141
|
const splits = challenge.request.methodDetails?.splits ?? []
|
|
2140
2142
|
const primaryAmount =
|
|
2141
2143
|
BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount)
|
|
2144
|
+
const memo = Attribution.encode({ challengeId: challenge.id, serverId: challenge.realm })
|
|
2142
2145
|
|
|
2143
2146
|
const prepared = await prepareTransactionRequest(client, {
|
|
2144
2147
|
account: accounts[1]!,
|
|
@@ -2150,6 +2153,7 @@ describe('tempo', () => {
|
|
|
2150
2153
|
}),
|
|
2151
2154
|
Actions.token.transfer.call({
|
|
2152
2155
|
amount: primaryAmount,
|
|
2156
|
+
memo: memo as Hex.Hex,
|
|
2153
2157
|
to: challenge.request.recipient as Hex.Hex,
|
|
2154
2158
|
token: challenge.request.currency as Hex.Hex,
|
|
2155
2159
|
}),
|
|
@@ -3640,6 +3644,147 @@ describe('tempo', () => {
|
|
|
3640
3644
|
httpServer.close()
|
|
3641
3645
|
})
|
|
3642
3646
|
|
|
3647
|
+
test('server rejects transaction with wrong challenge nonce (stolen signed tx)', async () => {
|
|
3648
|
+
const chargeServer = Mppx_server.create({
|
|
3649
|
+
methods: [
|
|
3650
|
+
tempo_server.charge({
|
|
3651
|
+
getClient() {
|
|
3652
|
+
return client
|
|
3653
|
+
},
|
|
3654
|
+
currency: asset,
|
|
3655
|
+
account: accounts[0],
|
|
3656
|
+
store: Store.memory(),
|
|
3657
|
+
}),
|
|
3658
|
+
],
|
|
3659
|
+
realm,
|
|
3660
|
+
secretKey,
|
|
3661
|
+
})
|
|
3662
|
+
|
|
3663
|
+
const mppx = Mppx_client.create({
|
|
3664
|
+
polyfill: false,
|
|
3665
|
+
methods: [
|
|
3666
|
+
tempo_client({
|
|
3667
|
+
account: accounts[1],
|
|
3668
|
+
mode: 'pull',
|
|
3669
|
+
getClient() {
|
|
3670
|
+
return client
|
|
3671
|
+
},
|
|
3672
|
+
}),
|
|
3673
|
+
],
|
|
3674
|
+
})
|
|
3675
|
+
|
|
3676
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
3677
|
+
const result = await Mppx_server.toNodeListener(
|
|
3678
|
+
chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
|
|
3679
|
+
)(req, res)
|
|
3680
|
+
if (result.status === 402) return
|
|
3681
|
+
res.end('OK')
|
|
3682
|
+
})
|
|
3683
|
+
|
|
3684
|
+
const [challengeResponse1, challengeResponse2] = await Promise.all([
|
|
3685
|
+
fetch(httpServer.url),
|
|
3686
|
+
fetch(httpServer.url),
|
|
3687
|
+
])
|
|
3688
|
+
expect(challengeResponse1.status).toBe(402)
|
|
3689
|
+
expect(challengeResponse2.status).toBe(402)
|
|
3690
|
+
|
|
3691
|
+
const credential1 = await mppx.createCredential(challengeResponse1)
|
|
3692
|
+
const decoded1 = Credential.deserialize<{
|
|
3693
|
+
signature: string
|
|
3694
|
+
type: 'transaction'
|
|
3695
|
+
}>(credential1)
|
|
3696
|
+
const challenge2 = Challenge.fromResponse(challengeResponse2, {
|
|
3697
|
+
methods: [tempo_client.charge()],
|
|
3698
|
+
})
|
|
3699
|
+
|
|
3700
|
+
const replayCredential = Credential.serialize(
|
|
3701
|
+
Credential.from({
|
|
3702
|
+
challenge: challenge2,
|
|
3703
|
+
payload: decoded1.payload,
|
|
3704
|
+
}),
|
|
3705
|
+
)
|
|
3706
|
+
|
|
3707
|
+
const replayResponse = await fetch(httpServer.url, {
|
|
3708
|
+
headers: { Authorization: replayCredential },
|
|
3709
|
+
})
|
|
3710
|
+
expect(replayResponse.status).toBe(402)
|
|
3711
|
+
const body = (await replayResponse.json()) as { detail: string }
|
|
3712
|
+
expect(body.detail).toContain('memo is not bound to this challenge')
|
|
3713
|
+
|
|
3714
|
+
httpServer.close()
|
|
3715
|
+
})
|
|
3716
|
+
|
|
3717
|
+
test('server rejects optimistic transaction with wrong challenge nonce', async () => {
|
|
3718
|
+
const chargeServer = Mppx_server.create({
|
|
3719
|
+
methods: [
|
|
3720
|
+
tempo_server.charge({
|
|
3721
|
+
getClient() {
|
|
3722
|
+
return client
|
|
3723
|
+
},
|
|
3724
|
+
currency: asset,
|
|
3725
|
+
account: accounts[0],
|
|
3726
|
+
store: Store.memory(),
|
|
3727
|
+
waitForConfirmation: false,
|
|
3728
|
+
}),
|
|
3729
|
+
],
|
|
3730
|
+
realm,
|
|
3731
|
+
secretKey,
|
|
3732
|
+
})
|
|
3733
|
+
|
|
3734
|
+
const mppx = Mppx_client.create({
|
|
3735
|
+
polyfill: false,
|
|
3736
|
+
methods: [
|
|
3737
|
+
tempo_client({
|
|
3738
|
+
account: accounts[1],
|
|
3739
|
+
mode: 'pull',
|
|
3740
|
+
getClient() {
|
|
3741
|
+
return client
|
|
3742
|
+
},
|
|
3743
|
+
}),
|
|
3744
|
+
],
|
|
3745
|
+
})
|
|
3746
|
+
|
|
3747
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
3748
|
+
const result = await Mppx_server.toNodeListener(
|
|
3749
|
+
chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
|
|
3750
|
+
)(req, res)
|
|
3751
|
+
if (result.status === 402) return
|
|
3752
|
+
res.end('OK')
|
|
3753
|
+
})
|
|
3754
|
+
|
|
3755
|
+
const [challengeResponse1, challengeResponse2] = await Promise.all([
|
|
3756
|
+
fetch(httpServer.url),
|
|
3757
|
+
fetch(httpServer.url),
|
|
3758
|
+
])
|
|
3759
|
+
expect(challengeResponse1.status).toBe(402)
|
|
3760
|
+
expect(challengeResponse2.status).toBe(402)
|
|
3761
|
+
|
|
3762
|
+
const credential1 = await mppx.createCredential(challengeResponse1)
|
|
3763
|
+
const decoded1 = Credential.deserialize<{
|
|
3764
|
+
signature: string
|
|
3765
|
+
type: 'transaction'
|
|
3766
|
+
}>(credential1)
|
|
3767
|
+
const challenge2 = Challenge.fromResponse(challengeResponse2, {
|
|
3768
|
+
methods: [tempo_client.charge()],
|
|
3769
|
+
})
|
|
3770
|
+
|
|
3771
|
+
const replayCredential = Credential.serialize(
|
|
3772
|
+
Credential.from({
|
|
3773
|
+
challenge: challenge2,
|
|
3774
|
+
payload: decoded1.payload,
|
|
3775
|
+
}),
|
|
3776
|
+
)
|
|
3777
|
+
|
|
3778
|
+
const replayResponse = await fetch(httpServer.url, {
|
|
3779
|
+
headers: { Authorization: replayCredential },
|
|
3780
|
+
})
|
|
3781
|
+
expect(replayResponse.status).toBe(402)
|
|
3782
|
+
const body = (await replayResponse.json()) as { detail: string }
|
|
3783
|
+
expect(body.detail).toContain('memo is not bound to this challenge')
|
|
3784
|
+
|
|
3785
|
+
httpServer.close()
|
|
3786
|
+
})
|
|
3787
|
+
|
|
3643
3788
|
test('server rejects plain transfer without challenge-bound memo', async () => {
|
|
3644
3789
|
const httpServer = await Http.createServer(async (req, res) => {
|
|
3645
3790
|
const result = await Mppx_server.toNodeListener(
|
|
@@ -310,7 +310,16 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
310
310
|
}[]
|
|
311
311
|
const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
312
312
|
const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
|
|
313
|
-
assertTransferCalls(calls, {
|
|
313
|
+
const matchedCalls = assertTransferCalls(calls, {
|
|
314
|
+
currency,
|
|
315
|
+
exactCount: isFeePayerTx,
|
|
316
|
+
transfers,
|
|
317
|
+
})
|
|
318
|
+
if (!memo)
|
|
319
|
+
assertChallengeBoundCallMemo(matchedCalls, {
|
|
320
|
+
challengeId: challenge.id,
|
|
321
|
+
realm: challenge.realm,
|
|
322
|
+
})
|
|
314
323
|
|
|
315
324
|
if (isFeePayerTx)
|
|
316
325
|
FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
|
|
@@ -347,11 +356,16 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
347
356
|
const receipt = await sendRawTransactionSync(client, {
|
|
348
357
|
serializedTransaction: serializedTransaction_final,
|
|
349
358
|
})
|
|
350
|
-
assertTransferLogs(receipt, {
|
|
359
|
+
const matchedLogs = assertTransferLogs(receipt, {
|
|
351
360
|
currency,
|
|
352
361
|
sender: transaction.from! as `0x${string}`,
|
|
353
362
|
transfers,
|
|
354
363
|
})
|
|
364
|
+
if (!memo)
|
|
365
|
+
assertChallengeBoundMemo(matchedLogs, {
|
|
366
|
+
challengeId: challenge.id,
|
|
367
|
+
realm: challenge.realm,
|
|
368
|
+
})
|
|
355
369
|
// Post-broadcast dedup: catch malleable input variants
|
|
356
370
|
// (different serialized bytes, same underlying tx) that
|
|
357
371
|
// bypass the pre-broadcast check. Skip if the broadcast
|
|
@@ -511,8 +525,6 @@ function assertTransferCalls(
|
|
|
511
525
|
actualCalls: String(transferCalls.length),
|
|
512
526
|
})
|
|
513
527
|
|
|
514
|
-
const used = new Set<number>()
|
|
515
|
-
|
|
516
528
|
// Match memo-specific transfers before wildcards to avoid greedy
|
|
517
529
|
// consumption of memo-bearing calls by allowAnyMemo entries.
|
|
518
530
|
const sorted = [...parameters.transfers].sort((a, b) => {
|
|
@@ -521,6 +533,8 @@ function assertTransferCalls(
|
|
|
521
533
|
return 0
|
|
522
534
|
})
|
|
523
535
|
|
|
536
|
+
const used = new Set<number>()
|
|
537
|
+
const matched: Charge_internal.Transfer[] = []
|
|
524
538
|
for (const expected of sorted) {
|
|
525
539
|
const matchIndex = transferCalls.findIndex((call, index) => {
|
|
526
540
|
if (used.has(index)) return false
|
|
@@ -545,7 +559,10 @@ function assertTransferCalls(
|
|
|
545
559
|
}
|
|
546
560
|
|
|
547
561
|
used.add(matchIndex)
|
|
562
|
+
matched.push(decodeTransferCall(transferCalls[matchIndex]!, parameters.currency)!)
|
|
548
563
|
}
|
|
564
|
+
|
|
565
|
+
return matched
|
|
549
566
|
}
|
|
550
567
|
|
|
551
568
|
function getTransferCalls(
|
|
@@ -814,6 +831,21 @@ function assertChallengeBoundMemo(
|
|
|
814
831
|
throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {})
|
|
815
832
|
}
|
|
816
833
|
|
|
834
|
+
function assertChallengeBoundCallMemo(
|
|
835
|
+
matchedCalls: readonly Charge_internal.Transfer[],
|
|
836
|
+
parameters: { challengeId: string; realm: string },
|
|
837
|
+
) {
|
|
838
|
+
const bound = matchedCalls.some((call) => {
|
|
839
|
+
if (!call.memo) return false
|
|
840
|
+
const memo = call.memo as `0x${string}`
|
|
841
|
+
if (!Attribution.verifyServer(memo, parameters.realm)) return false
|
|
842
|
+
return Attribution.verifyChallengeBinding(memo, parameters.challengeId)
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
if (!bound)
|
|
846
|
+
throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {})
|
|
847
|
+
}
|
|
848
|
+
|
|
817
849
|
/** @internal */
|
|
818
850
|
class MismatchError extends PaymentError {
|
|
819
851
|
override readonly name = 'MismatchError'
|