mppx 0.8.13 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/Errors.d.ts +10 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +11 -2
- package/dist/Errors.js.map +1 -1
- package/dist/Method.d.ts +105 -3
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +102 -2
- package/dist/Method.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +12 -2
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +17 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
- package/dist/client/internal/MethodChallenge.js +11 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -0
- package/dist/server/Mppx.d.ts +23 -4
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +91 -82
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +7 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +2 -1
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +38 -77
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +289 -209
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +12 -1
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +48 -0
- package/dist/tempo/server/Relay.d.ts.map +1 -0
- package/dist/tempo/server/Relay.js +177 -0
- package/dist/tempo/server/Relay.js.map +1 -0
- package/dist/tempo/server/SponsorBudget.d.ts +59 -0
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
- package/dist/tempo/server/SponsorBudget.js +144 -0
- package/dist/tempo/server/SponsorBudget.js.map +1 -0
- 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/session/client/ChannelOps.d.ts +7 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +9 -4
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +5 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +37 -19
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Runtime.d.ts +8 -0
- package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
- package/dist/tempo/session/client/Runtime.js +17 -0
- package/dist/tempo/session/client/Runtime.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +15 -5
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +93 -36
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +8 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +54 -26
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +7 -5
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +48 -24
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +96 -8
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/precompile/Channel.d.ts +2 -0
- package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Channel.js +4 -0
- package/dist/tempo/session/precompile/Channel.js.map +1 -1
- package/dist/tempo/session/server/RequestState.js +3 -1
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/package.json +1 -1
- package/src/Errors.test.ts +23 -0
- package/src/Errors.ts +21 -2
- package/src/Method.test.ts +102 -1
- package/src/Method.ts +241 -5
- package/src/client/Mppx.test-d.ts +3 -3
- package/src/client/internal/Fetch.ts +16 -7
- package/src/client/internal/MethodChallenge.ts +30 -0
- package/src/server/Mppx.test-d.ts +2 -0
- package/src/server/Mppx.test.ts +205 -0
- package/src/server/Mppx.ts +167 -103
- package/src/tempo/PublicExports.test-d.ts +20 -0
- package/src/tempo/internal/fee-payer.ts +7 -3
- package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
- package/src/tempo/legacy/client/ChannelOps.ts +2 -1
- package/src/tempo/server/Charge.test.ts +337 -63
- package/src/tempo/server/Charge.ts +435 -267
- package/src/tempo/server/Methods.ts +11 -0
- package/src/tempo/server/Relay.test.ts +523 -0
- package/src/tempo/server/Relay.ts +288 -0
- package/src/tempo/server/SponsorBudget.test.ts +125 -0
- package/src/tempo/server/SponsorBudget.ts +213 -0
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/ChannelOps.test.ts +17 -2
- package/src/tempo/session/client/ChannelOps.ts +12 -4
- package/src/tempo/session/client/CredentialState.ts +61 -20
- package/src/tempo/session/client/Runtime.test.ts +12 -0
- package/src/tempo/session/client/Runtime.ts +22 -0
- package/src/tempo/session/client/Session.test.ts +235 -2
- package/src/tempo/session/client/Session.ts +130 -41
- package/src/tempo/session/client/SessionManager.test.ts +102 -4
- package/src/tempo/session/client/SessionManager.ts +71 -27
- package/src/tempo/session/client/Transports.test.ts +32 -1
- package/src/tempo/session/client/Transports.ts +79 -35
- package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
- package/src/tempo/session/precompile/Chain.test.ts +197 -10
- package/src/tempo/session/precompile/Chain.ts +102 -8
- package/src/tempo/session/precompile/Channel.test.ts +9 -0
- package/src/tempo/session/precompile/Channel.ts +5 -0
- package/src/tempo/session/server/RequestState.test.ts +8 -1
- package/src/tempo/session/server/RequestState.ts +2 -2
|
@@ -194,10 +194,25 @@ describe('precompile client ChannelOps credential builders', () => {
|
|
|
194
194
|
const now = Math.floor(Date.now() / 1_000)
|
|
195
195
|
|
|
196
196
|
expect(requests).toMatchObject([
|
|
197
|
-
{ feePayer: true, validAfter: expect.any(Number) },
|
|
198
|
-
{ feePayer: true, validAfter: expect.any(Number) },
|
|
197
|
+
{ feePayer: true, nonceKey: 'expiring', validAfter: expect.any(Number) },
|
|
198
|
+
{ feePayer: true, nonceKey: 'expiring', validAfter: expect.any(Number) },
|
|
199
199
|
])
|
|
200
200
|
expect(new Set(validAfter).size).toBe(2)
|
|
201
201
|
expect(validAfter.every((value) => value >= 0 && value < now)).toBe(true)
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
test('uses expiring nonces and entropy for unsponsored management transactions', async () => {
|
|
205
|
+
mocks.prepareTransactionRequest.mockClear()
|
|
206
|
+
|
|
207
|
+
await ChannelOps.createTopUpPayload(client, account, descriptor, 10n, chainId, false)
|
|
208
|
+
|
|
209
|
+
expect(mocks.prepareTransactionRequest).toHaveBeenCalledWith(
|
|
210
|
+
client,
|
|
211
|
+
expect.objectContaining({
|
|
212
|
+
nonceKey: 'expiring',
|
|
213
|
+
validAfter: expect.any(Number),
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
expect(mocks.prepareTransactionRequest.mock.calls[0]?.[1]).not.toHaveProperty('feePayer')
|
|
217
|
+
})
|
|
203
218
|
})
|
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
import { uint96 } from '../precompile/Protocol.js'
|
|
29
29
|
import * as Voucher from '../precompile/Voucher.js'
|
|
30
30
|
|
|
31
|
-
type TempoChannelCall = {
|
|
31
|
+
export type TempoChannelCall = {
|
|
32
32
|
to: Address
|
|
33
33
|
data: Hex.Hex
|
|
34
34
|
}
|
|
@@ -91,14 +91,18 @@ async function prepareTempoChannelTransaction(
|
|
|
91
91
|
},
|
|
92
92
|
) {
|
|
93
93
|
const { account, calls, feePayer, feeToken, validAfter } = parameters
|
|
94
|
+
// Session management transactions are independent and short-lived, so they
|
|
95
|
+
// always use TIP-1009 expiring nonces. `feePayer` controls sponsorship only.
|
|
94
96
|
// viem's stable transaction request type does not yet expose Tempo's
|
|
95
97
|
// `calls`, `feePayer`, and `feeToken` fields together. Keep the cast at
|
|
96
98
|
// this boundary so session credential builders stay typed.
|
|
97
99
|
return prepareTransactionRequest(client, {
|
|
98
100
|
account,
|
|
99
101
|
calls,
|
|
100
|
-
|
|
102
|
+
nonceKey: 'expiring',
|
|
103
|
+
...(feePayer ? { feePayer: true } : {}),
|
|
101
104
|
feeToken,
|
|
105
|
+
...(validAfter !== undefined ? { validAfter } : {}),
|
|
102
106
|
} as never)
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -227,6 +231,8 @@ export async function createOpenPayload(
|
|
|
227
231
|
initialAmount: bigint
|
|
228
232
|
operator?: Address | undefined
|
|
229
233
|
payee: Address
|
|
234
|
+
/** Calls executed atomically before the channel is opened. */
|
|
235
|
+
prefixCalls?: readonly TempoChannelCall[] | undefined
|
|
230
236
|
token: Address
|
|
231
237
|
},
|
|
232
238
|
): Promise<OpenCredentialPayload> {
|
|
@@ -244,7 +250,7 @@ export async function createOpenPayload(
|
|
|
244
250
|
})
|
|
245
251
|
const prepared = await prepareTempoChannelTransaction(client, {
|
|
246
252
|
account,
|
|
247
|
-
calls: [{ to: escrow, data: openData }],
|
|
253
|
+
calls: [...(parameters.prefixCalls ?? []), { to: escrow, data: openData }],
|
|
248
254
|
feePayer: parameters.feePayer,
|
|
249
255
|
feeToken: parameters.token,
|
|
250
256
|
})
|
|
@@ -304,6 +310,7 @@ export async function createTopUpPayload(
|
|
|
304
310
|
chainId: number,
|
|
305
311
|
feePayer?: boolean | undefined,
|
|
306
312
|
escrow: Address = tip20ChannelEscrow,
|
|
313
|
+
prefixCalls: readonly TempoChannelCall[] = [],
|
|
307
314
|
): Promise<TopUpCredentialPayload> {
|
|
308
315
|
const channelId = Channel.computeId({
|
|
309
316
|
...descriptor,
|
|
@@ -314,6 +321,7 @@ export async function createTopUpPayload(
|
|
|
314
321
|
const prepared = await prepareTempoChannelTransaction(client, {
|
|
315
322
|
account,
|
|
316
323
|
calls: [
|
|
324
|
+
...prefixCalls,
|
|
317
325
|
{
|
|
318
326
|
to: escrow,
|
|
319
327
|
data: encodeFunctionData({
|
|
@@ -325,7 +333,7 @@ export async function createTopUpPayload(
|
|
|
325
333
|
],
|
|
326
334
|
feePayer,
|
|
327
335
|
feeToken: descriptor.token,
|
|
328
|
-
|
|
336
|
+
validAfter: randomValidAfter(),
|
|
329
337
|
})
|
|
330
338
|
const transaction = await signPreparedTempoTransaction(client, prepared)
|
|
331
339
|
|
|
@@ -11,6 +11,7 @@ import type * as Challenge from '../../../Challenge.js'
|
|
|
11
11
|
import * as Constants from '../../../Constants.js'
|
|
12
12
|
import * as Account from '../../../viem/Account.js'
|
|
13
13
|
import * as z from '../../../zod.js'
|
|
14
|
+
import * as AutoSwap from '../../internal/auto-swap.js'
|
|
14
15
|
import * as Chain from '../precompile/Chain.js'
|
|
15
16
|
import * as Channel from '../precompile/Channel.js'
|
|
16
17
|
import type { SessionCredentialPayload } from '../precompile/Protocol.js'
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
resolveAuthorizedSigner,
|
|
26
27
|
resolveEscrow,
|
|
27
28
|
type ChannelEntry,
|
|
29
|
+
type TempoChannelCall,
|
|
28
30
|
} from './ChannelOps.js'
|
|
29
31
|
import { channelKey, entryKey, type ChannelSink } from './ChannelStore.js'
|
|
30
32
|
import { assertWithinMaxDeposit, resolveOpeningDeposit } from './Runtime.js'
|
|
@@ -88,6 +90,7 @@ const hashSchema = z.custom<Hex>(
|
|
|
88
90
|
/** Runtime schema for low-level TIP-1034 session credential context. */
|
|
89
91
|
export const sessionContextSchema = z.object({
|
|
90
92
|
account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
|
|
93
|
+
autoSwap: z.optional(z.custom<AutoSwap.resolve.Value>()),
|
|
91
94
|
action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])),
|
|
92
95
|
channelId: z.optional(hashSchema),
|
|
93
96
|
cumulativeAmount: z.optional(z.amount()),
|
|
@@ -103,6 +106,8 @@ export const sessionContextSchema = z.object({
|
|
|
103
106
|
export type SessionContext = {
|
|
104
107
|
/** Optional account override used for this credential only. */
|
|
105
108
|
account?: Account.getResolver.Parameters['account'] | undefined
|
|
109
|
+
/** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
|
|
110
|
+
autoSwap?: AutoSwap.resolve.Value | undefined
|
|
106
111
|
/** Manual credential action. Omit for automatic open/recover/voucher management. */
|
|
107
112
|
action?: 'open' | 'topUp' | 'voucher' | 'close' | undefined
|
|
108
113
|
/** Channel ID being reused or manually operated on. */
|
|
@@ -497,7 +502,7 @@ function assertReusableChannelDescriptor(parameters: {
|
|
|
497
502
|
throw new Error('context descriptor token does not match challenge')
|
|
498
503
|
if (!isSameAddress(descriptor.payer, payer))
|
|
499
504
|
throw new Error('context descriptor payer does not match account')
|
|
500
|
-
if (!isSameAddress(descriptor
|
|
505
|
+
if (!isSameAddress(Channel.resolveAuthorizedSigner(descriptor), authorizedSigner))
|
|
501
506
|
throw new Error('context descriptor authorizedSigner does not match account')
|
|
502
507
|
}
|
|
503
508
|
|
|
@@ -558,10 +563,6 @@ export async function hydrateSessionSnapshot(
|
|
|
558
563
|
if (snapshotSettled > snapshotDeposit)
|
|
559
564
|
throw new Error('session snapshot settled amount exceeds deposit')
|
|
560
565
|
|
|
561
|
-
const authorizedSigner =
|
|
562
|
-
BigInt(snapshot.descriptor.authorizedSigner) === 0n
|
|
563
|
-
? snapshot.descriptor.payer
|
|
564
|
-
: snapshot.descriptor.authorizedSigner
|
|
565
566
|
if (
|
|
566
567
|
!Voucher.verifyVoucher(
|
|
567
568
|
snapshot.escrow,
|
|
@@ -571,7 +572,7 @@ export async function hydrateSessionSnapshot(
|
|
|
571
572
|
cumulativeAmount: voucherCumulative,
|
|
572
573
|
signature: signed.signature,
|
|
573
574
|
},
|
|
574
|
-
|
|
575
|
+
Channel.resolveAuthorizedSigner(snapshot.descriptor),
|
|
575
576
|
)
|
|
576
577
|
)
|
|
577
578
|
throw new Error('session snapshot highest voucher signature is invalid')
|
|
@@ -618,9 +619,10 @@ export function canSignDescriptor(
|
|
|
618
619
|
): boolean {
|
|
619
620
|
// Only the payer can deposit into and voucher against its own channel.
|
|
620
621
|
if (!isSameAddress(account.address, descriptor.payer)) return false
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
622
|
+
return isSameAddress(
|
|
623
|
+
resolveAuthorizedSigner(account),
|
|
624
|
+
Channel.resolveAuthorizedSigner(descriptor),
|
|
625
|
+
)
|
|
624
626
|
}
|
|
625
627
|
|
|
626
628
|
/** Chooses the next credential plan from local channel cache and optional caller context. */
|
|
@@ -661,22 +663,43 @@ export function planCredential(parameters: PlanCredentialParameters): Credential
|
|
|
661
663
|
export async function executeCredentialPlan(
|
|
662
664
|
plan: CredentialPlan,
|
|
663
665
|
sink: ChannelSink,
|
|
666
|
+
autoSwap: AutoSwap.resolve.Resolved | false = false,
|
|
664
667
|
): Promise<SessionCredentialPayload> {
|
|
665
668
|
switch (plan.type) {
|
|
666
669
|
case 'open':
|
|
667
|
-
return open(plan, sink)
|
|
670
|
+
return open(plan, sink, autoSwap)
|
|
668
671
|
case 'recover':
|
|
669
672
|
return recover(plan, sink)
|
|
670
673
|
case 'voucher':
|
|
671
674
|
return voucher(plan, sink)
|
|
672
675
|
case 'manual':
|
|
673
|
-
return manual(plan, sink)
|
|
676
|
+
return manual(plan, sink, autoSwap)
|
|
674
677
|
}
|
|
675
678
|
}
|
|
676
679
|
|
|
680
|
+
async function resolveAutoSwapCalls(
|
|
681
|
+
autoSwap: AutoSwap.resolve.Resolved | false,
|
|
682
|
+
parameters: {
|
|
683
|
+
account: Address
|
|
684
|
+
amountOut: bigint
|
|
685
|
+
client: Client
|
|
686
|
+
tokenOut: Address
|
|
687
|
+
},
|
|
688
|
+
): Promise<readonly TempoChannelCall[] | undefined> {
|
|
689
|
+
if (!autoSwap) return undefined
|
|
690
|
+
return (await AutoSwap.findCalls(parameters.client, {
|
|
691
|
+
account: parameters.account,
|
|
692
|
+
amountOut: parameters.amountOut,
|
|
693
|
+
tokenOut: parameters.tokenOut,
|
|
694
|
+
tokenIn: autoSwap.tokenIn,
|
|
695
|
+
slippage: autoSwap.slippage,
|
|
696
|
+
})) as readonly TempoChannelCall[] | undefined
|
|
697
|
+
}
|
|
698
|
+
|
|
677
699
|
async function open(
|
|
678
700
|
plan: Extract<CredentialPlan, { type: 'open' }>,
|
|
679
701
|
sink: ChannelSink,
|
|
702
|
+
autoSwap: AutoSwap.resolve.Resolved | false,
|
|
680
703
|
): Promise<SessionCredentialPayload> {
|
|
681
704
|
const { account, resolved } = plan
|
|
682
705
|
const deposit = resolveOpeningDeposit({
|
|
@@ -693,6 +716,12 @@ async function open(
|
|
|
693
716
|
initialAmount: resolved.amount,
|
|
694
717
|
operator: resolved.operator,
|
|
695
718
|
payee: resolved.payee,
|
|
719
|
+
prefixCalls: await resolveAutoSwapCalls(autoSwap, {
|
|
720
|
+
account: account.address,
|
|
721
|
+
amountOut: deposit,
|
|
722
|
+
client: resolved.client,
|
|
723
|
+
tokenOut: resolved.token,
|
|
724
|
+
}),
|
|
696
725
|
token: resolved.token,
|
|
697
726
|
})
|
|
698
727
|
await storeChannelEntry(sink, {
|
|
@@ -779,6 +808,7 @@ async function voucher(
|
|
|
779
808
|
async function manual(
|
|
780
809
|
plan: Extract<CredentialPlan, { type: 'manual' }>,
|
|
781
810
|
sink: ChannelSink,
|
|
811
|
+
autoSwap: AutoSwap.resolve.Resolved | false,
|
|
782
812
|
): Promise<SessionCredentialPayload> {
|
|
783
813
|
const { account, context, decimals, resolved } = plan
|
|
784
814
|
const { descriptor } = context
|
|
@@ -797,26 +827,30 @@ async function manual(
|
|
|
797
827
|
token: resolved.token,
|
|
798
828
|
})
|
|
799
829
|
|
|
800
|
-
const payload = await executeManualCredential(
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
830
|
+
const payload = await executeManualCredential(
|
|
831
|
+
{
|
|
832
|
+
account,
|
|
833
|
+
channelId,
|
|
834
|
+
context,
|
|
835
|
+
decimals,
|
|
836
|
+
descriptor,
|
|
837
|
+
resolved,
|
|
838
|
+
},
|
|
839
|
+
autoSwap,
|
|
840
|
+
)
|
|
808
841
|
await applyCumulative(sink, resolved.key, payload)
|
|
809
842
|
return payload
|
|
810
843
|
}
|
|
811
844
|
|
|
812
845
|
async function executeManualCredential(
|
|
813
846
|
parameters: ManualCredentialParameters,
|
|
847
|
+
autoSwap: AutoSwap.resolve.Resolved | false,
|
|
814
848
|
): Promise<SessionCredentialPayload> {
|
|
815
849
|
switch (parameters.context.action) {
|
|
816
850
|
case 'open':
|
|
817
851
|
return manualOpen(parameters)
|
|
818
852
|
case 'topUp':
|
|
819
|
-
return manualTopUp(parameters)
|
|
853
|
+
return manualTopUp(parameters, autoSwap)
|
|
820
854
|
case 'voucher':
|
|
821
855
|
return manualVoucher(parameters)
|
|
822
856
|
case 'close':
|
|
@@ -852,6 +886,7 @@ async function manualOpen(
|
|
|
852
886
|
|
|
853
887
|
async function manualTopUp(
|
|
854
888
|
parameters: ManualCredentialParameters,
|
|
889
|
+
autoSwap: AutoSwap.resolve.Resolved | false,
|
|
855
890
|
): Promise<SessionCredentialPayload> {
|
|
856
891
|
const { account, channelId, context, decimals, descriptor, resolved } = parameters
|
|
857
892
|
const additionalDeposit = requireContextAmount(context, decimals, 'additionalDeposit', 'topUp')
|
|
@@ -873,6 +908,12 @@ async function manualTopUp(
|
|
|
873
908
|
resolved.chainId,
|
|
874
909
|
resolved.feePayer,
|
|
875
910
|
resolved.escrow,
|
|
911
|
+
await resolveAutoSwapCalls(autoSwap, {
|
|
912
|
+
account: account.address,
|
|
913
|
+
amountOut: additionalDeposit,
|
|
914
|
+
client: resolved.client,
|
|
915
|
+
tokenOut: resolved.token,
|
|
916
|
+
}),
|
|
876
917
|
)
|
|
877
918
|
}
|
|
878
919
|
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
nextSpentFromReceipt,
|
|
31
31
|
parseManagerAmount,
|
|
32
32
|
reduce,
|
|
33
|
+
resolveAutomaticTopUp,
|
|
33
34
|
resolveCloseTarget,
|
|
34
35
|
resolveNeedVoucherTransition,
|
|
35
36
|
resolveOpeningDeposit,
|
|
@@ -925,6 +926,17 @@ describe('LocalAuthorization', () => {
|
|
|
925
926
|
)
|
|
926
927
|
})
|
|
927
928
|
|
|
929
|
+
test.each([
|
|
930
|
+
[{ topUpAmount: 80n, suggestedDeposit: 100n }, 80n],
|
|
931
|
+
[{ suggestedDeposit: 100n }, 100n],
|
|
932
|
+
[{}, 50n],
|
|
933
|
+
[{ maxDeposit: 180n, suggestedDeposit: 100n }, 80n],
|
|
934
|
+
])('resolves bounded automatic top-ups %#', (policy, expected) => {
|
|
935
|
+
expect(resolveAutomaticTopUp({ deposit: 100n, requiredCumulative: 150n, ...policy })).toBe(
|
|
936
|
+
expected,
|
|
937
|
+
)
|
|
938
|
+
})
|
|
939
|
+
|
|
928
940
|
test('enforces optional maxDeposit through the compatibility helper', () => {
|
|
929
941
|
expect(() => assertWithinMaxDeposit(101n, 100n)).toThrow(
|
|
930
942
|
'requested voucher amount 101 exceeds local maxDeposit 100',
|
|
@@ -533,6 +533,28 @@ export function resolveOpeningDeposit(parameters: ResolveOpeningDepositParameter
|
|
|
533
533
|
return proposed
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
/** Resolves a bounded automatic refill, preferring explicit policy over server headroom. */
|
|
537
|
+
export function resolveAutomaticTopUp(parameters: {
|
|
538
|
+
deposit: bigint
|
|
539
|
+
maxDeposit?: bigint | null | undefined
|
|
540
|
+
requiredCumulative: bigint
|
|
541
|
+
suggestedDeposit?: bigint | undefined
|
|
542
|
+
topUpAmount?: bigint | null | undefined
|
|
543
|
+
}): bigint {
|
|
544
|
+
const { deposit, maxDeposit, requiredCumulative, suggestedDeposit, topUpAmount } = parameters
|
|
545
|
+
if (requiredCumulative <= deposit) return 0n
|
|
546
|
+
assertVoucherWithinLocalLimit({
|
|
547
|
+
cumulativeAmount: requiredCumulative,
|
|
548
|
+
maxVoucherCumulative: maxDeposit ?? null,
|
|
549
|
+
})
|
|
550
|
+
const shortfall = requiredCumulative - deposit
|
|
551
|
+
const preferred = topUpAmount ?? suggestedDeposit ?? shortfall
|
|
552
|
+
const proposed = preferred > shortfall ? preferred : shortfall
|
|
553
|
+
if (maxDeposit === null || maxDeposit === undefined) return proposed
|
|
554
|
+
const remaining = maxDeposit - deposit
|
|
555
|
+
return proposed < remaining ? proposed : remaining
|
|
556
|
+
}
|
|
557
|
+
|
|
536
558
|
/** Enforces the optional client-side maximum cumulative voucher authorization. */
|
|
537
559
|
export function assertWithinMaxDeposit(
|
|
538
560
|
cumulativeAmount: bigint,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem'
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
3
|
import { Account as TempoAccount, Secp256k1, Transaction } from 'viem/tempo'
|
|
4
|
-
import { describe, expect, test } from 'vp/test'
|
|
4
|
+
import { describe, expect, test, vi } from 'vp/test'
|
|
5
5
|
|
|
6
6
|
import { serialize as serializeChallenge, type Challenge } from '../../../Challenge.js'
|
|
7
7
|
import * as Fetch from '../../../client/internal/Fetch.js'
|
|
8
|
+
import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
|
|
8
9
|
import * as Constants from '../../../Constants.js'
|
|
9
10
|
import * as Credential from '../../../Credential.js'
|
|
10
11
|
import * as z from '../../../zod.js'
|
|
12
|
+
import * as AutoSwap from '../../internal/auto-swap.js'
|
|
11
13
|
import * as Methods from '../../Methods.js'
|
|
12
14
|
import * as Channel from '../precompile/Channel.js'
|
|
13
15
|
import { escrowAbi } from '../precompile/escrow.abi.js'
|
|
@@ -99,6 +101,14 @@ function openDeposit(payload: Types.SessionCredentialPayload): bigint {
|
|
|
99
101
|
return openArgs(payload)[3]
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
function transactionCalls(payload: Types.SessionCredentialPayload) {
|
|
105
|
+
if (payload.action !== 'open' && payload.action !== 'topUp')
|
|
106
|
+
throw new Error('expected transaction payload')
|
|
107
|
+
const transaction = Transaction.deserialize(payload.transaction)
|
|
108
|
+
if (!('calls' in transaction)) throw new Error('expected tempo calls')
|
|
109
|
+
return transaction.calls as readonly { to?: Address; data?: `0x${string}` }[]
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
describe('precompile client session', () => {
|
|
103
113
|
test('handles only TIP-1034 session challenges', () => {
|
|
104
114
|
const method = session({ account, getClient: () => client })
|
|
@@ -205,7 +215,164 @@ describe('precompile client session', () => {
|
|
|
205
215
|
expect(await response.text()).toBe(
|
|
206
216
|
`${Types.formatMessageEvent('first')}${Types.formatMessageEvent('second')}`,
|
|
207
217
|
)
|
|
208
|
-
expect(actions).toEqual(['open', '
|
|
218
|
+
expect(actions).toEqual(['open', 'topUp', 'voucher', 'voucher'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('tops up before signing plain HTTP vouchers through Fetch.from', async () => {
|
|
222
|
+
const challenge = makeChallenge({
|
|
223
|
+
suggestedDeposit: '100',
|
|
224
|
+
methodDetails: {
|
|
225
|
+
chainId,
|
|
226
|
+
escrowContract: tip20ChannelEscrow,
|
|
227
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
231
|
+
const paidRequests: {
|
|
232
|
+
body: BodyInit | null
|
|
233
|
+
header: string | null
|
|
234
|
+
method: string | undefined
|
|
235
|
+
}[] = []
|
|
236
|
+
let opened: Extract<Types.SessionCredentialPayload, { action: 'open' }> | undefined
|
|
237
|
+
const rawFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
238
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
239
|
+
if (!authorization)
|
|
240
|
+
return new Response(null, {
|
|
241
|
+
status: 402,
|
|
242
|
+
headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const payload = deserialize(authorization)
|
|
246
|
+
actions.push(payload.action)
|
|
247
|
+
if (payload.action === 'open') {
|
|
248
|
+
opened = payload
|
|
249
|
+
return new Response('opened')
|
|
250
|
+
}
|
|
251
|
+
if (payload.action === 'topUp') return new Response(null, { status: 204 })
|
|
252
|
+
if (payload.action !== 'voucher' || !opened) throw new Error('expected open voucher')
|
|
253
|
+
|
|
254
|
+
paidRequests.push({
|
|
255
|
+
body: init?.body ?? null,
|
|
256
|
+
header: new Headers(init?.headers).get('x-test'),
|
|
257
|
+
method: init?.method,
|
|
258
|
+
})
|
|
259
|
+
return new Response('paid')
|
|
260
|
+
}
|
|
261
|
+
const fetch = Fetch.from({
|
|
262
|
+
fetch: rawFetch,
|
|
263
|
+
methods: [
|
|
264
|
+
session({
|
|
265
|
+
account,
|
|
266
|
+
decimals: 0,
|
|
267
|
+
getClient: () => client,
|
|
268
|
+
maxDeposit: '300',
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('opened')
|
|
274
|
+
const response = await fetch('https://example.com/resource', {
|
|
275
|
+
body: 'request-body',
|
|
276
|
+
headers: { 'x-test': 'preserved' },
|
|
277
|
+
method: 'POST',
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect(await response.text()).toBe('paid')
|
|
281
|
+
expect(actions).toEqual(['open', 'topUp', 'voucher'])
|
|
282
|
+
expect(paidRequests).toEqual([{ body: 'request-body', header: 'preserved', method: 'POST' }])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('does not top up a stored channel owned by another account', async () => {
|
|
286
|
+
const channelStore = createChannelStore()
|
|
287
|
+
const foreign = '0x0000000000000000000000000000000000000004' as Address
|
|
288
|
+
await channelStore.set({
|
|
289
|
+
chainId,
|
|
290
|
+
channelId: `0x${'44'.repeat(32)}`,
|
|
291
|
+
cumulativeAmount: 100n,
|
|
292
|
+
deposit: 100n,
|
|
293
|
+
descriptor: { ...descriptor, authorizedSigner: foreign, payer: foreign },
|
|
294
|
+
escrow: tip20ChannelEscrow,
|
|
295
|
+
opened: true,
|
|
296
|
+
})
|
|
297
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
298
|
+
const challenge = makeChallenge({
|
|
299
|
+
methodDetails: {
|
|
300
|
+
chainId,
|
|
301
|
+
escrowContract: tip20ChannelEscrow,
|
|
302
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
const fetch = Fetch.from({
|
|
306
|
+
fetch: async (_input, init) => {
|
|
307
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
308
|
+
if (!authorization)
|
|
309
|
+
return new Response(null, {
|
|
310
|
+
status: 402,
|
|
311
|
+
headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
|
|
312
|
+
})
|
|
313
|
+
actions.push(deserialize(authorization).action)
|
|
314
|
+
return new Response('paid')
|
|
315
|
+
},
|
|
316
|
+
methods: [session({ account, channelStore, decimals: 0, getClient: () => client })],
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
|
|
320
|
+
expect(actions).toEqual(['open'])
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('uses a matching server snapshot deposit when checking headroom', async () => {
|
|
324
|
+
const channelStore = createChannelStore()
|
|
325
|
+
const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
|
|
326
|
+
await channelStore.set({
|
|
327
|
+
chainId,
|
|
328
|
+
channelId,
|
|
329
|
+
cumulativeAmount: 100n,
|
|
330
|
+
deposit: 100n,
|
|
331
|
+
descriptor,
|
|
332
|
+
escrow: tip20ChannelEscrow,
|
|
333
|
+
opened: true,
|
|
334
|
+
})
|
|
335
|
+
const challenge = makeChallenge({
|
|
336
|
+
methodDetails: {
|
|
337
|
+
chainId,
|
|
338
|
+
escrowContract: tip20ChannelEscrow,
|
|
339
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
340
|
+
sessionSnapshot: {
|
|
341
|
+
acceptedCumulative: '100',
|
|
342
|
+
chainId,
|
|
343
|
+
channelId,
|
|
344
|
+
deposit: '200',
|
|
345
|
+
descriptor,
|
|
346
|
+
escrow: tip20ChannelEscrow,
|
|
347
|
+
requiredCumulative: '200',
|
|
348
|
+
settled: '0',
|
|
349
|
+
spent: '100',
|
|
350
|
+
units: 1,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
let updatedDeposit = 0n
|
|
355
|
+
const method = session({
|
|
356
|
+
account,
|
|
357
|
+
channelStore,
|
|
358
|
+
decimals: 0,
|
|
359
|
+
getClient: () => client,
|
|
360
|
+
onChannelUpdate: (entry) => {
|
|
361
|
+
updatedDeposit = entry.deposit
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await expect(
|
|
366
|
+
MethodChallenge.handle(method, {
|
|
367
|
+
challenge,
|
|
368
|
+
context: {},
|
|
369
|
+
fetch: async () => {
|
|
370
|
+
throw new Error('unexpected top-up')
|
|
371
|
+
},
|
|
372
|
+
input: 'https://example.com/resource',
|
|
373
|
+
}),
|
|
374
|
+
).resolves.toBeUndefined()
|
|
375
|
+
expect(updatedDeposit).toBe(200n)
|
|
209
376
|
})
|
|
210
377
|
|
|
211
378
|
test('opens for the current amount without client deposit configuration', async () => {
|
|
@@ -218,6 +385,72 @@ describe('precompile client session', () => {
|
|
|
218
385
|
expect(openDeposit(payload)).toBe(100n)
|
|
219
386
|
})
|
|
220
387
|
|
|
388
|
+
test('atomically auto-swaps before opening a session channel', async () => {
|
|
389
|
+
const sourceToken = '0x0000000000000000000000000000000000000007' as Address
|
|
390
|
+
const swapCalls = [
|
|
391
|
+
{ to: sourceToken, data: '0x1234' as const },
|
|
392
|
+
{ to: '0x0000000000000000000000000000000000000008' as Address, data: '0x5678' as const },
|
|
393
|
+
]
|
|
394
|
+
const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
|
|
395
|
+
try {
|
|
396
|
+
const method = session({
|
|
397
|
+
account,
|
|
398
|
+
autoSwap: { slippage: 2, tokenIn: [sourceToken] },
|
|
399
|
+
getClient: () => client,
|
|
400
|
+
})
|
|
401
|
+
const payload = deserialize(
|
|
402
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
|
|
406
|
+
account: account.address,
|
|
407
|
+
amountOut: 100n,
|
|
408
|
+
slippage: 2,
|
|
409
|
+
tokenIn: expect.arrayContaining([sourceToken]),
|
|
410
|
+
tokenOut: descriptor.token,
|
|
411
|
+
})
|
|
412
|
+
const calls = transactionCalls(payload)
|
|
413
|
+
expect(calls.slice(0, 2)).toEqual(swapCalls)
|
|
414
|
+
expect(calls[2]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
|
|
415
|
+
expect(decodeFunctionData({ abi: escrowAbi, data: calls[2]!.data! }).functionName).toBe(
|
|
416
|
+
'open',
|
|
417
|
+
)
|
|
418
|
+
} finally {
|
|
419
|
+
findCalls.mockRestore()
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('atomically auto-swaps before topping up a session channel', async () => {
|
|
424
|
+
const sourceToken = '0x0000000000000000000000000000000000000007' as Address
|
|
425
|
+
const swapCalls = [{ to: sourceToken, data: '0x1234' as const }]
|
|
426
|
+
const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
|
|
427
|
+
try {
|
|
428
|
+
const method = session({ account, autoSwap: true, getClient: () => client })
|
|
429
|
+
const payload = deserialize(
|
|
430
|
+
await method.createCredential({
|
|
431
|
+
challenge: makeChallenge(),
|
|
432
|
+
context: { action: 'topUp', additionalDepositRaw: '250', descriptor },
|
|
433
|
+
}),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
|
|
437
|
+
account: account.address,
|
|
438
|
+
amountOut: 250n,
|
|
439
|
+
slippage: 1,
|
|
440
|
+
tokenIn: AutoSwap.defaultCurrencies,
|
|
441
|
+
tokenOut: descriptor.token,
|
|
442
|
+
})
|
|
443
|
+
const calls = transactionCalls(payload)
|
|
444
|
+
expect(calls[0]).toEqual(swapCalls[0])
|
|
445
|
+
expect(calls[1]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
|
|
446
|
+
expect(decodeFunctionData({ abi: escrowAbi, data: calls[1]!.data! }).functionName).toBe(
|
|
447
|
+
'topUp',
|
|
448
|
+
)
|
|
449
|
+
} finally {
|
|
450
|
+
findCalls.mockRestore()
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
221
454
|
test('uses client chain ID when the challenge omits chain ID', async () => {
|
|
222
455
|
const method = session({ account, getClient: () => client })
|
|
223
456
|
const credential = deserializeCredential(
|