mppx 0.4.11 → 0.4.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 +15 -0
- package/dist/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +49 -2
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/Methods.d.ts +15 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +27 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +21 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +33 -7
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +15 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +148 -99
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +17 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -3
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- package/src/server/Mppx.test.ts +287 -0
- package/src/server/Mppx.ts +59 -5
- package/src/tempo/Methods.test.ts +79 -0
- package/src/tempo/Methods.ts +53 -17
- package/src/tempo/client/Charge.ts +41 -8
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/server/Charge.test.ts +231 -0
- package/src/tempo/server/Charge.ts +193 -124
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +28 -0
- package/src/tempo/server/Session.ts +26 -17
- package/src/tempo/session/Chain.test.ts +25 -5
- package/src/tempo/session/Chain.ts +30 -14
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { TempoAddress as TempoAddress_types } from 'ox/tempo'
|
|
2
1
|
import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
|
|
3
2
|
import {
|
|
4
3
|
getTransactionReceipt,
|
|
@@ -11,12 +10,13 @@ import { tempo as tempo_chain } from 'viem/chains'
|
|
|
11
10
|
import { Abis, Transaction } from 'viem/tempo'
|
|
12
11
|
|
|
13
12
|
import { PaymentExpiredError } from '../../Errors.js'
|
|
14
|
-
import type { LooseOmit } from '../../internal/types.js'
|
|
13
|
+
import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
|
|
15
14
|
import * as Method from '../../Method.js'
|
|
16
15
|
import * as Store from '../../Store.js'
|
|
17
16
|
import * as Client from '../../viem/Client.js'
|
|
18
17
|
import * as Account from '../internal/account.js'
|
|
19
18
|
import * as TempoAddress from '../internal/address.js'
|
|
19
|
+
import * as Charge_internal from '../internal/charge.js'
|
|
20
20
|
import * as defaults from '../internal/defaults.js'
|
|
21
21
|
import * as FeePayer from '../internal/fee-payer.js'
|
|
22
22
|
import * as Selectors from '../internal/selectors.js'
|
|
@@ -34,7 +34,10 @@ import * as Methods from '../Methods.js'
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
export function charge<const parameters extends charge.Parameters>(
|
|
37
|
-
parameters: parameters = {} as
|
|
37
|
+
parameters: NoExtraKeys<parameters, charge.Parameters> = {} as NoExtraKeys<
|
|
38
|
+
parameters,
|
|
39
|
+
charge.Parameters
|
|
40
|
+
>,
|
|
38
41
|
) {
|
|
39
42
|
const {
|
|
40
43
|
amount,
|
|
@@ -126,16 +129,12 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
126
129
|
const hash = payload.hash as `0x${string}`
|
|
127
130
|
await assertHashUnused(store, hash)
|
|
128
131
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
assertTransferLog(receipt, {
|
|
134
|
-
amount,
|
|
132
|
+
const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
133
|
+
const receipt = await getTransactionReceipt(client, { hash })
|
|
134
|
+
assertTransferLogs(receipt, {
|
|
135
135
|
currency,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
recipient,
|
|
136
|
+
sender: receipt.from,
|
|
137
|
+
transfers: expectedTransfers,
|
|
139
138
|
})
|
|
140
139
|
|
|
141
140
|
await markHashUsed(store, hash)
|
|
@@ -161,58 +160,15 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
161
160
|
{},
|
|
162
161
|
)
|
|
163
162
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (selector !== Selectors.transferWithMemo) return false
|
|
172
|
-
try {
|
|
173
|
-
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
174
|
-
const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`]
|
|
175
|
-
return (
|
|
176
|
-
TempoAddress.isEqual(to, recipient) &&
|
|
177
|
-
amount_.toString() === amount &&
|
|
178
|
-
memo_.toLowerCase() === memo.toLowerCase()
|
|
179
|
-
)
|
|
180
|
-
} catch {
|
|
181
|
-
return false
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (selector === Selectors.transfer) {
|
|
186
|
-
try {
|
|
187
|
-
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
188
|
-
const [to, amount_] = args as [`0x${string}`, bigint]
|
|
189
|
-
return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
|
|
190
|
-
} catch {
|
|
191
|
-
return false
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (selector === Selectors.transferWithMemo) {
|
|
196
|
-
try {
|
|
197
|
-
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
198
|
-
const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`]
|
|
199
|
-
return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
|
|
200
|
-
} catch {
|
|
201
|
-
return false
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return false
|
|
206
|
-
})
|
|
163
|
+
const calls = (transaction.calls ?? []) as readonly {
|
|
164
|
+
data?: `0x${string}` | undefined
|
|
165
|
+
to?: `0x${string}` | undefined
|
|
166
|
+
}[]
|
|
167
|
+
const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
|
|
168
|
+
const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
|
|
169
|
+
assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
|
|
207
170
|
|
|
208
|
-
if (
|
|
209
|
-
throw new MismatchError('Invalid transaction: no matching payment call found', {
|
|
210
|
-
amount,
|
|
211
|
-
currency,
|
|
212
|
-
recipient,
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
|
|
171
|
+
if (isFeePayerTx)
|
|
216
172
|
FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
|
|
217
173
|
|
|
218
174
|
const resolvedFeeToken =
|
|
@@ -234,12 +190,10 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
234
190
|
const receipt = await sendRawTransactionSync(client, {
|
|
235
191
|
serializedTransaction: serializedTransaction_final,
|
|
236
192
|
})
|
|
237
|
-
|
|
238
|
-
amount,
|
|
193
|
+
assertTransferLogs(receipt, {
|
|
239
194
|
currency,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
recipient,
|
|
195
|
+
sender: transaction.from! as `0x${string}`,
|
|
196
|
+
transfers,
|
|
243
197
|
})
|
|
244
198
|
// Post-broadcast dedup: catch malleable input variants
|
|
245
199
|
// (different serialized bytes, same underlying tx) that
|
|
@@ -323,72 +277,187 @@ export declare namespace charge {
|
|
|
323
277
|
}
|
|
324
278
|
}
|
|
325
279
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
280
|
+
type ExpectedTransfer = {
|
|
281
|
+
amount: string
|
|
282
|
+
allowAnyMemo?: boolean | undefined
|
|
283
|
+
memo?: `0x${string}` | undefined
|
|
284
|
+
recipient: `0x${string}`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getExpectedTransfers(parameters: {
|
|
288
|
+
amount: string
|
|
289
|
+
memo: `0x${string}` | undefined
|
|
290
|
+
methodDetails: { splits?: readonly Charge_internal.Split[] | undefined } | undefined
|
|
291
|
+
recipient: `0x${string}`
|
|
292
|
+
}): ExpectedTransfer[] {
|
|
293
|
+
return Charge_internal.getTransfers({
|
|
294
|
+
amount: parameters.amount,
|
|
295
|
+
methodDetails: {
|
|
296
|
+
memo: parameters.memo,
|
|
297
|
+
splits: parameters.methodDetails?.splits,
|
|
298
|
+
},
|
|
299
|
+
recipient: parameters.recipient,
|
|
300
|
+
}).map((transfer) => ({
|
|
301
|
+
...transfer,
|
|
302
|
+
...(!transfer.memo ? { allowAnyMemo: true } : {}),
|
|
303
|
+
})) as ExpectedTransfer[]
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function assertTransferCalls(
|
|
307
|
+
calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
|
|
329
308
|
parameters: {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
memo: `0x${string}` | undefined
|
|
334
|
-
recipient: TempoAddress_types.Address
|
|
309
|
+
currency: `0x${string}`
|
|
310
|
+
exactCount?: boolean | undefined
|
|
311
|
+
transfers: readonly ExpectedTransfer[]
|
|
335
312
|
},
|
|
336
|
-
)
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
if (memo) {
|
|
340
|
-
const memoLogs = parseEventLogs({
|
|
341
|
-
abi: Abis.tip20,
|
|
342
|
-
eventName: 'TransferWithMemo',
|
|
343
|
-
logs: receipt.logs,
|
|
344
|
-
})
|
|
313
|
+
) {
|
|
314
|
+
const transferCalls = getTransferCalls(calls)
|
|
345
315
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
TempoAddress.isEqual(log.args.to, recipient) &&
|
|
351
|
-
log.args.amount.toString() === amount &&
|
|
352
|
-
log.args.memo.toLowerCase() === memo.toLowerCase(),
|
|
353
|
-
)
|
|
354
|
-
|
|
355
|
-
if (!match)
|
|
356
|
-
throw new MismatchError(
|
|
357
|
-
'Payment verification failed: no matching transfer with memo found.',
|
|
358
|
-
{
|
|
359
|
-
amount,
|
|
360
|
-
currency,
|
|
361
|
-
memo,
|
|
362
|
-
recipient,
|
|
363
|
-
},
|
|
364
|
-
)
|
|
365
|
-
} else {
|
|
366
|
-
const transferLogs = parseEventLogs({
|
|
367
|
-
abi: Abis.tip20,
|
|
368
|
-
eventName: 'Transfer',
|
|
369
|
-
logs: receipt.logs,
|
|
316
|
+
if (parameters.exactCount && transferCalls.length !== parameters.transfers.length)
|
|
317
|
+
throw new MismatchError('Invalid transaction: no matching payment call found', {
|
|
318
|
+
expectedCalls: String(parameters.transfers.length),
|
|
319
|
+
actualCalls: String(transferCalls.length),
|
|
370
320
|
})
|
|
371
321
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
322
|
+
const used = new Set<number>()
|
|
323
|
+
|
|
324
|
+
// Match memo-specific transfers before wildcards to avoid greedy
|
|
325
|
+
// consumption of memo-bearing calls by allowAnyMemo entries.
|
|
326
|
+
const sorted = [...parameters.transfers].sort((a, b) => {
|
|
327
|
+
if (a.memo && !b.memo) return -1
|
|
328
|
+
if (!a.memo && b.memo) return 1
|
|
329
|
+
return 0
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
for (const expected of sorted) {
|
|
333
|
+
const matchIndex = transferCalls.findIndex((call, index) => {
|
|
334
|
+
if (used.has(index)) return false
|
|
335
|
+
const decoded = decodeTransferCall(call, parameters.currency)
|
|
336
|
+
if (!decoded) return false
|
|
337
|
+
|
|
338
|
+
if (!TempoAddress.isEqual(decoded.recipient, expected.recipient)) return false
|
|
339
|
+
if (decoded.amount !== expected.amount) return false
|
|
340
|
+
if (expected.memo) {
|
|
341
|
+
return decoded.memo?.toLowerCase() === expected.memo.toLowerCase()
|
|
342
|
+
}
|
|
343
|
+
if (expected.allowAnyMemo) return true
|
|
344
|
+
return decoded.memo === undefined
|
|
376
345
|
})
|
|
377
346
|
|
|
378
|
-
|
|
379
|
-
(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
347
|
+
if (matchIndex === -1) {
|
|
348
|
+
throw new MismatchError('Invalid transaction: no matching payment call found', {
|
|
349
|
+
amount: expected.amount,
|
|
350
|
+
currency: parameters.currency,
|
|
351
|
+
recipient: expected.recipient,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
used.add(matchIndex)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function getTransferCalls(
|
|
360
|
+
calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
|
|
361
|
+
) {
|
|
362
|
+
const selectors = calls.map((call) => call.data?.slice(0, 10))
|
|
363
|
+
const offset =
|
|
364
|
+
selectors[0] === Selectors.approve && selectors[1] === Selectors.swapExactAmountOut ? 2 : 0
|
|
365
|
+
const transferCalls = calls.slice(offset)
|
|
366
|
+
|
|
367
|
+
if (
|
|
368
|
+
transferCalls.length === 0 ||
|
|
369
|
+
selectors
|
|
370
|
+
.slice(offset)
|
|
371
|
+
.some(
|
|
372
|
+
(selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
|
|
373
|
+
)
|
|
374
|
+
) {
|
|
375
|
+
throw new MismatchError('Invalid transaction: no matching payment call found', {})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return transferCalls
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function decodeTransferCall(
|
|
382
|
+
call: { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined },
|
|
383
|
+
currency: `0x${string}`,
|
|
384
|
+
) {
|
|
385
|
+
if (!call.to || !TempoAddress.isEqual(call.to, currency) || !call.data) return null
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const selector = call.data.slice(0, 10)
|
|
389
|
+
if (selector === Selectors.transfer) {
|
|
390
|
+
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
391
|
+
const [recipient, amount] = args as [`0x${string}`, bigint]
|
|
392
|
+
return { amount: amount.toString(), recipient }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (selector === Selectors.transferWithMemo) {
|
|
396
|
+
const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
|
|
397
|
+
const [recipient, amount, memo] = args as [`0x${string}`, bigint, `0x${string}`]
|
|
398
|
+
return { amount: amount.toString(), memo, recipient }
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
return null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
385
406
|
|
|
386
|
-
|
|
407
|
+
function assertTransferLogs(
|
|
408
|
+
receipt: TransactionReceipt,
|
|
409
|
+
parameters: {
|
|
410
|
+
currency: `0x${string}`
|
|
411
|
+
sender: `0x${string}`
|
|
412
|
+
transfers: readonly ExpectedTransfer[]
|
|
413
|
+
},
|
|
414
|
+
) {
|
|
415
|
+
const transferLogs = parseEventLogs({
|
|
416
|
+
abi: Abis.tip20,
|
|
417
|
+
eventName: 'Transfer',
|
|
418
|
+
logs: receipt.logs,
|
|
419
|
+
}).map((log) => ({ ...log, kind: 'transfer' as const }))
|
|
420
|
+
|
|
421
|
+
const memoLogs = parseEventLogs({
|
|
422
|
+
abi: Abis.tip20,
|
|
423
|
+
eventName: 'TransferWithMemo',
|
|
424
|
+
logs: receipt.logs,
|
|
425
|
+
}).map((log) => ({ ...log, kind: 'memo' as const }))
|
|
426
|
+
|
|
427
|
+
const logs = [...transferLogs, ...memoLogs]
|
|
428
|
+
const used = new Set<number>()
|
|
429
|
+
|
|
430
|
+
// Match memo-specific transfers before wildcards to avoid greedy
|
|
431
|
+
// consumption of memo-bearing logs by allowAnyMemo entries.
|
|
432
|
+
const sorted = [...parameters.transfers].sort((a, b) => {
|
|
433
|
+
if (a.memo && !b.memo) return -1
|
|
434
|
+
if (!a.memo && b.memo) return 1
|
|
435
|
+
return 0
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
for (const transfer of sorted) {
|
|
439
|
+
const matchIndex = logs.findIndex((log, index) => {
|
|
440
|
+
if (used.has(index)) return false
|
|
441
|
+
if (!TempoAddress.isEqual(log.address, parameters.currency)) return false
|
|
442
|
+
if (!TempoAddress.isEqual(log.args.from, parameters.sender)) return false
|
|
443
|
+
if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) return false
|
|
444
|
+
if (log.args.amount.toString() !== transfer.amount) return false
|
|
445
|
+
if (transfer.memo) {
|
|
446
|
+
return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase()
|
|
447
|
+
}
|
|
448
|
+
if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo'
|
|
449
|
+
return log.kind === 'transfer'
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
if (matchIndex === -1) {
|
|
387
453
|
throw new MismatchError('Payment verification failed: no matching transfer found.', {
|
|
388
|
-
amount,
|
|
389
|
-
currency,
|
|
390
|
-
recipient,
|
|
454
|
+
amount: transfer.amount,
|
|
455
|
+
currency: parameters.currency,
|
|
456
|
+
recipient: transfer.recipient,
|
|
391
457
|
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
used.add(matchIndex)
|
|
392
461
|
}
|
|
393
462
|
}
|
|
394
463
|
|
|
@@ -14,7 +14,10 @@ import { session as session_, settle as settle_ } from './Session.js'
|
|
|
14
14
|
* ```
|
|
15
15
|
*/
|
|
16
16
|
export function tempo<const parameters extends tempo.Parameters>(parameters?: parameters) {
|
|
17
|
-
return [
|
|
17
|
+
return [
|
|
18
|
+
tempo.charge(parameters as charge_.Parameters as never),
|
|
19
|
+
tempo.session(parameters as session_.Parameters as never),
|
|
20
|
+
] as const
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export namespace tempo {
|
|
@@ -885,6 +885,34 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
885
885
|
}),
|
|
886
886
|
).rejects.toThrow(ChannelClosedError)
|
|
887
887
|
})
|
|
888
|
+
|
|
889
|
+
test('rejects voucher when deposit is zero (settled race window)', async () => {
|
|
890
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
891
|
+
// Use a large TTL so the voucher path uses the cached store state
|
|
892
|
+
// instead of reading on-chain. This lets us simulate the settlement
|
|
893
|
+
// race where deposit=0 but finalized=false by manipulating the store.
|
|
894
|
+
const server = createServer({ channelStateTtl: 60_000 })
|
|
895
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
896
|
+
|
|
897
|
+
// Simulate the escrow contract zeroing the deposit before setting
|
|
898
|
+
// finalized (the race window this PR guards against).
|
|
899
|
+
await store.updateChannel(channelId, (ch) => (ch ? { ...ch, deposit: 0n } : null))
|
|
900
|
+
|
|
901
|
+
await expect(
|
|
902
|
+
server.verify({
|
|
903
|
+
credential: {
|
|
904
|
+
challenge: makeChallenge({ id: 'challenge-after-settle', channelId }),
|
|
905
|
+
payload: {
|
|
906
|
+
action: 'voucher' as const,
|
|
907
|
+
channelId,
|
|
908
|
+
cumulativeAmount: '2000000',
|
|
909
|
+
signature: await signTestVoucher(channelId, 2000000n),
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
request: makeRequest(),
|
|
913
|
+
}),
|
|
914
|
+
).rejects.toThrow(ChannelClosedError)
|
|
915
|
+
})
|
|
888
916
|
})
|
|
889
917
|
|
|
890
918
|
describe('topUp', () => {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type Address,
|
|
15
15
|
type Hex,
|
|
16
16
|
parseUnits,
|
|
17
|
+
zeroAddress,
|
|
17
18
|
type Account as viem_Account,
|
|
18
19
|
type Client as viem_Client,
|
|
19
20
|
} from 'viem'
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
VerificationFailedError,
|
|
31
32
|
} from '../../Errors.js'
|
|
32
33
|
import type { Challenge, Credential } from '../../index.js'
|
|
33
|
-
import type { LooseOmit } from '../../internal/types.js'
|
|
34
|
+
import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
|
|
34
35
|
import * as Method from '../../Method.js'
|
|
35
36
|
import * as Store from '../../Store.js'
|
|
36
37
|
import * as Client from '../../viem/Client.js'
|
|
@@ -82,7 +83,9 @@ type SessionMethodDetails = {
|
|
|
82
83
|
* })
|
|
83
84
|
* ```
|
|
84
85
|
*/
|
|
85
|
-
export function session<const parameters extends session.Parameters>(
|
|
86
|
+
export function session<const parameters extends session.Parameters>(
|
|
87
|
+
p?: NoExtraKeys<parameters, session.Parameters>,
|
|
88
|
+
) {
|
|
86
89
|
const parameters = p as parameters
|
|
87
90
|
const {
|
|
88
91
|
amount,
|
|
@@ -340,8 +343,10 @@ export async function settle(
|
|
|
340
343
|
channelId: Hex,
|
|
341
344
|
options?: {
|
|
342
345
|
escrowContract?: Address | undefined
|
|
343
|
-
|
|
344
|
-
|
|
346
|
+
} & (
|
|
347
|
+
| { feePayer: viem_Account; account: viem_Account }
|
|
348
|
+
| { feePayer?: undefined; account?: viem_Account | undefined }
|
|
349
|
+
),
|
|
345
350
|
): Promise<Hex> {
|
|
346
351
|
const channel = await store.getChannel(channelId)
|
|
347
352
|
if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
|
|
@@ -354,12 +359,11 @@ export async function settle(
|
|
|
354
359
|
if (!resolvedEscrow) throw new Error(`No escrow contract for chainId ${chainId}.`)
|
|
355
360
|
|
|
356
361
|
const settledAmount = channel.highestVoucher.cumulativeAmount
|
|
357
|
-
const txHash = await settleOnChain(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
)
|
|
362
|
+
const txHash = await settleOnChain(client, resolvedEscrow, channel.highestVoucher, {
|
|
363
|
+
...(options?.feePayer && options?.account
|
|
364
|
+
? { feePayer: options.feePayer, account: options.account }
|
|
365
|
+
: { account: options?.account }),
|
|
366
|
+
})
|
|
363
367
|
|
|
364
368
|
await store.updateChannel(channelId, (current) => {
|
|
365
369
|
if (!current) return null
|
|
@@ -456,6 +460,15 @@ async function verifyAndAcceptVoucher(parameters: {
|
|
|
456
460
|
if (onChain.closeRequestedAt !== 0n) {
|
|
457
461
|
throw new ChannelClosedError({ reason: 'channel has a pending close request' })
|
|
458
462
|
}
|
|
463
|
+
// Treat a zero deposit on an existing channel as settled/closed.
|
|
464
|
+
// During settlement the escrow contract may zero the deposit before
|
|
465
|
+
// setting the finalized flag, creating a brief window where
|
|
466
|
+
// finalized=false but deposit=0. Without this guard the voucher
|
|
467
|
+
// check below would return a 402 (AmountExceedsDepositError) instead
|
|
468
|
+
// of the correct 410 (ChannelClosedError).
|
|
469
|
+
if (onChain.deposit === 0n && onChain.payer !== zeroAddress) {
|
|
470
|
+
throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' })
|
|
471
|
+
}
|
|
459
472
|
|
|
460
473
|
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
461
474
|
throw new VerificationFailedError({
|
|
@@ -843,13 +856,9 @@ async function handleClose(
|
|
|
843
856
|
throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
|
|
844
857
|
}
|
|
845
858
|
|
|
846
|
-
const txHash = await closeOnChain(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
voucher,
|
|
850
|
-
account,
|
|
851
|
-
feePayer,
|
|
852
|
-
)
|
|
859
|
+
const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
|
|
860
|
+
...(feePayer && account ? { feePayer, account } : { account }),
|
|
861
|
+
})
|
|
853
862
|
|
|
854
863
|
const updated = await store.updateChannel(payload.channelId, (current) => {
|
|
855
864
|
if (!current) return null
|
|
@@ -730,7 +730,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
730
730
|
expect(channel.finalized).toBe(false)
|
|
731
731
|
})
|
|
732
732
|
|
|
733
|
-
test('settles
|
|
733
|
+
test.todo('settles with distinct feePayer != account (fee-sponsored settle)')
|
|
734
|
+
|
|
735
|
+
test('settles with explicit account (no fee payer)', async () => {
|
|
734
736
|
const salt = nextSalt()
|
|
735
737
|
const deposit = 10_000_000n
|
|
736
738
|
const settleAmount = 5_000_000n
|
|
@@ -752,6 +754,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
752
754
|
chain.id,
|
|
753
755
|
)
|
|
754
756
|
|
|
757
|
+
// Pass account explicitly — should use it as sender instead of client.account
|
|
755
758
|
const txHash = await settleOnChain(
|
|
756
759
|
client,
|
|
757
760
|
escrowContract,
|
|
@@ -760,7 +763,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
760
763
|
cumulativeAmount: settleAmount,
|
|
761
764
|
signature,
|
|
762
765
|
},
|
|
763
|
-
accounts[0],
|
|
766
|
+
{ account: accounts[0] },
|
|
764
767
|
)
|
|
765
768
|
|
|
766
769
|
expect(txHash).toBeDefined()
|
|
@@ -769,6 +772,21 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
769
772
|
expect(channel.settled).toBe(settleAmount)
|
|
770
773
|
expect(channel.finalized).toBe(false)
|
|
771
774
|
})
|
|
775
|
+
|
|
776
|
+
test('throws when no account available', async () => {
|
|
777
|
+
const noAccountClient = { chain: { id: 42431 } } as any
|
|
778
|
+
const dummyEscrow = '0x0000000000000000000000000000000000000001' as Address
|
|
779
|
+
const dummyChannelId =
|
|
780
|
+
'0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
|
|
781
|
+
|
|
782
|
+
await expect(
|
|
783
|
+
settleOnChain(noAccountClient, dummyEscrow, {
|
|
784
|
+
channelId: dummyChannelId,
|
|
785
|
+
cumulativeAmount: 1_000_000n,
|
|
786
|
+
signature: '0xsig' as Hex,
|
|
787
|
+
}),
|
|
788
|
+
).rejects.toThrow('no account available')
|
|
789
|
+
})
|
|
772
790
|
})
|
|
773
791
|
|
|
774
792
|
describe('closeOnChain', () => {
|
|
@@ -806,7 +824,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
806
824
|
expect(channel.finalized).toBe(true)
|
|
807
825
|
})
|
|
808
826
|
|
|
809
|
-
test('closes
|
|
827
|
+
test.todo('closes with distinct feePayer != account (fee-sponsored close)')
|
|
828
|
+
|
|
829
|
+
test('closes with explicit account (no fee payer)', async () => {
|
|
810
830
|
const salt = nextSalt()
|
|
811
831
|
const deposit = 10_000_000n
|
|
812
832
|
const closeAmount = 5_000_000n
|
|
@@ -828,6 +848,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
828
848
|
chain.id,
|
|
829
849
|
)
|
|
830
850
|
|
|
851
|
+
// Pass account explicitly — should use it as sender instead of client.account
|
|
831
852
|
const txHash = await closeOnChain(
|
|
832
853
|
client,
|
|
833
854
|
escrowContract,
|
|
@@ -836,8 +857,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
836
857
|
cumulativeAmount: closeAmount,
|
|
837
858
|
signature,
|
|
838
859
|
},
|
|
839
|
-
|
|
840
|
-
accounts[0],
|
|
860
|
+
{ account: accounts[0] },
|
|
841
861
|
)
|
|
842
862
|
|
|
843
863
|
expect(txHash).toBeDefined()
|