mppx 0.4.1 → 0.4.2
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 +260 -0
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli/account.d.ts +53 -0
- package/dist/cli/account.d.ts.map +1 -0
- package/dist/cli/account.js +156 -0
- package/dist/cli/account.js.map +1 -0
- package/dist/{cli.d.ts → cli/cli.d.ts} +4 -3
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +852 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/config.d.ts +39 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +30 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/internal.d.ts +16 -0
- package/dist/cli/internal.d.ts.map +1 -0
- package/dist/cli/internal.js +58 -0
- package/dist/cli/internal.js.map +1 -0
- package/dist/cli/plugins/index.d.ts +4 -0
- package/dist/cli/plugins/index.d.ts.map +1 -0
- package/dist/cli/plugins/index.js +4 -0
- package/dist/cli/plugins/index.js.map +1 -0
- package/dist/cli/plugins/plugin.d.ts +68 -0
- package/dist/cli/plugins/plugin.d.ts.map +1 -0
- package/dist/cli/plugins/plugin.js +4 -0
- package/dist/cli/plugins/plugin.js.map +1 -0
- package/dist/cli/plugins/stripe.d.ts +2 -0
- package/dist/cli/plugins/stripe.d.ts.map +1 -0
- package/dist/cli/plugins/stripe.js +118 -0
- package/dist/cli/plugins/stripe.js.map +1 -0
- package/dist/cli/plugins/tempo.d.ts +11 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -0
- package/dist/cli/plugins/tempo.js +706 -0
- package/dist/cli/plugins/tempo.js.map +1 -0
- package/dist/cli/utils.d.ts +93 -0
- package/dist/cli/utils.d.ts.map +1 -0
- package/dist/cli/utils.js +274 -0
- package/dist/cli/utils.js.map +1 -0
- package/dist/tempo/client/Methods.d.ts +1 -1
- package/dist/tempo/client/Session.d.ts +2 -2
- package/package.json +12 -1
- package/src/bin.ts +2 -2
- package/src/cli/account.ts +157 -0
- package/src/{cli.test.ts → cli/cli.test.ts} +107 -51
- package/src/cli/cli.ts +907 -0
- package/src/cli/config.test.ts +82 -0
- package/src/cli/config.ts +44 -0
- package/src/cli/internal.ts +72 -0
- package/src/cli/plugins/index.ts +3 -0
- package/src/cli/plugins/plugin.ts +73 -0
- package/src/cli/plugins/stripe.ts +143 -0
- package/src/cli/plugins/tempo.ts +842 -0
- package/src/cli/utils.ts +336 -0
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -1992
- package/dist/cli.js.map +0 -1
- package/src/cli.ts +0 -2178
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import * as child from 'node:child_process'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
import { Errors, z } from 'incur'
|
|
7
|
+
import { Base64 } from 'ox'
|
|
8
|
+
import type { Address } from 'viem'
|
|
9
|
+
import { createClient, http } from 'viem'
|
|
10
|
+
import { privateKeyToAccount } from 'viem/accounts'
|
|
11
|
+
import * as Credential from '../../Credential.js'
|
|
12
|
+
import { tempo as tempoMethods } from '../../tempo/client/index.js'
|
|
13
|
+
import type { SessionCredentialPayload } from '../../tempo/session/Types.js'
|
|
14
|
+
import { signVoucher } from '../../tempo/session/Voucher.js'
|
|
15
|
+
import { createDefaultStore, createKeychain, resolveAccountName } from '../account.js'
|
|
16
|
+
import {
|
|
17
|
+
fetchTokenInfo,
|
|
18
|
+
fmtBalance,
|
|
19
|
+
isTempoAccount,
|
|
20
|
+
isTestnet,
|
|
21
|
+
link,
|
|
22
|
+
pc,
|
|
23
|
+
resolveChain,
|
|
24
|
+
} from '../utils.js'
|
|
25
|
+
import { createPlugin, type Plugin } from './plugin.js'
|
|
26
|
+
|
|
27
|
+
const packageJson = createRequire(import.meta.url)('../../../package.json') as { name: string }
|
|
28
|
+
|
|
29
|
+
export function tempo() {
|
|
30
|
+
let _session:
|
|
31
|
+
| {
|
|
32
|
+
signVoucher(params: {
|
|
33
|
+
channelId: string
|
|
34
|
+
cumulativeAmount: bigint
|
|
35
|
+
escrowContract: Address
|
|
36
|
+
chainId: number
|
|
37
|
+
}): Promise<string>
|
|
38
|
+
source: string
|
|
39
|
+
}
|
|
40
|
+
| undefined
|
|
41
|
+
|
|
42
|
+
return createPlugin({
|
|
43
|
+
method: 'tempo',
|
|
44
|
+
|
|
45
|
+
async setup({ challenge, options, methodOpts }) {
|
|
46
|
+
const accountName = resolveAccountName(options.account)
|
|
47
|
+
const challengeRequest = challenge.request as Record<string, unknown>
|
|
48
|
+
const currency = challengeRequest.currency as string | undefined
|
|
49
|
+
|
|
50
|
+
let tokenSymbol = currency ?? ''
|
|
51
|
+
let tokenDecimals = (challengeRequest.decimals as number | undefined) ?? 6
|
|
52
|
+
let explorerUrl: string | undefined
|
|
53
|
+
|
|
54
|
+
let account: ReturnType<typeof privateKeyToAccount> | undefined
|
|
55
|
+
let client: ReturnType<typeof createClient> | undefined
|
|
56
|
+
let useTempoCliSign = false
|
|
57
|
+
|
|
58
|
+
const privateKey =
|
|
59
|
+
process.env.MPPX_PRIVATE_KEY?.trim() ||
|
|
60
|
+
(isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
|
|
61
|
+
|
|
62
|
+
if (!privateKey && isTempoAccount(accountName) && hasTempoCliSync()) {
|
|
63
|
+
useTempoCliSign = true
|
|
64
|
+
const tempoEntry = resolveTempoAccount(accountName)
|
|
65
|
+
if (tempoEntry) {
|
|
66
|
+
const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
|
|
67
|
+
client = createClient({
|
|
68
|
+
chain: await resolveChain({ rpcUrl }),
|
|
69
|
+
transport: http(rpcUrl),
|
|
70
|
+
})
|
|
71
|
+
explorerUrl = client.chain?.blockExplorers?.default?.url
|
|
72
|
+
const tokenInfo = currency
|
|
73
|
+
? await fetchTokenInfo(
|
|
74
|
+
client,
|
|
75
|
+
currency as Address,
|
|
76
|
+
tempoEntry.wallet_address as Address,
|
|
77
|
+
).catch(() => undefined)
|
|
78
|
+
: undefined
|
|
79
|
+
tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
|
|
80
|
+
tokenDecimals =
|
|
81
|
+
tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
|
|
82
|
+
}
|
|
83
|
+
} else if (!privateKey) {
|
|
84
|
+
const fallback = fallbackFromTempo()
|
|
85
|
+
if (fallback) {
|
|
86
|
+
const fallbackKey = await createKeychain(fallback).get()
|
|
87
|
+
if (fallbackKey) account = privateKeyToAccount(fallbackKey as `0x${string}`)
|
|
88
|
+
}
|
|
89
|
+
if (!account) {
|
|
90
|
+
if (options.account)
|
|
91
|
+
throw new Errors.IncurError({
|
|
92
|
+
code: 'ACCOUNT_NOT_FOUND',
|
|
93
|
+
message: `Account "${accountName}" not found.`,
|
|
94
|
+
exitCode: 69,
|
|
95
|
+
})
|
|
96
|
+
else
|
|
97
|
+
throw new Errors.IncurError({
|
|
98
|
+
code: 'ACCOUNT_NOT_FOUND',
|
|
99
|
+
message: 'No account found.',
|
|
100
|
+
exitCode: 69,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
} else account = privateKeyToAccount(privateKey as `0x${string}`)
|
|
104
|
+
|
|
105
|
+
if (!useTempoCliSign && account) {
|
|
106
|
+
const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
|
|
107
|
+
client = createClient({
|
|
108
|
+
chain: await resolveChain({ rpcUrl }),
|
|
109
|
+
transport: http(rpcUrl),
|
|
110
|
+
})
|
|
111
|
+
explorerUrl = client.chain?.blockExplorers?.default?.url
|
|
112
|
+
const tokenInfo = currency
|
|
113
|
+
? await fetchTokenInfo(client, currency as Address, account.address).catch(
|
|
114
|
+
() => undefined,
|
|
115
|
+
)
|
|
116
|
+
: undefined
|
|
117
|
+
tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
|
|
118
|
+
tokenDecimals =
|
|
119
|
+
tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (useTempoCliSign)
|
|
123
|
+
return {
|
|
124
|
+
tokenSymbol,
|
|
125
|
+
tokenDecimals,
|
|
126
|
+
explorerUrl,
|
|
127
|
+
methods: [],
|
|
128
|
+
async createCredential(response: Response) {
|
|
129
|
+
const wwwAuth = response.headers.get('www-authenticate')
|
|
130
|
+
if (!wwwAuth) throw new Error('No WWW-Authenticate header in 402 response.')
|
|
131
|
+
return tempoCliSign(wwwAuth)
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!account || !client)
|
|
136
|
+
throw new Errors.IncurError({
|
|
137
|
+
code: 'ACCOUNT_NOT_FOUND',
|
|
138
|
+
message: 'Tempo requires a configured account.',
|
|
139
|
+
exitCode: 69,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const tempoOpts = parseOptions(
|
|
143
|
+
z.object({
|
|
144
|
+
channel: z.optional(z.coerce.string()),
|
|
145
|
+
deposit: z.optional(z.union([z.string(), z.number()])),
|
|
146
|
+
}),
|
|
147
|
+
methodOpts,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const methods = tempoMethods({
|
|
151
|
+
account,
|
|
152
|
+
getClient: () => client!,
|
|
153
|
+
deposit: (() => {
|
|
154
|
+
if (challenge.intent !== 'session') return undefined
|
|
155
|
+
const suggestedDeposit = (challenge.request as Record<string, unknown>)
|
|
156
|
+
.suggestedDeposit as string | undefined
|
|
157
|
+
const cliDeposit = tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
|
|
158
|
+
const resolved =
|
|
159
|
+
suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
|
|
160
|
+
if (!resolved) {
|
|
161
|
+
throw new Errors.IncurError({
|
|
162
|
+
code: 'MISSING_DEPOSIT',
|
|
163
|
+
message:
|
|
164
|
+
'Session payment requires a deposit. Use -M deposit=<amount> or connect to testnet.',
|
|
165
|
+
exitCode: 2,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return resolved
|
|
169
|
+
})(),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const credentialContext = (() => {
|
|
173
|
+
if (!tempoOpts.channel) return undefined
|
|
174
|
+
const channelId = tempoOpts.channel
|
|
175
|
+
const saved = readChannelCumulative(channelId)
|
|
176
|
+
return {
|
|
177
|
+
channelId,
|
|
178
|
+
...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
|
|
179
|
+
}
|
|
180
|
+
})()
|
|
181
|
+
|
|
182
|
+
const chainId = client.chain!.id
|
|
183
|
+
|
|
184
|
+
// Store session support for use in lifecycle hooks
|
|
185
|
+
_session = {
|
|
186
|
+
async signVoucher({ channelId, cumulativeAmount, escrowContract, chainId }) {
|
|
187
|
+
return Credential.serialize({
|
|
188
|
+
challenge,
|
|
189
|
+
payload: {
|
|
190
|
+
action: 'voucher',
|
|
191
|
+
channelId,
|
|
192
|
+
cumulativeAmount: cumulativeAmount.toString(),
|
|
193
|
+
signature: await signVoucher(
|
|
194
|
+
client!,
|
|
195
|
+
account!,
|
|
196
|
+
{ channelId: channelId as `0x${string}`, cumulativeAmount },
|
|
197
|
+
escrowContract,
|
|
198
|
+
chainId,
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
source: `did:pkh:eip155:${chainId}:${account!.address}`,
|
|
202
|
+
})
|
|
203
|
+
},
|
|
204
|
+
source: `did:pkh:eip155:${chainId}:${account.address}`,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
tokenSymbol,
|
|
209
|
+
tokenDecimals,
|
|
210
|
+
explorerUrl,
|
|
211
|
+
methods: [...methods],
|
|
212
|
+
credentialContext,
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
prepareCredentialRequest({ challenge, headers }) {
|
|
217
|
+
if (challenge.intent === 'session') headers.Accept = 'text/event-stream'
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async handleResponse(ctx) {
|
|
221
|
+
if (ctx.challenge.intent !== 'session') return false
|
|
222
|
+
if (!_session) return false
|
|
223
|
+
|
|
224
|
+
const { challenge, credential, response, fetchUrl, fetchInit, verbose } = ctx
|
|
225
|
+
const { silent, confirmEnabled, tokenSymbol, tokenDecimals, explorerUrl, shownKeys } = ctx
|
|
226
|
+
const info = silent ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg)
|
|
227
|
+
|
|
228
|
+
const parsed = Credential.deserialize<SessionCredentialPayload>(credential)
|
|
229
|
+
const challengeRequest = challenge.request as Record<string, unknown>
|
|
230
|
+
const sessionMd = challengeRequest.methodDetails as
|
|
231
|
+
| { escrowContract?: string; chainId?: number }
|
|
232
|
+
| undefined
|
|
233
|
+
const channelId = parsed.payload.channelId
|
|
234
|
+
const escrowContract = sessionMd?.escrowContract as Address | undefined
|
|
235
|
+
const chainId = sessionMd?.chainId ?? 0
|
|
236
|
+
let cumulativeAmount =
|
|
237
|
+
'cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount
|
|
238
|
+
? BigInt(parsed.payload.cumulativeAmount)
|
|
239
|
+
: 0n
|
|
240
|
+
|
|
241
|
+
if (verbose >= 1) {
|
|
242
|
+
if (parsed.payload.action === 'open') {
|
|
243
|
+
const depositRaw = challengeRequest.suggestedDeposit as string | undefined
|
|
244
|
+
const depositDisplay = depositRaw
|
|
245
|
+
? ` ${pc.dim(`(deposit ${depositRaw} ${tokenSymbol})`)}`
|
|
246
|
+
: ''
|
|
247
|
+
const prefix = confirmEnabled ? '' : '\n'
|
|
248
|
+
info(
|
|
249
|
+
`${prefix}${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`,
|
|
250
|
+
)
|
|
251
|
+
} else {
|
|
252
|
+
const prefix = confirmEnabled ? '' : '\n'
|
|
253
|
+
info(`${prefix}${pc.dim(`Channel reused ${parsed.payload.channelId}`)}\n`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Handle non-SSE session response (server returned non-streaming)
|
|
258
|
+
let credentialResponse = response
|
|
259
|
+
if (
|
|
260
|
+
credentialResponse.ok &&
|
|
261
|
+
!credentialResponse.headers.get('Content-Type')?.includes('text/event-stream')
|
|
262
|
+
) {
|
|
263
|
+
if (parsed.payload.action === 'open' && 'cumulativeAmount' in parsed.payload) {
|
|
264
|
+
const tickAmount = BigInt(challengeRequest.amount as string)
|
|
265
|
+
cumulativeAmount = BigInt(parsed.payload.cumulativeAmount) + tickAmount
|
|
266
|
+
|
|
267
|
+
if (escrowContract) {
|
|
268
|
+
const voucherCred = await _session.signVoucher({
|
|
269
|
+
channelId,
|
|
270
|
+
cumulativeAmount,
|
|
271
|
+
escrowContract,
|
|
272
|
+
chainId,
|
|
273
|
+
})
|
|
274
|
+
credentialResponse = await globalThis.fetch(fetchUrl, {
|
|
275
|
+
...fetchInit,
|
|
276
|
+
headers: {
|
|
277
|
+
...(fetchInit.headers as Record<string, string>),
|
|
278
|
+
Accept: 'text/event-stream',
|
|
279
|
+
Authorization: voucherCred,
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Print receipt from initial response headers
|
|
287
|
+
const receiptHeader = credentialResponse.headers.get('Payment-Receipt')
|
|
288
|
+
if (receiptHeader) {
|
|
289
|
+
try {
|
|
290
|
+
const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record<string, unknown>
|
|
291
|
+
if (
|
|
292
|
+
typeof receiptJson.acceptedCumulative === 'string' &&
|
|
293
|
+
receiptJson.acceptedCumulative
|
|
294
|
+
) {
|
|
295
|
+
cumulativeAmount = BigInt(receiptJson.acceptedCumulative)
|
|
296
|
+
writeChannelCumulative(channelId, cumulativeAmount)
|
|
297
|
+
}
|
|
298
|
+
if (verbose >= 1)
|
|
299
|
+
printReceipt(receiptJson, {
|
|
300
|
+
info,
|
|
301
|
+
shownKeys,
|
|
302
|
+
tokenSymbol,
|
|
303
|
+
tokenDecimals,
|
|
304
|
+
explorerUrl,
|
|
305
|
+
handler: this,
|
|
306
|
+
prefix: '\n',
|
|
307
|
+
})
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const contentType = credentialResponse.headers.get('Content-Type') ?? ''
|
|
312
|
+
if (contentType.includes('text/event-stream')) {
|
|
313
|
+
await handleSseStream(credentialResponse, {
|
|
314
|
+
challenge,
|
|
315
|
+
channelId,
|
|
316
|
+
escrowContract,
|
|
317
|
+
chainId,
|
|
318
|
+
cumulativeAmount,
|
|
319
|
+
fetchUrl,
|
|
320
|
+
fetchInit,
|
|
321
|
+
session: _session,
|
|
322
|
+
info,
|
|
323
|
+
verbose,
|
|
324
|
+
shownKeys,
|
|
325
|
+
tokenSymbol,
|
|
326
|
+
tokenDecimals,
|
|
327
|
+
explorerUrl,
|
|
328
|
+
handler: this,
|
|
329
|
+
})
|
|
330
|
+
} else {
|
|
331
|
+
// Non-SSE: print body, then close channel
|
|
332
|
+
const body = (await credentialResponse.text()).replace(/\n+$/, '')
|
|
333
|
+
console.log(body)
|
|
334
|
+
|
|
335
|
+
if (channelId && escrowContract && chainId) {
|
|
336
|
+
if (confirmEnabled) info('\n')
|
|
337
|
+
if (confirmEnabled && !(await ctx.confirm('Close channel?', true))) {
|
|
338
|
+
if (verbose >= 1) info(`${pc.dim('Kept channel open.')}\n`)
|
|
339
|
+
} else {
|
|
340
|
+
await closeChannel({
|
|
341
|
+
channelId,
|
|
342
|
+
cumulativeAmount,
|
|
343
|
+
escrowContract,
|
|
344
|
+
chainId,
|
|
345
|
+
fetchUrl,
|
|
346
|
+
fetchInit,
|
|
347
|
+
session: _session,
|
|
348
|
+
info,
|
|
349
|
+
verbose,
|
|
350
|
+
tokenSymbol,
|
|
351
|
+
tokenDecimals,
|
|
352
|
+
explorerUrl,
|
|
353
|
+
confirmEnabled,
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return true
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
formatReceiptField(key, value) {
|
|
363
|
+
if (
|
|
364
|
+
(key === 'reference' || key === 'txHash') &&
|
|
365
|
+
typeof value === 'string' &&
|
|
366
|
+
value.startsWith('0x')
|
|
367
|
+
)
|
|
368
|
+
return undefined // let default explorer link handling apply
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- Session helpers ---
|
|
374
|
+
|
|
375
|
+
function printReceipt(
|
|
376
|
+
receiptJson: Record<string, unknown>,
|
|
377
|
+
opts: {
|
|
378
|
+
info: (msg: string) => void
|
|
379
|
+
shownKeys: Set<string>
|
|
380
|
+
tokenSymbol: string
|
|
381
|
+
tokenDecimals: number
|
|
382
|
+
explorerUrl?: string | undefined
|
|
383
|
+
handler: Plugin
|
|
384
|
+
prefix?: string | undefined
|
|
385
|
+
},
|
|
386
|
+
) {
|
|
387
|
+
opts.info(`${opts.prefix ?? ''}${pc.bold(pc.green('Payment Receipt'))}\n`)
|
|
388
|
+
const rows: [string, string][] = []
|
|
389
|
+
const skipRef =
|
|
390
|
+
receiptJson.channelId &&
|
|
391
|
+
receiptJson.reference &&
|
|
392
|
+
receiptJson.channelId === receiptJson.reference
|
|
393
|
+
const receiptBalanceKeys = new Set(['acceptedCumulative', 'spent'])
|
|
394
|
+
for (const [key, value] of Object.entries(receiptJson)) {
|
|
395
|
+
if (value === undefined || opts.shownKeys.has(key)) continue
|
|
396
|
+
if (key === 'reference' && skipRef) continue
|
|
397
|
+
const formatted = opts.handler.formatReceiptField?.(key, value)
|
|
398
|
+
if (formatted !== undefined) {
|
|
399
|
+
rows.push([key, formatted])
|
|
400
|
+
} else if (receiptBalanceKeys.has(key) && typeof value === 'string') {
|
|
401
|
+
rows.push([
|
|
402
|
+
key,
|
|
403
|
+
`${value} ${pc.dim(`(${fmtBalance(BigInt(value), opts.tokenSymbol, opts.tokenDecimals)})`)}`,
|
|
404
|
+
])
|
|
405
|
+
} else if (
|
|
406
|
+
(key === 'reference' || key === 'txHash') &&
|
|
407
|
+
typeof value === 'string' &&
|
|
408
|
+
opts.explorerUrl
|
|
409
|
+
) {
|
|
410
|
+
rows.push([key, link(`${opts.explorerUrl}/tx/${value}`, value)])
|
|
411
|
+
} else rows.push([key, String(value)])
|
|
412
|
+
}
|
|
413
|
+
rows.sort(([a], [b]) => a.localeCompare(b))
|
|
414
|
+
const pad = Math.max(...rows.map(([k]) => k.length))
|
|
415
|
+
for (const [label, value] of rows) opts.info(` ${pc.dim(label.padEnd(pad))} ${value}\n`)
|
|
416
|
+
if (opts.prefix) opts.info('\n')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function handleSseStream(
|
|
420
|
+
response: Response,
|
|
421
|
+
opts: {
|
|
422
|
+
challenge: import('../../Challenge.js').Challenge
|
|
423
|
+
channelId: string
|
|
424
|
+
escrowContract: Address | undefined
|
|
425
|
+
chainId: number
|
|
426
|
+
cumulativeAmount: bigint
|
|
427
|
+
fetchUrl: string
|
|
428
|
+
fetchInit: RequestInit
|
|
429
|
+
session: {
|
|
430
|
+
signVoucher(params: {
|
|
431
|
+
channelId: string
|
|
432
|
+
cumulativeAmount: bigint
|
|
433
|
+
escrowContract: Address
|
|
434
|
+
chainId: number
|
|
435
|
+
}): Promise<string>
|
|
436
|
+
}
|
|
437
|
+
info: (msg: string) => void
|
|
438
|
+
verbose: number
|
|
439
|
+
shownKeys: Set<string>
|
|
440
|
+
tokenSymbol: string
|
|
441
|
+
tokenDecimals: number
|
|
442
|
+
explorerUrl?: string | undefined
|
|
443
|
+
handler: Plugin
|
|
444
|
+
},
|
|
445
|
+
) {
|
|
446
|
+
let cumulativeAmount = opts.cumulativeAmount
|
|
447
|
+
|
|
448
|
+
const reader = response.body?.getReader()
|
|
449
|
+
if (!reader) throw new Error('No response body')
|
|
450
|
+
|
|
451
|
+
const decoder = new TextDecoder()
|
|
452
|
+
let buffer = ''
|
|
453
|
+
let currentEvent = ''
|
|
454
|
+
|
|
455
|
+
const termBg = opts.verbose ? await detectTerminalBg() : undefined
|
|
456
|
+
const chunkBgs = (() => {
|
|
457
|
+
if (!termBg || !pc.isColorSupported) return undefined
|
|
458
|
+
const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
|
|
459
|
+
const isDark = 0.299 * termBg.r + 0.587 * termBg.g + 0.114 * termBg.b < 128
|
|
460
|
+
const offset = isDark ? 1 : -1
|
|
461
|
+
const bgRgb = (d: number) => (s: string) => {
|
|
462
|
+
const r = clamp(termBg.r + d * offset)
|
|
463
|
+
const g = clamp(termBg.g + d * offset)
|
|
464
|
+
const b = clamp(termBg.b + d * offset)
|
|
465
|
+
return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`
|
|
466
|
+
}
|
|
467
|
+
return [bgRgb(12), bgRgb(24)] as const
|
|
468
|
+
})()
|
|
469
|
+
let chunkIdx = 0
|
|
470
|
+
|
|
471
|
+
const writeContent = (chunk: string) => {
|
|
472
|
+
if (chunkBgs) {
|
|
473
|
+
const bgFn = chunkBgs[chunkIdx % chunkBgs.length]!
|
|
474
|
+
process.stdout.write(chunk.replace(/[^\n]+/g, (m) => bgFn(m)))
|
|
475
|
+
chunkIdx++
|
|
476
|
+
} else {
|
|
477
|
+
process.stdout.write(chunk)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const processLines = async (lines: string[]) => {
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
if (line.startsWith('event: ')) {
|
|
484
|
+
currentEvent = line.slice(7).trim()
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if (!line.startsWith('data: ')) {
|
|
488
|
+
if (line === '') currentEvent = ''
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
const data = line.slice(6)
|
|
492
|
+
if (data.trim() === '[DONE]') continue
|
|
493
|
+
if (
|
|
494
|
+
currentEvent === 'payment-need-voucher' &&
|
|
495
|
+
opts.channelId &&
|
|
496
|
+
opts.escrowContract &&
|
|
497
|
+
opts.chainId
|
|
498
|
+
) {
|
|
499
|
+
try {
|
|
500
|
+
const event = JSON.parse(data) as {
|
|
501
|
+
channelId: string
|
|
502
|
+
requiredCumulative: string
|
|
503
|
+
}
|
|
504
|
+
const required = BigInt(event.requiredCumulative)
|
|
505
|
+
cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
|
|
506
|
+
|
|
507
|
+
const voucherCred = await opts.session.signVoucher({
|
|
508
|
+
channelId: opts.channelId,
|
|
509
|
+
cumulativeAmount,
|
|
510
|
+
escrowContract: opts.escrowContract,
|
|
511
|
+
chainId: opts.chainId,
|
|
512
|
+
})
|
|
513
|
+
await globalThis.fetch(opts.fetchUrl, {
|
|
514
|
+
method: 'POST',
|
|
515
|
+
headers: { Authorization: voucherCred },
|
|
516
|
+
})
|
|
517
|
+
} catch (e) {
|
|
518
|
+
opts.info(pc.dim(pc.yellow(` [voucher failed: ${e instanceof Error ? e.message : e}]`)))
|
|
519
|
+
}
|
|
520
|
+
currentEvent = ''
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
if (currentEvent === 'payment-receipt') {
|
|
524
|
+
if (opts.verbose >= 1) {
|
|
525
|
+
try {
|
|
526
|
+
const receipt = JSON.parse(data) as Record<string, unknown>
|
|
527
|
+
printReceipt(receipt, {
|
|
528
|
+
info: opts.info,
|
|
529
|
+
shownKeys: opts.shownKeys,
|
|
530
|
+
tokenSymbol: opts.tokenSymbol,
|
|
531
|
+
tokenDecimals: opts.tokenDecimals,
|
|
532
|
+
explorerUrl: opts.explorerUrl,
|
|
533
|
+
handler: opts.handler,
|
|
534
|
+
prefix: '\n\n',
|
|
535
|
+
})
|
|
536
|
+
} catch {}
|
|
537
|
+
}
|
|
538
|
+
currentEvent = ''
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
if (data.length === 0) {
|
|
542
|
+
writeContent('\n')
|
|
543
|
+
} else {
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse(data) as {
|
|
546
|
+
token?: string
|
|
547
|
+
choices?: { delta?: { content?: string } }[]
|
|
548
|
+
}
|
|
549
|
+
writeContent(parsed.token ?? parsed.choices?.[0]?.delta?.content ?? data)
|
|
550
|
+
} catch {
|
|
551
|
+
writeContent(data)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
currentEvent = ''
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
while (true) {
|
|
559
|
+
const { done, value } = await reader.read()
|
|
560
|
+
if (done) break
|
|
561
|
+
buffer += decoder.decode(value, { stream: true })
|
|
562
|
+
const lines = buffer.split('\n')
|
|
563
|
+
buffer = lines.pop()!
|
|
564
|
+
await processLines(lines)
|
|
565
|
+
}
|
|
566
|
+
if (buffer.trim()) await processLines([buffer])
|
|
567
|
+
|
|
568
|
+
// Close channel after SSE stream ends
|
|
569
|
+
if (opts.channelId && opts.escrowContract && opts.chainId) {
|
|
570
|
+
await closeChannel({
|
|
571
|
+
channelId: opts.channelId,
|
|
572
|
+
cumulativeAmount,
|
|
573
|
+
escrowContract: opts.escrowContract,
|
|
574
|
+
chainId: opts.chainId,
|
|
575
|
+
fetchUrl: opts.fetchUrl,
|
|
576
|
+
fetchInit: opts.fetchInit,
|
|
577
|
+
session: opts.session,
|
|
578
|
+
info: opts.info,
|
|
579
|
+
verbose: opts.verbose,
|
|
580
|
+
tokenSymbol: opts.tokenSymbol,
|
|
581
|
+
tokenDecimals: opts.tokenDecimals,
|
|
582
|
+
explorerUrl: opts.explorerUrl,
|
|
583
|
+
confirmEnabled: false,
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function closeChannel(opts: {
|
|
589
|
+
channelId: string
|
|
590
|
+
cumulativeAmount: bigint
|
|
591
|
+
escrowContract: Address
|
|
592
|
+
chainId: number
|
|
593
|
+
fetchUrl: string
|
|
594
|
+
fetchInit: RequestInit
|
|
595
|
+
session: {
|
|
596
|
+
signVoucher(params: {
|
|
597
|
+
channelId: string
|
|
598
|
+
cumulativeAmount: bigint
|
|
599
|
+
escrowContract: Address
|
|
600
|
+
chainId: number
|
|
601
|
+
}): Promise<string>
|
|
602
|
+
}
|
|
603
|
+
info: (msg: string) => void
|
|
604
|
+
verbose: number
|
|
605
|
+
tokenSymbol: string
|
|
606
|
+
tokenDecimals: number
|
|
607
|
+
explorerUrl?: string | undefined
|
|
608
|
+
confirmEnabled: boolean
|
|
609
|
+
}) {
|
|
610
|
+
const closeCred = await opts.session.signVoucher({
|
|
611
|
+
channelId: opts.channelId,
|
|
612
|
+
cumulativeAmount: opts.cumulativeAmount,
|
|
613
|
+
escrowContract: opts.escrowContract,
|
|
614
|
+
chainId: opts.chainId,
|
|
615
|
+
})
|
|
616
|
+
const closeRes = await globalThis.fetch(opts.fetchUrl, {
|
|
617
|
+
...opts.fetchInit,
|
|
618
|
+
headers: {
|
|
619
|
+
...(opts.fetchInit.headers as Record<string, string>),
|
|
620
|
+
Authorization: closeCred,
|
|
621
|
+
},
|
|
622
|
+
})
|
|
623
|
+
if (closeRes.ok) {
|
|
624
|
+
deleteChannelState(opts.channelId)
|
|
625
|
+
if (opts.verbose >= 1) {
|
|
626
|
+
const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
|
|
627
|
+
let closeTxHash: string | undefined
|
|
628
|
+
if (closeReceiptHeader) {
|
|
629
|
+
try {
|
|
630
|
+
const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<string, unknown>
|
|
631
|
+
if (typeof r.txHash === 'string') closeTxHash = r.txHash
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
634
|
+
const txInfo =
|
|
635
|
+
closeTxHash && opts.explorerUrl
|
|
636
|
+
? ` ${pc.dim(link(`${opts.explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
|
|
637
|
+
: ''
|
|
638
|
+
opts.info(
|
|
639
|
+
`${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(opts.cumulativeAmount, opts.tokenSymbol, opts.tokenDecimals)}.`)}${txInfo}\n`,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
const closeBody = await closeRes.text().catch(() => '')
|
|
644
|
+
opts.info(`\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`)
|
|
645
|
+
opts.info(
|
|
646
|
+
`${pc.dim(` channelId: ${opts.channelId}`)}\n` +
|
|
647
|
+
`${pc.dim(` cumulativeAmount: ${opts.cumulativeAmount}`)}\n` +
|
|
648
|
+
`${pc.dim(` escrowContract: ${opts.escrowContract}`)}\n` +
|
|
649
|
+
`${pc.dim(` chainId: ${opts.chainId}`)}\n` +
|
|
650
|
+
`${pc.dim(` response: ${closeBody || '(empty)'}`)}\n`,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function detectTerminalBg(
|
|
656
|
+
timeoutMs = 100,
|
|
657
|
+
): Promise<{ r: number; g: number; b: number } | undefined> {
|
|
658
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(undefined)
|
|
659
|
+
return new Promise((resolve) => {
|
|
660
|
+
const wasRaw = process.stdin.isRaw
|
|
661
|
+
let buf = ''
|
|
662
|
+
const cleanup = () => {
|
|
663
|
+
clearTimeout(timer)
|
|
664
|
+
process.stdin.removeListener('data', onData)
|
|
665
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw ?? false)
|
|
666
|
+
process.stdin.pause()
|
|
667
|
+
}
|
|
668
|
+
const timer = setTimeout(() => {
|
|
669
|
+
cleanup()
|
|
670
|
+
resolve(undefined)
|
|
671
|
+
}, timeoutMs)
|
|
672
|
+
const onData = (data: Buffer) => {
|
|
673
|
+
buf += data.toString()
|
|
674
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence for terminal background detection
|
|
675
|
+
const match = buf.match(/\x1b\]11;rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i)
|
|
676
|
+
if (!match) return
|
|
677
|
+
cleanup()
|
|
678
|
+
const parse = (hex: string) => Number.parseInt(hex.slice(0, 2), 16)
|
|
679
|
+
resolve({ r: parse(match[1]!), g: parse(match[2]!), b: parse(match[3]!) })
|
|
680
|
+
}
|
|
681
|
+
process.stdin.setRawMode(true)
|
|
682
|
+
process.stdin.resume()
|
|
683
|
+
process.stdin.on('data', onData)
|
|
684
|
+
process.stdout.write('\x1b]11;?\x07')
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// --- Account helpers ---
|
|
689
|
+
|
|
690
|
+
function parseOptions<const schema extends z.ZodType>(
|
|
691
|
+
schema: schema,
|
|
692
|
+
rawOptions: unknown,
|
|
693
|
+
): z.output<schema> {
|
|
694
|
+
const result = schema.safeParse(rawOptions ?? {})
|
|
695
|
+
if (result.success) return result.data
|
|
696
|
+
const summary = result.error.issues
|
|
697
|
+
.map((issue) => {
|
|
698
|
+
const path = issue.path.length ? issue.path.join('.') : 'options'
|
|
699
|
+
return `${path}: ${issue.message}`
|
|
700
|
+
})
|
|
701
|
+
.join(', ')
|
|
702
|
+
throw new Error(`Invalid CLI options (${summary})`)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function channelStateDir() {
|
|
706
|
+
return path.join(
|
|
707
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
708
|
+
'mppx',
|
|
709
|
+
'channels',
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function readChannelCumulative(channelId: string): bigint | undefined {
|
|
714
|
+
try {
|
|
715
|
+
const raw = fs.readFileSync(path.join(channelStateDir(), channelId), 'utf-8').trim()
|
|
716
|
+
return raw ? BigInt(raw) : undefined
|
|
717
|
+
} catch {
|
|
718
|
+
return undefined
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function writeChannelCumulative(channelId: string, cumulative: bigint): void {
|
|
723
|
+
const dir = channelStateDir()
|
|
724
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
725
|
+
fs.writeFileSync(path.join(dir, channelId), cumulative.toString(), 'utf-8')
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function deleteChannelState(channelId: string): void {
|
|
729
|
+
try {
|
|
730
|
+
fs.unlinkSync(path.join(channelStateDir(), channelId))
|
|
731
|
+
} catch {}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function tempoKeystorePath(): string {
|
|
735
|
+
const platform = os.platform()
|
|
736
|
+
if (platform === 'darwin')
|
|
737
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'tempo', 'wallet', 'keys.toml')
|
|
738
|
+
return path.join(
|
|
739
|
+
process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'),
|
|
740
|
+
'tempo',
|
|
741
|
+
'wallet',
|
|
742
|
+
'keys.toml',
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
interface TempoKeyEntry {
|
|
747
|
+
wallet_type: string
|
|
748
|
+
wallet_address: string
|
|
749
|
+
chain_id: number
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export function readTempoKeystore(): TempoKeyEntry[] {
|
|
753
|
+
try {
|
|
754
|
+
const raw = fs.readFileSync(tempoKeystorePath(), 'utf-8')
|
|
755
|
+
const entries: TempoKeyEntry[] = []
|
|
756
|
+
let current: Partial<TempoKeyEntry> | undefined
|
|
757
|
+
for (const line of raw.split('\n')) {
|
|
758
|
+
const trimmed = line.trim()
|
|
759
|
+
if (trimmed === '[[keys]]') {
|
|
760
|
+
if (current?.wallet_address) entries.push(current as TempoKeyEntry)
|
|
761
|
+
current = { wallet_type: 'local', wallet_address: '', chain_id: 0 }
|
|
762
|
+
continue
|
|
763
|
+
}
|
|
764
|
+
if (!current) continue
|
|
765
|
+
const m = trimmed.match(/^(\w+)\s*=\s*"?([^"]*)"?$/)
|
|
766
|
+
if (!m) continue
|
|
767
|
+
const [, key, value] = m
|
|
768
|
+
if (key === 'wallet_type') current.wallet_type = value!
|
|
769
|
+
else if (key === 'wallet_address') current.wallet_address = value!
|
|
770
|
+
else if (key === 'chain_id') current.chain_id = Number.parseInt(value!, 10)
|
|
771
|
+
}
|
|
772
|
+
if (current?.wallet_address) entries.push(current as TempoKeyEntry)
|
|
773
|
+
return entries
|
|
774
|
+
} catch {
|
|
775
|
+
return []
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function resolveTempoAccount(accountName: string): TempoKeyEntry | undefined {
|
|
780
|
+
const entries = readTempoKeystore()
|
|
781
|
+
if (entries.length === 0) return undefined
|
|
782
|
+
const suffix = accountName.slice('tempo:'.length)
|
|
783
|
+
if (suffix === 'default' || suffix === '') return entries[0]
|
|
784
|
+
const idx = Number.parseInt(suffix, 10)
|
|
785
|
+
if (!Number.isNaN(idx) && idx >= 0 && idx < entries.length) return entries[idx]
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
let _tempoCliAvailable: boolean | undefined
|
|
790
|
+
function hasTempoCliSync(): boolean {
|
|
791
|
+
if (_tempoCliAvailable !== undefined) return _tempoCliAvailable
|
|
792
|
+
try {
|
|
793
|
+
child.execFileSync('which', ['tempo'], { stdio: 'ignore' })
|
|
794
|
+
_tempoCliAvailable = true
|
|
795
|
+
} catch {
|
|
796
|
+
_tempoCliAvailable = false
|
|
797
|
+
}
|
|
798
|
+
return _tempoCliAvailable
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function tempoCliSign(wwwAuth: string): Promise<string> {
|
|
802
|
+
return new Promise((resolve, reject) => {
|
|
803
|
+
child.execFile('tempo', ['mpp', 'sign', '--challenge', wwwAuth], (error, stdout, stderr) => {
|
|
804
|
+
if (error) {
|
|
805
|
+
const msg = stderr?.trim() || error.message
|
|
806
|
+
reject(new Error(`tempo mpp sign failed: ${msg}`))
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
const trimmed = stdout.trim()
|
|
810
|
+
if (!trimmed) {
|
|
811
|
+
reject(new Error('tempo mpp sign returned empty output'))
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
resolve(trimmed)
|
|
815
|
+
})
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function fallbackFromTempo(): string | undefined {
|
|
820
|
+
const store = createDefaultStore()
|
|
821
|
+
const currentDefault = store.get()
|
|
822
|
+
if (!isTempoAccount(currentDefault)) return undefined
|
|
823
|
+
if (hasTempoCliSync()) return undefined
|
|
824
|
+
const platform = os.platform()
|
|
825
|
+
if (platform === 'darwin') {
|
|
826
|
+
try {
|
|
827
|
+
const stdout = child.execFileSync('security', ['dump-keychain'], { encoding: 'utf-8' })
|
|
828
|
+
const mppxAccounts: string[] = []
|
|
829
|
+
for (const block of stdout.split('keychain:')) {
|
|
830
|
+
const serviceMatch = block.match(/"svce"<blob>="([^"]*)"/)
|
|
831
|
+
const accountMatch = block.match(/"acct"<blob>="([^"]*)"/)
|
|
832
|
+
if (serviceMatch?.[1] === packageJson.name && accountMatch?.[1])
|
|
833
|
+
mppxAccounts.push(accountMatch[1])
|
|
834
|
+
}
|
|
835
|
+
if (mppxAccounts.length > 0) {
|
|
836
|
+
store.set(mppxAccounts[0]!)
|
|
837
|
+
return mppxAccounts[0]!
|
|
838
|
+
}
|
|
839
|
+
} catch {}
|
|
840
|
+
}
|
|
841
|
+
return undefined
|
|
842
|
+
}
|