mppx 0.4.6 → 0.4.8
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/Store.d.ts +5 -4
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +22 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +9 -22
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +5 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +3 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +2 -2
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +4 -2
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +26 -8
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +12 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/address.d.ts +3 -0
- package/dist/tempo/internal/address.d.ts.map +1 -0
- package/dist/tempo/internal/address.js +4 -0
- package/dist/tempo/internal/address.js.map +1 -0
- package/dist/tempo/internal/auto-swap.js +3 -3
- package/dist/tempo/internal/auto-swap.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 +11 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +109 -50
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +39 -32
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +41 -1
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +51 -10
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +4 -2
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js +3 -2
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/package.json +6 -2
- package/src/Store.test-d.ts +58 -0
- package/src/Store.ts +6 -4
- package/src/cli/cli.test.ts +124 -0
- package/src/cli/cli.ts +19 -7
- package/src/cli/plugins/tempo.ts +17 -23
- package/src/middlewares/elysia.test.ts +89 -0
- package/src/middlewares/elysia.ts +4 -1
- package/src/proxy/Proxy.test.ts +56 -0
- package/src/proxy/Proxy.ts +6 -1
- package/src/proxy/internal/Route.test.ts +57 -0
- package/src/proxy/internal/Route.ts +3 -1
- package/src/server/Mppx.test.ts +246 -0
- package/src/server/Mppx.ts +27 -8
- package/src/tempo/client/SessionManager.ts +11 -1
- package/src/tempo/internal/address.ts +6 -0
- package/src/tempo/internal/auto-swap.ts +3 -3
- package/src/tempo/internal/fee-payer.ts +18 -4
- package/src/tempo/server/Charge.test.ts +1080 -31
- package/src/tempo/server/Charge.ts +158 -63
- package/src/tempo/server/Session.test.ts +929 -111
- package/src/tempo/server/Session.ts +48 -33
- package/src/tempo/server/Sse.test.ts +1 -0
- package/src/tempo/server/internal/transport.test.ts +29 -0
- package/src/tempo/server/internal/transport.ts +41 -2
- package/src/tempo/session/Chain.test.ts +144 -0
- package/src/tempo/session/Chain.ts +58 -10
- package/src/tempo/session/ChannelStore.test.ts +10 -0
- package/src/tempo/session/ChannelStore.ts +6 -3
- package/src/tempo/session/Sse.test.ts +1 -0
- package/src/tempo/session/Voucher.ts +3 -2
|
@@ -85,7 +85,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
|
|
|
85
85
|
const parameters = p as parameters
|
|
86
86
|
const {
|
|
87
87
|
amount,
|
|
88
|
-
channelStateTtl =
|
|
88
|
+
channelStateTtl = 5_000,
|
|
89
89
|
currency = defaults.resolveCurrency(parameters),
|
|
90
90
|
decimals = defaults.decimals,
|
|
91
91
|
store: rawStore = Store.memory(),
|
|
@@ -284,7 +284,7 @@ export declare namespace session {
|
|
|
284
284
|
>
|
|
285
285
|
|
|
286
286
|
type Parameters = {
|
|
287
|
-
/** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default
|
|
287
|
+
/** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */
|
|
288
288
|
channelStateTtl?: number | undefined
|
|
289
289
|
/** Minimum voucher delta to accept (numeric string, default: "0"). */
|
|
290
290
|
minVoucherDelta?: string | undefined
|
|
@@ -451,7 +451,7 @@ async function verifyAndAcceptVoucher(parameters: {
|
|
|
451
451
|
throw new ChannelClosedError({ reason: 'channel has a pending close request' })
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
if (voucher.cumulativeAmount
|
|
454
|
+
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
455
455
|
throw new VerificationFailedError({
|
|
456
456
|
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
457
457
|
})
|
|
@@ -462,12 +462,8 @@ async function verifyAndAcceptVoucher(parameters: {
|
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
if (voucher.cumulativeAmount <= channel.highestVoucherAmount) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
channelId,
|
|
468
|
-
acceptedCumulative: channel.highestVoucherAmount,
|
|
469
|
-
spent: channel.spent,
|
|
470
|
-
units: channel.units,
|
|
465
|
+
throw new VerificationFailedError({
|
|
466
|
+
reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
|
|
471
467
|
})
|
|
472
468
|
}
|
|
473
469
|
|
|
@@ -561,7 +557,7 @@ async function handleOpen(
|
|
|
561
557
|
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
|
|
562
558
|
}
|
|
563
559
|
|
|
564
|
-
if (voucher.cumulativeAmount
|
|
560
|
+
if (voucher.cumulativeAmount <= onChain.settled) {
|
|
565
561
|
throw new VerificationFailedError({
|
|
566
562
|
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
567
563
|
})
|
|
@@ -580,16 +576,22 @@ async function handleOpen(
|
|
|
580
576
|
|
|
581
577
|
const updated = await store.updateChannel(payload.channelId, (existing) => {
|
|
582
578
|
if (existing) {
|
|
583
|
-
if (voucher.cumulativeAmount
|
|
579
|
+
if (voucher.cumulativeAmount <= existing.settledOnChain) {
|
|
584
580
|
throw new VerificationFailedError({
|
|
585
581
|
reason: 'voucher amount is below settled on-chain amount',
|
|
586
582
|
})
|
|
587
583
|
}
|
|
588
584
|
|
|
585
|
+
const settledOnChain =
|
|
586
|
+
onChain.settled > existing.settledOnChain ? onChain.settled : existing.settledOnChain
|
|
587
|
+
const spent = settledOnChain > existing.spent ? settledOnChain : existing.spent
|
|
588
|
+
|
|
589
589
|
if (voucher.cumulativeAmount > existing.highestVoucherAmount) {
|
|
590
590
|
return {
|
|
591
591
|
...existing,
|
|
592
592
|
deposit: onChain.deposit,
|
|
593
|
+
settledOnChain,
|
|
594
|
+
spent,
|
|
593
595
|
highestVoucherAmount: voucher.cumulativeAmount,
|
|
594
596
|
highestVoucher: voucher,
|
|
595
597
|
authorizedSigner,
|
|
@@ -598,6 +600,8 @@ async function handleOpen(
|
|
|
598
600
|
return {
|
|
599
601
|
...existing,
|
|
600
602
|
deposit: onChain.deposit,
|
|
603
|
+
settledOnChain,
|
|
604
|
+
spent,
|
|
601
605
|
authorizedSigner,
|
|
602
606
|
}
|
|
603
607
|
}
|
|
@@ -605,15 +609,16 @@ async function handleOpen(
|
|
|
605
609
|
channelId: payload.channelId,
|
|
606
610
|
chainId: methodDetails.chainId,
|
|
607
611
|
escrowContract: methodDetails.escrowContract,
|
|
612
|
+
closeRequestedAt: onChain.closeRequestedAt,
|
|
608
613
|
payer: onChain.payer,
|
|
609
614
|
payee: onChain.payee,
|
|
610
615
|
token: onChain.token,
|
|
611
616
|
authorizedSigner,
|
|
612
617
|
deposit: onChain.deposit,
|
|
613
|
-
settledOnChain:
|
|
618
|
+
settledOnChain: onChain.settled,
|
|
614
619
|
highestVoucherAmount: voucher.cumulativeAmount,
|
|
615
620
|
highestVoucher: voucher,
|
|
616
|
-
spent:
|
|
621
|
+
spent: onChain.settled,
|
|
617
622
|
units: 0,
|
|
618
623
|
finalized: false,
|
|
619
624
|
createdAt: new Date().toISOString(),
|
|
@@ -715,18 +720,30 @@ async function handleVoucher(
|
|
|
715
720
|
//
|
|
716
721
|
// To guard against the payer initiating a forced close while vouchers
|
|
717
722
|
// are still being accepted, re-query on-chain state when the cache
|
|
718
|
-
// exceeds the configured staleness TTL.
|
|
723
|
+
// exceeds the configured staleness TTL (default: 5s).
|
|
719
724
|
const lastVerified = lastOnChainVerified.get(payload.channelId) ?? 0
|
|
720
725
|
const isStale = Date.now() - lastVerified > channelStateTtl
|
|
721
726
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
727
|
+
const onChain = await (async () => {
|
|
728
|
+
if (isStale) {
|
|
729
|
+
const onChainChannel = await getOnChainChannel(
|
|
730
|
+
client,
|
|
731
|
+
methodDetails.escrowContract,
|
|
732
|
+
payload.channelId,
|
|
733
|
+
)
|
|
734
|
+
lastOnChainVerified.set(payload.channelId, Date.now())
|
|
735
|
+
// Persist closeRequestedAt so the cached path detects force-close
|
|
736
|
+
// between re-queries.
|
|
737
|
+
if (onChainChannel.closeRequestedAt !== 0n) {
|
|
738
|
+
await store.updateChannel(payload.channelId, (current) =>
|
|
739
|
+
current ? { ...current, closeRequestedAt: onChainChannel.closeRequestedAt } : current,
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
return onChainChannel
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
728
745
|
finalized: channel.finalized,
|
|
729
|
-
closeRequestedAt:
|
|
746
|
+
closeRequestedAt: channel.closeRequestedAt,
|
|
730
747
|
payer: channel.payer,
|
|
731
748
|
payee: channel.payee,
|
|
732
749
|
token: channel.token,
|
|
@@ -734,7 +751,7 @@ async function handleVoucher(
|
|
|
734
751
|
deposit: channel.deposit,
|
|
735
752
|
settled: channel.settledOnChain,
|
|
736
753
|
}
|
|
737
|
-
}
|
|
754
|
+
})()
|
|
738
755
|
|
|
739
756
|
return verifyAndAcceptVoucher({
|
|
740
757
|
store,
|
|
@@ -743,7 +760,7 @@ async function handleVoucher(
|
|
|
743
760
|
channel,
|
|
744
761
|
channelId: payload.channelId,
|
|
745
762
|
voucher,
|
|
746
|
-
onChain
|
|
763
|
+
onChain,
|
|
747
764
|
methodDetails,
|
|
748
765
|
})
|
|
749
766
|
}
|
|
@@ -774,21 +791,16 @@ async function handleClose(
|
|
|
774
791
|
payload.signature,
|
|
775
792
|
)
|
|
776
793
|
|
|
777
|
-
if (voucher.cumulativeAmount < channel.highestVoucherAmount) {
|
|
778
|
-
throw new VerificationFailedError({
|
|
779
|
-
reason: 'close voucher amount must be >= highest accepted voucher',
|
|
780
|
-
})
|
|
781
|
-
}
|
|
782
|
-
|
|
783
794
|
const onChain = await getOnChainChannel(client, methodDetails.escrowContract, payload.channelId)
|
|
784
795
|
|
|
785
796
|
if (onChain.finalized) {
|
|
786
797
|
throw new ChannelClosedError({ reason: 'channel is finalized on-chain' })
|
|
787
798
|
}
|
|
788
799
|
|
|
789
|
-
|
|
800
|
+
const minCloseAmount = channel.spent > onChain.settled ? channel.spent : onChain.settled
|
|
801
|
+
if (voucher.cumulativeAmount < minCloseAmount) {
|
|
790
802
|
throw new VerificationFailedError({
|
|
791
|
-
reason:
|
|
803
|
+
reason: `close voucher amount must be >= ${minCloseAmount} (max of spent and on-chain settled)`,
|
|
792
804
|
})
|
|
793
805
|
}
|
|
794
806
|
|
|
@@ -819,11 +831,14 @@ async function handleClose(
|
|
|
819
831
|
|
|
820
832
|
const updated = await store.updateChannel(payload.channelId, (current) => {
|
|
821
833
|
if (!current) return null
|
|
834
|
+
const updateVoucher = voucher.cumulativeAmount > current.highestVoucherAmount
|
|
822
835
|
return {
|
|
823
836
|
...current,
|
|
824
837
|
deposit: onChain.deposit,
|
|
825
|
-
|
|
826
|
-
|
|
838
|
+
...(updateVoucher && {
|
|
839
|
+
highestVoucherAmount: voucher.cumulativeAmount,
|
|
840
|
+
highestVoucher: voucher,
|
|
841
|
+
}),
|
|
827
842
|
finalized: true,
|
|
828
843
|
}
|
|
829
844
|
})
|
|
@@ -31,6 +31,7 @@ function seedChannel(
|
|
|
31
31
|
highestVoucher: null,
|
|
32
32
|
spent: 0n,
|
|
33
33
|
units: 0,
|
|
34
|
+
closeRequestedAt: 0n,
|
|
34
35
|
finalized: false,
|
|
35
36
|
createdAt: new Date().toISOString(),
|
|
36
37
|
}))
|
|
@@ -262,6 +263,34 @@ describe('sse transport', () => {
|
|
|
262
263
|
).toThrow('No SSE context available')
|
|
263
264
|
})
|
|
264
265
|
|
|
266
|
+
test('respondReceipt with non-SSE upstream Response still deducts from channel', async () => {
|
|
267
|
+
const store = memoryStore()
|
|
268
|
+
await seedChannel(store, 10000000n)
|
|
269
|
+
const transport = sse({ store })
|
|
270
|
+
|
|
271
|
+
transport.getCredential(makeAuthorizedRequest())
|
|
272
|
+
|
|
273
|
+
const plainResponse = new Response(JSON.stringify({ content: 'hello' }), {
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const response = transport.respondReceipt({
|
|
278
|
+
receipt: makeReceipt(),
|
|
279
|
+
response: plainResponse,
|
|
280
|
+
challengeId,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const body = await response.text()
|
|
284
|
+
|
|
285
|
+
const channel = await store.getChannel(channelId)
|
|
286
|
+
expect(channel!.spent).toBe(1000000n)
|
|
287
|
+
expect(channel!.units).toBe(1)
|
|
288
|
+
|
|
289
|
+
expect(JSON.parse(body)).toEqual({ content: 'hello' })
|
|
290
|
+
expect(response.headers.get('Content-Type')).toBe('application/json')
|
|
291
|
+
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
292
|
+
})
|
|
293
|
+
|
|
265
294
|
test('poll: true strips waitForUpdate from store', async () => {
|
|
266
295
|
const store = memoryStore()
|
|
267
296
|
;(store as any).waitForUpdate = async () => {}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @internal
|
|
7
7
|
*/
|
|
8
8
|
import * as Transport from '../../../server/Transport.js'
|
|
9
|
-
import
|
|
9
|
+
import * as ChannelStore from '../../session/ChannelStore.js'
|
|
10
10
|
import * as Sse_core from '../../session/Sse.js'
|
|
11
11
|
|
|
12
12
|
/** SSE transport with Tempo session controller. */
|
|
@@ -89,7 +89,46 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
89
89
|
return Sse_core.toResponse(stream)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
const baseResponse = base.respondReceipt({
|
|
93
|
+
receipt,
|
|
94
|
+
response: response as Response,
|
|
95
|
+
challengeId,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Non-SSE response (e.g. upstream returned JSON instead of event-stream).
|
|
99
|
+
// Need to deduct tickCost so request isn't free.
|
|
100
|
+
const ctx = contextMap.get(challengeId)
|
|
101
|
+
if (ctx) {
|
|
102
|
+
contextMap.delete(challengeId)
|
|
103
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
104
|
+
async start(controller) {
|
|
105
|
+
// deduction completes before consumer reads
|
|
106
|
+
await ChannelStore.deductFromChannel(store, ctx.channelId, ctx.tickCost)
|
|
107
|
+
if (!baseResponse.body) {
|
|
108
|
+
controller.close()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const reader = baseResponse.body.getReader()
|
|
112
|
+
try {
|
|
113
|
+
while (true) {
|
|
114
|
+
const { done, value } = await reader.read()
|
|
115
|
+
if (done) break
|
|
116
|
+
controller.enqueue(value)
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
reader.releaseLock()
|
|
120
|
+
controller.close()
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
return new Response(stream, {
|
|
125
|
+
status: baseResponse.status,
|
|
126
|
+
statusText: baseResponse.statusText,
|
|
127
|
+
headers: baseResponse.headers,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return baseResponse
|
|
93
132
|
},
|
|
94
133
|
})
|
|
95
134
|
}
|
|
@@ -242,6 +242,33 @@ describe('on-chain', () => {
|
|
|
242
242
|
).rejects.toThrow('open transaction token does not match server currency')
|
|
243
243
|
})
|
|
244
244
|
|
|
245
|
+
test('rejects when transaction channelId does not match claimed channelId', async () => {
|
|
246
|
+
const salt = nextSalt()
|
|
247
|
+
|
|
248
|
+
const { serializedTransaction } = await signOpenChannel({
|
|
249
|
+
escrow: escrowContract,
|
|
250
|
+
payer,
|
|
251
|
+
payee: recipient,
|
|
252
|
+
token: currency,
|
|
253
|
+
deposit: 5_000_000n,
|
|
254
|
+
salt,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const fakeChannelId =
|
|
258
|
+
'0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
|
|
259
|
+
|
|
260
|
+
await expect(
|
|
261
|
+
broadcastOpenTransaction({
|
|
262
|
+
client,
|
|
263
|
+
serializedTransaction,
|
|
264
|
+
escrowContract,
|
|
265
|
+
channelId: fakeChannelId,
|
|
266
|
+
recipient,
|
|
267
|
+
currency,
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toThrow('open transaction does not match claimed channelId')
|
|
270
|
+
})
|
|
271
|
+
|
|
245
272
|
test('successful broadcast returns txHash and onChain state', async () => {
|
|
246
273
|
const salt = nextSalt()
|
|
247
274
|
const deposit = 10_000_000n
|
|
@@ -313,6 +340,59 @@ describe('on-chain', () => {
|
|
|
313
340
|
).rejects.toThrow('fee-sponsored open transaction contains an unauthorized call')
|
|
314
341
|
})
|
|
315
342
|
|
|
343
|
+
test('fee-payer: rejects unsigned transaction', async () => {
|
|
344
|
+
const salt = nextSalt()
|
|
345
|
+
const deposit = 5_000_000n
|
|
346
|
+
|
|
347
|
+
const { channelId, serializedTransaction } = await signOpenChannel({
|
|
348
|
+
escrow: escrowContract,
|
|
349
|
+
payer,
|
|
350
|
+
payee: recipient,
|
|
351
|
+
token: currency,
|
|
352
|
+
deposit,
|
|
353
|
+
salt,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// Strip the sender signature to simulate the POC attack
|
|
357
|
+
const deserialized = Transaction.deserialize(
|
|
358
|
+
serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
359
|
+
)
|
|
360
|
+
const unsigned = await Transaction.serialize({
|
|
361
|
+
...deserialized,
|
|
362
|
+
signature: undefined,
|
|
363
|
+
from: undefined,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
await expect(
|
|
367
|
+
broadcastOpenTransaction({
|
|
368
|
+
client,
|
|
369
|
+
serializedTransaction: unsigned,
|
|
370
|
+
escrowContract,
|
|
371
|
+
channelId,
|
|
372
|
+
recipient,
|
|
373
|
+
currency,
|
|
374
|
+
feePayer: accounts[0],
|
|
375
|
+
}),
|
|
376
|
+
).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('fee-payer: rejects non-Tempo transaction', async () => {
|
|
380
|
+
const fakeEip1559 =
|
|
381
|
+
'0x02f8650182a5bf843b9aca00843b9aca008252089400000000000000000000000000000000000000008080c001a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' as Hex
|
|
382
|
+
|
|
383
|
+
await expect(
|
|
384
|
+
broadcastOpenTransaction({
|
|
385
|
+
client,
|
|
386
|
+
serializedTransaction: fakeEip1559,
|
|
387
|
+
escrowContract,
|
|
388
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
|
|
389
|
+
recipient,
|
|
390
|
+
currency,
|
|
391
|
+
feePayer: accounts[0],
|
|
392
|
+
}),
|
|
393
|
+
).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
|
|
394
|
+
})
|
|
395
|
+
|
|
316
396
|
test('duplicate broadcast returns fallback with txHash undefined', async () => {
|
|
317
397
|
const salt = nextSalt()
|
|
318
398
|
const deposit = 5_000_000n
|
|
@@ -544,6 +624,70 @@ describe('on-chain', () => {
|
|
|
544
624
|
}),
|
|
545
625
|
).rejects.toThrow('fee-sponsored topUp transaction contains an unauthorized call')
|
|
546
626
|
})
|
|
627
|
+
|
|
628
|
+
test('fee-payer: rejects unsigned transaction', async () => {
|
|
629
|
+
const salt = nextSalt()
|
|
630
|
+
const deposit = 5_000_000n
|
|
631
|
+
const topUpAmount = 3_000_000n
|
|
632
|
+
|
|
633
|
+
const { channelId } = await openChannel({
|
|
634
|
+
escrow: escrowContract,
|
|
635
|
+
payer,
|
|
636
|
+
payee: recipient,
|
|
637
|
+
token: currency,
|
|
638
|
+
deposit,
|
|
639
|
+
salt,
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const { serializedTransaction } = await signTopUpChannel({
|
|
643
|
+
escrow: escrowContract,
|
|
644
|
+
payer,
|
|
645
|
+
channelId,
|
|
646
|
+
token: currency,
|
|
647
|
+
amount: topUpAmount,
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
// Strip the sender signature to simulate the POC attack
|
|
651
|
+
const deserialized = Transaction.deserialize(
|
|
652
|
+
serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
653
|
+
)
|
|
654
|
+
const unsigned = await Transaction.serialize({
|
|
655
|
+
...deserialized,
|
|
656
|
+
signature: undefined,
|
|
657
|
+
from: undefined,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
await expect(
|
|
661
|
+
broadcastTopUpTransaction({
|
|
662
|
+
client,
|
|
663
|
+
serializedTransaction: unsigned,
|
|
664
|
+
escrowContract,
|
|
665
|
+
channelId,
|
|
666
|
+
currency: asset,
|
|
667
|
+
declaredDeposit: topUpAmount,
|
|
668
|
+
previousDeposit: deposit,
|
|
669
|
+
feePayer: accounts[0],
|
|
670
|
+
}),
|
|
671
|
+
).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
test('fee-payer: rejects non-Tempo transaction', async () => {
|
|
675
|
+
const fakeEip1559 =
|
|
676
|
+
'0x02f8650182a5bf843b9aca00843b9aca008252089400000000000000000000000000000000000000008080c001a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' as Hex
|
|
677
|
+
|
|
678
|
+
await expect(
|
|
679
|
+
broadcastTopUpTransaction({
|
|
680
|
+
client,
|
|
681
|
+
serializedTransaction: fakeEip1559,
|
|
682
|
+
escrowContract,
|
|
683
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
|
|
684
|
+
currency: asset,
|
|
685
|
+
declaredDeposit: 1_000_000n,
|
|
686
|
+
previousDeposit: 0n,
|
|
687
|
+
feePayer: accounts[0],
|
|
688
|
+
}),
|
|
689
|
+
).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
|
|
690
|
+
})
|
|
547
691
|
})
|
|
548
692
|
|
|
549
693
|
describe('settleOnChain', () => {
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
encodeFunctionData,
|
|
7
7
|
getAbiItem,
|
|
8
8
|
type Hex,
|
|
9
|
-
isAddressEqual,
|
|
10
9
|
type ReadContractReturnType,
|
|
11
10
|
toFunctionSelector,
|
|
12
11
|
} from 'viem'
|
|
@@ -21,12 +20,27 @@ import {
|
|
|
21
20
|
} from 'viem/actions'
|
|
22
21
|
import { Transaction } from 'viem/tempo'
|
|
23
22
|
import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
|
|
23
|
+
import * as TempoAddress from '../internal/address.js'
|
|
24
24
|
import * as defaults from '../internal/defaults.js'
|
|
25
|
+
import { isTempoTransaction } from '../internal/fee-payer.js'
|
|
26
|
+
import * as Channel from './Channel.js'
|
|
25
27
|
import { escrowAbi } from './escrow.abi.js'
|
|
26
28
|
import type { SignedVoucher } from './Types.js'
|
|
27
29
|
|
|
28
30
|
export { escrowAbi }
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Asserts that a deserialized transaction has an existing sender signature —
|
|
34
|
+
* required before fee payer co-signing to prevent the fee payer from becoming
|
|
35
|
+
* the sender.
|
|
36
|
+
*/
|
|
37
|
+
function assertSenderSigned(transaction: any): void {
|
|
38
|
+
if (!transaction.signature || !transaction.from)
|
|
39
|
+
throw new BadRequestError({
|
|
40
|
+
reason: 'Transaction must be signed by the sender before fee payer co-signing',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
const UINT128_MAX = 2n ** 128n - 1n
|
|
31
45
|
|
|
32
46
|
/**
|
|
@@ -221,13 +235,21 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
221
235
|
waitForConfirmation = true,
|
|
222
236
|
} = parameters
|
|
223
237
|
|
|
238
|
+
if (feePayer && !isTempoTransaction(serializedTransaction))
|
|
239
|
+
throw new BadRequestError({
|
|
240
|
+
reason: 'Only Tempo (0x76/0x78) transactions are supported',
|
|
241
|
+
})
|
|
242
|
+
|
|
224
243
|
const transaction = Transaction.deserialize(
|
|
225
244
|
serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
226
245
|
)
|
|
246
|
+
|
|
247
|
+
if (feePayer) assertSenderSigned(transaction)
|
|
248
|
+
|
|
227
249
|
const calls = transaction.calls ?? []
|
|
228
250
|
|
|
229
251
|
const openCall = calls.find((call) => {
|
|
230
|
-
if (!call.to || !
|
|
252
|
+
if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
|
|
231
253
|
if (!call.data) return false
|
|
232
254
|
return call.data.slice(0, 10) === escrowOpenSelector
|
|
233
255
|
})
|
|
@@ -246,8 +268,9 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
246
268
|
}
|
|
247
269
|
const selector = call.data.slice(0, 10)
|
|
248
270
|
const isEscrowOpen =
|
|
249
|
-
|
|
250
|
-
const isTokenApprove =
|
|
271
|
+
TempoAddress.isEqual(call.to, escrowContract) && selector === escrowOpenSelector
|
|
272
|
+
const isTokenApprove =
|
|
273
|
+
TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
|
|
251
274
|
if (!isEscrowOpen && !isTokenApprove) {
|
|
252
275
|
throw new BadRequestError({
|
|
253
276
|
reason: 'fee-sponsored open transaction contains an unauthorized call',
|
|
@@ -257,7 +280,7 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
257
280
|
}
|
|
258
281
|
|
|
259
282
|
const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
|
|
260
|
-
const [payee, token, deposit, , authorizedSigner] = openArgs as readonly [
|
|
283
|
+
const [payee, token, deposit, salt, authorizedSigner] = openArgs as readonly [
|
|
261
284
|
Address,
|
|
262
285
|
Address,
|
|
263
286
|
bigint,
|
|
@@ -265,17 +288,33 @@ export async function broadcastOpenTransaction(parameters: {
|
|
|
265
288
|
Address,
|
|
266
289
|
]
|
|
267
290
|
|
|
268
|
-
if (!
|
|
291
|
+
if (!TempoAddress.isEqual(payee, recipient)) {
|
|
269
292
|
throw new VerificationFailedError({
|
|
270
293
|
reason: 'open transaction payee does not match server recipient',
|
|
271
294
|
})
|
|
272
295
|
}
|
|
273
|
-
if (!
|
|
296
|
+
if (!TempoAddress.isEqual(token, currency)) {
|
|
274
297
|
throw new VerificationFailedError({
|
|
275
298
|
reason: 'open transaction token does not match server currency',
|
|
276
299
|
})
|
|
277
300
|
}
|
|
278
301
|
|
|
302
|
+
if (!transaction.from) throw new BadRequestError({ reason: 'open transaction has no sender' })
|
|
303
|
+
|
|
304
|
+
const derivedChannelId = Channel.computeId({
|
|
305
|
+
payer: transaction.from as `0x${string}`,
|
|
306
|
+
payee,
|
|
307
|
+
token,
|
|
308
|
+
salt,
|
|
309
|
+
authorizedSigner,
|
|
310
|
+
escrowContract,
|
|
311
|
+
chainId: client.chain!.id,
|
|
312
|
+
})
|
|
313
|
+
if (derivedChannelId.toLowerCase() !== channelId.toLowerCase())
|
|
314
|
+
throw new VerificationFailedError({
|
|
315
|
+
reason: 'open transaction does not match claimed channelId',
|
|
316
|
+
})
|
|
317
|
+
|
|
279
318
|
const resolvedFeeToken =
|
|
280
319
|
transaction.feeToken ?? defaults.currency[client.chain?.id as keyof typeof defaults.currency]
|
|
281
320
|
|
|
@@ -364,13 +403,21 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
364
403
|
feePayer,
|
|
365
404
|
} = parameters
|
|
366
405
|
|
|
406
|
+
if (feePayer && !isTempoTransaction(serializedTransaction))
|
|
407
|
+
throw new BadRequestError({
|
|
408
|
+
reason: 'Only Tempo (0x76/0x78) transactions are supported',
|
|
409
|
+
})
|
|
410
|
+
|
|
367
411
|
const transaction = Transaction.deserialize(
|
|
368
412
|
serializedTransaction as Transaction.TransactionSerializedTempo,
|
|
369
413
|
)
|
|
414
|
+
|
|
415
|
+
if (feePayer) assertSenderSigned(transaction)
|
|
416
|
+
|
|
370
417
|
const calls = transaction.calls ?? []
|
|
371
418
|
|
|
372
419
|
const topUpCall = calls.find((call) => {
|
|
373
|
-
if (!call.to || !
|
|
420
|
+
if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
|
|
374
421
|
if (!call.data) return false
|
|
375
422
|
return call.data.slice(0, 10) === escrowTopUpSelector
|
|
376
423
|
})
|
|
@@ -389,8 +436,9 @@ export async function broadcastTopUpTransaction(parameters: {
|
|
|
389
436
|
}
|
|
390
437
|
const selector = call.data.slice(0, 10)
|
|
391
438
|
const isEscrowTopUp =
|
|
392
|
-
|
|
393
|
-
const isTokenApprove =
|
|
439
|
+
TempoAddress.isEqual(call.to, escrowContract) && selector === escrowTopUpSelector
|
|
440
|
+
const isTokenApprove =
|
|
441
|
+
TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
|
|
394
442
|
if (!isEscrowTopUp && !isTokenApprove) {
|
|
395
443
|
throw new BadRequestError({
|
|
396
444
|
reason: 'fee-sponsored topUp transaction contains an unauthorized call',
|
|
@@ -22,6 +22,7 @@ function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.Stat
|
|
|
22
22
|
highestVoucher: null,
|
|
23
23
|
spent: 0n,
|
|
24
24
|
units: 0,
|
|
25
|
+
closeRequestedAt: 0n,
|
|
25
26
|
finalized: false,
|
|
26
27
|
createdAt: '2025-01-01T00:00:00.000Z',
|
|
27
28
|
...overrides,
|
|
@@ -239,6 +240,15 @@ describe('ChannelStore.deductFromChannel', () => {
|
|
|
239
240
|
)
|
|
240
241
|
})
|
|
241
242
|
|
|
243
|
+
test('rejects deduction when channel is finalized', async () => {
|
|
244
|
+
const cs = ChannelStore.fromStore(Store.memory())
|
|
245
|
+
await seedChannel(cs, { highestVoucherAmount: 10_000_000n, spent: 0n, finalized: true })
|
|
246
|
+
|
|
247
|
+
const result = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
|
|
248
|
+
expect(result.ok).toBe(false)
|
|
249
|
+
expect(result.channel.spent).toBe(0n)
|
|
250
|
+
})
|
|
251
|
+
|
|
242
252
|
test('exact balance succeeds', async () => {
|
|
243
253
|
const cs = ChannelStore.fromStore(Store.memory())
|
|
244
254
|
await seedChannel(cs, { highestVoucherAmount: 1_000_000n, spent: 0n })
|
|
@@ -27,6 +27,8 @@ export interface State {
|
|
|
27
27
|
escrowContract: Address
|
|
28
28
|
/** Unique identifier for this payment channel. */
|
|
29
29
|
channelId: Hex
|
|
30
|
+
/** On-chain timestamp when a force-close was requested (0n if not requested). */
|
|
31
|
+
closeRequestedAt: bigint
|
|
30
32
|
/** ISO 8601 timestamp when the channel was created. */
|
|
31
33
|
createdAt: string
|
|
32
34
|
/** Current on-chain deposit in the escrow contract. */
|
|
@@ -107,6 +109,7 @@ export async function deductFromChannel(
|
|
|
107
109
|
const channel = await store.updateChannel(channelId, (current) => {
|
|
108
110
|
deducted = false
|
|
109
111
|
if (!current) return null
|
|
112
|
+
if (current.finalized) return current
|
|
110
113
|
if (current.highestVoucherAmount - current.spent >= amount) {
|
|
111
114
|
deducted = true
|
|
112
115
|
return { ...current, spent: current.spent + amount, units: current.units + 1 }
|
|
@@ -165,9 +168,9 @@ export function fromStore(store: Store.Store): ChannelStore {
|
|
|
165
168
|
)
|
|
166
169
|
|
|
167
170
|
try {
|
|
168
|
-
const current = await store.get
|
|
171
|
+
const current = (await store.get(channelId)) as State | null
|
|
169
172
|
const next = fn(current)
|
|
170
|
-
if (next) await store.put(channelId, next)
|
|
173
|
+
if (next) await store.put(channelId, next as never)
|
|
171
174
|
else await store.delete(channelId)
|
|
172
175
|
return next
|
|
173
176
|
} finally {
|
|
@@ -178,7 +181,7 @@ export function fromStore(store: Store.Store): ChannelStore {
|
|
|
178
181
|
|
|
179
182
|
const cs: ChannelStore = {
|
|
180
183
|
async getChannel(channelId) {
|
|
181
|
-
return store.get
|
|
184
|
+
return (await store.get(channelId)) as State | null
|
|
182
185
|
},
|
|
183
186
|
async updateChannel(channelId, fn) {
|
|
184
187
|
const result = await update(channelId, fn)
|