@routedock/routedock 0.1.0

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.
@@ -0,0 +1,44 @@
1
+ import { Keypair } from '@stellar/stellar-sdk'
2
+ import { stellar } from '@stellar/mpp/charge/client'
3
+ import { Mppx } from 'mppx/client'
4
+ import type { RouteDockManifest, PaymentResult } from '../types.js'
5
+ import { RouteDockManifestError } from '../types.js'
6
+
7
+ export class MppChargeClient {
8
+ constructor(
9
+ private readonly keypair: Keypair,
10
+ private readonly network: 'testnet' | 'mainnet',
11
+ ) {}
12
+
13
+ async pay(url: string, manifest: RouteDockManifest): Promise<PaymentResult> {
14
+ const pricing = manifest.pricing['mpp-charge']
15
+ if (!pricing) {
16
+ throw new RouteDockManifestError('manifest.pricing.mpp-charge missing')
17
+ }
18
+
19
+ let txHash: string | null = null
20
+
21
+ const mppx = Mppx.create({
22
+ polyfill: false,
23
+ methods: [
24
+ stellar.charge({
25
+ keypair: this.keypair,
26
+ mode: 'pull',
27
+ onProgress(event) {
28
+ if (event.type === 'paid') {
29
+ txHash = event.hash
30
+ }
31
+ },
32
+ }),
33
+ ],
34
+ })
35
+
36
+ const response = await mppx.fetch(url)
37
+ if (!response.ok) {
38
+ throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`)
39
+ }
40
+
41
+ const data = await response.json()
42
+ return { data, txHash, mode: 'mpp-charge', amount: pricing.amount, timestamp: Date.now() }
43
+ }
44
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * MppSessionClient — uses @stellar/mpp channel/client for off-chain
3
+ * cumulative commitments against a pre-deployed one-way-channel contract.
4
+ *
5
+ * The channel is deployed and funded before the agent runs. The client
6
+ * signs ed25519 commitments per the one-way-channel protocol — the
7
+ * @stellar/mpp library handles the 402 challenge-response cycle.
8
+ */
9
+ import { Keypair } from '@stellar/stellar-sdk'
10
+ import { stellar } from '@stellar/mpp/channel/client'
11
+ import { Mppx } from 'mppx/client'
12
+ import type {
13
+ RouteDockManifest,
14
+ SessionHandle,
15
+ SessionCloseResult,
16
+ } from '../types.js'
17
+ import {
18
+ RouteDockManifestError,
19
+ RouteDockSessionError,
20
+ } from '../types.js'
21
+
22
+ const MIN_REFUND_WAITING_PERIOD = 17_280
23
+
24
+ export class MppSessionClient {
25
+ constructor(
26
+ private readonly keypair: Keypair,
27
+ private readonly network: 'testnet' | 'mainnet',
28
+ ) {}
29
+
30
+ async openSession(
31
+ url: string,
32
+ manifest: RouteDockManifest,
33
+ commitmentSecret: string,
34
+ ): Promise<SessionHandle> {
35
+ const pricing = manifest.pricing['mpp-session']
36
+ if (!pricing) {
37
+ throw new RouteDockManifestError('manifest.pricing.mpp-session missing')
38
+ }
39
+
40
+ const refundPeriod = pricing.refund_waiting_period_ledgers
41
+ if (refundPeriod < MIN_REFUND_WAITING_PERIOD) {
42
+ throw new RouteDockManifestError(
43
+ `refund_waiting_period_ledgers ${refundPeriod} < minimum ${MIN_REFUND_WAITING_PERIOD}`,
44
+ )
45
+ }
46
+
47
+ const commitmentKey = Keypair.fromSecret(commitmentSecret)
48
+ const channelContract = pricing.channel_contract
49
+ const agentPublicKey = this.keypair.publicKey()
50
+
51
+ let currentCumulative = 0n
52
+ let vouchersIssued = 0
53
+
54
+ const mppx = Mppx.create({
55
+ polyfill: false,
56
+ methods: [
57
+ stellar.channel({
58
+ commitmentKey,
59
+ sourceAccount: this.keypair.publicKey(),
60
+ onProgress(event) {
61
+ if (event.type === 'signed') {
62
+ currentCumulative = BigInt(event.cumulativeAmount)
63
+ }
64
+ },
65
+ }),
66
+ ],
67
+ })
68
+
69
+ const openTxHash = channelContract
70
+
71
+ const handle: SessionHandle = {
72
+ channelId: channelContract,
73
+ openTxHash,
74
+
75
+ async *stream(): AsyncIterable<unknown> {
76
+ while (true) {
77
+ let resp: globalThis.Response | null = null
78
+ for (let attempt = 0; attempt < 3; attempt++) {
79
+ try {
80
+ resp = await mppx.fetch(url)
81
+ if (resp.ok) break
82
+ resp = null
83
+ } catch {
84
+ if (attempt === 2) throw new RouteDockSessionError('Voucher request failed after 3 retries')
85
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)))
86
+ }
87
+ }
88
+ if (!resp || !resp.ok) {
89
+ throw new RouteDockSessionError(`Voucher request failed: HTTP ${resp?.status ?? 'unknown'}`)
90
+ }
91
+ vouchersIssued++
92
+ yield await resp.json()
93
+ }
94
+ },
95
+
96
+ async close(): Promise<SessionCloseResult> {
97
+ const { rpc: rpcMod, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk')
98
+ const rpcUrl = 'https://soroban-testnet.stellar.org'
99
+ const server = new rpcMod.Server(rpcUrl)
100
+ const contract = new Contract(channelContract)
101
+ const passphrase = 'Test SDF Network ; September 2015'
102
+
103
+ const account = await server.getAccount(agentPublicKey)
104
+ const simTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase })
105
+ .addOperation(contract.call('prepare_commitment', nativeToScVal(currentCumulative, { type: 'i128' })))
106
+ .setTimeout(30)
107
+ .build()
108
+ const simResult = await server.simulateTransaction(simTx)
109
+ if (rpcMod.Api.isSimulationError(simResult)) {
110
+ throw new RouteDockSessionError(`prepare_commitment simulation failed: ${simResult.error}`)
111
+ }
112
+ const commitmentBytes = (simResult as { result?: { retval?: { bytes: () => Buffer } } }).result?.retval?.bytes()
113
+ if (!commitmentBytes) throw new RouteDockSessionError('prepare_commitment returned no bytes')
114
+
115
+ const signature = commitmentKey.sign(Buffer.from(commitmentBytes))
116
+
117
+ const closeResp = await fetch(url, {
118
+ method: 'DELETE',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({
121
+ amount: currentCumulative.toString(),
122
+ signature: Buffer.from(signature).toString('hex'),
123
+ }),
124
+ })
125
+
126
+ if (!closeResp.ok) {
127
+ throw new RouteDockSessionError(`Channel close failed: HTTP ${closeResp.status}`)
128
+ }
129
+
130
+ const closeData = (await closeResp.json()) as { closeTxHash?: string }
131
+ const closeTxHash = closeData.closeTxHash ?? null
132
+
133
+ if (!closeTxHash) {
134
+ throw new RouteDockSessionError('Channel close response missing closeTxHash')
135
+ }
136
+
137
+ const totalPaid = (Number(currentCumulative) / 1e7).toFixed(7)
138
+ return { closeTxHash, totalPaid, vouchersIssued }
139
+ },
140
+ }
141
+
142
+ return handle
143
+ }
144
+ }
@@ -0,0 +1,124 @@
1
+ import { Keypair } from '@stellar/stellar-sdk'
2
+ import { fetchManifest, selectMode, type ModeSelectOptions } from './ModeRouter.js'
3
+ import { X402Client } from './x402Client.js'
4
+ import { MppChargeClient } from './MppChargeClient.js'
5
+ import { MppSessionClient } from './MppSessionClient.js'
6
+ import type { PaymentResult, SessionHandle } from '../types.js'
7
+ import {
8
+ RouteDockManifestError,
9
+ RouteDockPolicyRejectedError,
10
+ } from '../types.js'
11
+
12
+ export interface SpendCap {
13
+ /** Maximum total USDC spend per day (decimal string, e.g. "1.00") */
14
+ daily: string
15
+ asset: 'USDC'
16
+ }
17
+
18
+ export interface RouteDockClientConfig {
19
+ /** Stellar keypair or raw secret key (S...) */
20
+ wallet: Keypair | string
21
+ network: 'testnet' | 'mainnet'
22
+ /** Optional local daily spend cap — checked before every payment */
23
+ spendCap?: SpendCap
24
+ /** Ed25519 secret key (S...) for signing channel commitments. Required for mpp-session. */
25
+ commitmentSecret?: string | undefined
26
+ }
27
+
28
+ export class RouteDockClient {
29
+ private readonly keypair: Keypair
30
+ private readonly network: 'testnet' | 'mainnet'
31
+ private readonly spendCap: SpendCap | undefined
32
+ private readonly commitmentSecret: string | undefined
33
+
34
+ /** Local daily accumulator keyed by YYYY-MM-DD */
35
+ private dailySpend: { date: string; total: number } = { date: '', total: 0 }
36
+
37
+ private readonly x402: X402Client
38
+ private readonly charge: MppChargeClient
39
+ private readonly session: MppSessionClient
40
+
41
+ constructor(config: RouteDockClientConfig) {
42
+ this.keypair =
43
+ typeof config.wallet === 'string' ? Keypair.fromSecret(config.wallet) : config.wallet
44
+ this.network = config.network
45
+ this.spendCap = config.spendCap
46
+ this.commitmentSecret = config.commitmentSecret
47
+
48
+ const secretKey = this.keypair.secret()
49
+ this.x402 = new X402Client(secretKey, this.network)
50
+ this.charge = new MppChargeClient(this.keypair, this.network)
51
+ this.session = new MppSessionClient(this.keypair, this.network)
52
+ }
53
+
54
+ /**
55
+ * Pay for one request at `url`. Fetches manifest, selects payment mode,
56
+ * checks local spend cap, executes payment, returns result.
57
+ */
58
+ async pay(url: string, options?: ModeSelectOptions): Promise<PaymentResult> {
59
+ const baseUrl = new URL(url).origin
60
+ const manifest = await fetchManifest(baseUrl)
61
+ const mode = selectMode(manifest, options)
62
+
63
+ let result: PaymentResult
64
+
65
+ switch (mode) {
66
+ case 'x402':
67
+ result = await this.x402.pay(url, manifest)
68
+ break
69
+ case 'mpp-charge':
70
+ result = await this.charge.pay(url, manifest)
71
+ break
72
+ case 'mpp-session':
73
+ throw new RouteDockManifestError(
74
+ 'Use client.openSession() for mpp-session mode — client.pay() only handles discrete payments',
75
+ )
76
+ default:
77
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode as string}`)
78
+ }
79
+
80
+ this._checkAndRecordSpend(result.amount)
81
+ return result
82
+ }
83
+
84
+ /**
85
+ * Open a sustained MPP session at `url`. Verifies mpp-session is supported
86
+ * before opening a channel. Returns a SessionHandle for streaming + closing.
87
+ */
88
+ async openSession(url: string): Promise<SessionHandle> {
89
+ const baseUrl = new URL(url).origin
90
+ const manifest = await fetchManifest(baseUrl)
91
+
92
+ if (!manifest.modes.includes('mpp-session')) {
93
+ throw new RouteDockManifestError(
94
+ `Provider at ${baseUrl} does not support mpp-session mode`,
95
+ )
96
+ }
97
+
98
+ if (!this.commitmentSecret) {
99
+ throw new RouteDockManifestError(
100
+ 'commitmentSecret is required in RouteDockClientConfig for mpp-session mode',
101
+ )
102
+ }
103
+
104
+ return this.session.openSession(url, manifest, this.commitmentSecret)
105
+ }
106
+
107
+ /** Check local daily cap and record the spend. Throws if cap exceeded. */
108
+ private _checkAndRecordSpend(amount: string): void {
109
+ if (!this.spendCap) return
110
+
111
+ const today = new Date().toISOString().slice(0, 10)
112
+ if (this.dailySpend.date !== today) {
113
+ this.dailySpend = { date: today, total: 0 }
114
+ }
115
+
116
+ const amountNum = parseFloat(amount)
117
+ const capNum = parseFloat(this.spendCap.daily)
118
+ if (this.dailySpend.total + amountNum > capNum) {
119
+ throw new RouteDockPolicyRejectedError('local_daily_cap_exceeded')
120
+ }
121
+
122
+ this.dailySpend.total += amountNum
123
+ }
124
+ }
@@ -0,0 +1,8 @@
1
+ // Client-only exports (no provider/Express)
2
+ export * from '../types.js'
3
+ export * from './RouteDockClient.js'
4
+ export * from './ModeRouter.js'
5
+ export * from './x402Client.js'
6
+ export * from './MppChargeClient.js'
7
+ export * from './MppSessionClient.js'
8
+ export * from '../store/SessionStore.js'
@@ -0,0 +1,84 @@
1
+ import { createEd25519Signer } from '@x402/stellar'
2
+ import { ExactStellarScheme } from '@x402/stellar/exact/client'
3
+ import { x402Client, x402HTTPClient } from '@x402/core/client'
4
+ import {
5
+ decodePaymentRequiredHeader,
6
+ decodePaymentResponseHeader,
7
+ encodePaymentSignatureHeader,
8
+ } from '@x402/core/http'
9
+ import type { Network as X402Network } from '@x402/core/types'
10
+ import type { RouteDockManifest, PaymentResult } from '../types.js'
11
+ import { RouteDockManifestError } from '../types.js'
12
+
13
+ type Network = 'testnet' | 'mainnet'
14
+
15
+ const CAIP2: Record<Network, X402Network> = {
16
+ testnet: 'stellar:testnet',
17
+ mainnet: 'stellar:pubnet',
18
+ }
19
+
20
+ export class X402Client {
21
+ private readonly httpClient: x402HTTPClient
22
+
23
+ constructor(
24
+ private readonly secretKey: string,
25
+ private readonly network: Network,
26
+ ) {
27
+ const caip2 = CAIP2[network]
28
+ const signer = createEd25519Signer(secretKey, caip2)
29
+ const scheme = new ExactStellarScheme(signer)
30
+ const core = new x402Client()
31
+ core.register(caip2, scheme)
32
+ this.httpClient = new x402HTTPClient(core)
33
+ }
34
+
35
+ async pay(url: string, manifest: RouteDockManifest): Promise<PaymentResult> {
36
+ const pricing = manifest.pricing.x402
37
+ if (!pricing) {
38
+ throw new RouteDockManifestError('manifest.pricing.x402 missing')
39
+ }
40
+ if (!pricing.facilitator) {
41
+ throw new RouteDockManifestError('manifest.pricing.x402.facilitator missing')
42
+ }
43
+
44
+ // Initial request — expect 402. Include mode hint so middleware routes correctly.
45
+ const init = await fetch(url, { headers: { 'X-Preferred-Mode': 'x402' } })
46
+ if (init.status !== 402) {
47
+ const data = await init.json()
48
+ return { data, txHash: null, mode: 'x402', amount: pricing.amount, timestamp: Date.now() }
49
+ }
50
+
51
+ // Parse payment requirements from header
52
+ const reqHeader = init.headers.get('X-Payment-Requirements')
53
+ if (!reqHeader) {
54
+ throw new RouteDockManifestError('402 response missing X-Payment-Requirements header')
55
+ }
56
+
57
+ const paymentRequired = decodePaymentRequiredHeader(reqHeader)
58
+
59
+ // Build signed payment payload
60
+ const paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired)
61
+ const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload)
62
+
63
+ // Re-request with payment
64
+ const settled = await fetch(url, { headers: paymentHeaders })
65
+ if (!settled.ok) {
66
+ throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`)
67
+ }
68
+
69
+ // Extract settlement tx hash from response header
70
+ let txHash: string | null = null
71
+ const responseHeader = settled.headers.get('X-Payment-Response')
72
+ if (responseHeader) {
73
+ try {
74
+ const settleResponse = decodePaymentResponseHeader(responseHeader)
75
+ txHash = (settleResponse as { transaction?: string }).transaction ?? null
76
+ } catch {
77
+ // non-fatal — some facilitators may omit the response header
78
+ }
79
+ }
80
+
81
+ const data = await settled.json()
82
+ return { data, txHash, mode: 'x402', amount: pricing.amount, timestamp: Date.now() }
83
+ }
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Full SDK — types, client, provider, store
2
+ export * from './types.js'
3
+ export * from './client/RouteDockClient.js'
4
+ export * from './client/ModeRouter.js'
5
+ export * from './client/x402Client.js'
6
+ export * from './client/MppChargeClient.js'
7
+ export * from './client/MppSessionClient.js'
8
+ export * from './store/SessionStore.js'
9
+ export * from './provider/routedockMiddleware.js'
@@ -0,0 +1,90 @@
1
+ import type { Request, Response, NextFunction, RequestHandler } from 'express'
2
+ import { stellar } from '@stellar/mpp/charge/server'
3
+ import { Mppx, Request as MppxRequest } from 'mppx/server'
4
+ import type { RouteDockManifest } from '../types.js'
5
+ import type { SessionStore } from '../store/SessionStore.js'
6
+
7
+ type Network = 'testnet' | 'mainnet'
8
+
9
+ const MPP_NETWORK: Record<Network, 'stellar:testnet' | 'stellar:pubnet'> = {
10
+ testnet: 'stellar:testnet',
11
+ mainnet: 'stellar:pubnet',
12
+ }
13
+
14
+ export interface MppChargeHandlerOptions {
15
+ payeeSecretKey: string
16
+ network: Network
17
+ amount: string
18
+ assetContract: string
19
+ manifest: RouteDockManifest
20
+ store?: SessionStore
21
+ onSettled?: (txHash: string, amount: string, mode: string) => Promise<void>
22
+ }
23
+
24
+ export function createMppChargeHandler(opts: MppChargeHandlerOptions): RequestHandler {
25
+ const networkId = MPP_NETWORK[opts.network]
26
+ const amountHumanReadable = opts.amount
27
+
28
+ const mppx = Mppx.create({
29
+ secretKey: opts.payeeSecretKey,
30
+ methods: [
31
+ stellar.charge({
32
+ recipient: opts.manifest.payee,
33
+ currency: opts.assetContract,
34
+ network: networkId,
35
+ feePayer: { envelopeSigner: opts.payeeSecretKey },
36
+ }),
37
+ ],
38
+ })
39
+
40
+ return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
41
+ try {
42
+ const fetchReq = MppxRequest.fromNodeListener(req, res)
43
+ // `mppx['stellar/charge']` — method key is `name/intent`
44
+ const handler = (
45
+ mppx as unknown as {
46
+ 'stellar/charge': (
47
+ opts: { amount: string; currency: string; recipient: string; description?: string },
48
+ ) => (input: globalThis.Request) => Promise<{ status: 402 | 200; challenge?: globalThis.Response; withReceipt?: (r: globalThis.Response) => globalThis.Response }>
49
+ }
50
+ )['stellar/charge']
51
+ const result = await handler({
52
+ amount: amountHumanReadable,
53
+ currency: opts.assetContract,
54
+ recipient: opts.manifest.payee,
55
+ description: opts.manifest.name,
56
+ })(fetchReq)
57
+
58
+ if (result.status === 402) {
59
+ const challenge = result.challenge!
60
+ res.status(402)
61
+ challenge.headers.forEach((v: string, k: string) => res.setHeader(k, v))
62
+ res.send(await challenge.text())
63
+ return
64
+ }
65
+
66
+ // status 200 — payment verified
67
+ const receipt = result.withReceipt!(new Response(''))
68
+ receipt.headers.forEach((v: string, k: string) => res.setHeader(k, v))
69
+
70
+ // Extract tx hash from receipt headers if available
71
+ const receiptHeader = receipt.headers.get('payment-receipt')
72
+ if (receiptHeader && opts.onSettled) {
73
+ try {
74
+ const parsed = JSON.parse(
75
+ Buffer.from(receiptHeader, 'base64').toString('utf8'),
76
+ ) as { reference?: string }
77
+ if (parsed.reference) {
78
+ await opts.onSettled(parsed.reference, opts.amount, 'mpp-charge')
79
+ }
80
+ } catch {
81
+ // non-fatal
82
+ }
83
+ }
84
+
85
+ next()
86
+ } catch (err) {
87
+ next(err)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,136 @@
1
+ import type { Request, Response, NextFunction, RequestHandler } from 'express'
2
+ import { Keypair } from '@stellar/stellar-sdk'
3
+ import { stellar, close as channelClose, Store } from '@stellar/mpp/channel/server'
4
+ import { Mppx, Request as MppxRequest } from 'mppx/server'
5
+ import type { RouteDockManifest } from '../types.js'
6
+
7
+ type Network = 'testnet' | 'mainnet'
8
+
9
+ const MPP_NETWORK: Record<Network, 'stellar:testnet' | 'stellar:pubnet'> = {
10
+ testnet: 'stellar:testnet',
11
+ mainnet: 'stellar:pubnet',
12
+ }
13
+
14
+ export interface MppSessionHandlerOptions {
15
+ payeeSecretKey: string
16
+ network: Network
17
+ channelContract: string
18
+ rate: string
19
+ assetContract: string
20
+ manifest: RouteDockManifest
21
+ commitmentPublicKey: string
22
+ onSettled?: (txHash: string, totalPaid: string, mode: string) => Promise<void>
23
+ }
24
+
25
+ export function createMppSessionHandler(opts: MppSessionHandlerOptions): RequestHandler {
26
+ const networkId = MPP_NETWORK[opts.network]
27
+ const rateHuman = opts.rate
28
+ const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey)
29
+ const cumulativeKey = `stellar:channel:cumulative:${opts.channelContract}`
30
+
31
+ const innerStore = Store.memory()
32
+
33
+ // Wrap the store to intercept cumulative amount writes
34
+ let lastCumulativeAmount = 0n
35
+ const wrappedStore: ReturnType<typeof Store.memory> = {
36
+ async get(key: string) { return innerStore.get(key) },
37
+ async put(key: string, value: unknown) {
38
+ await innerStore.put(key, value)
39
+ if (key === cumulativeKey && value && typeof value === 'object' && 'amount' in (value as Record<string, unknown>)) {
40
+ lastCumulativeAmount = BigInt((value as { amount: string }).amount)
41
+ }
42
+ },
43
+ async delete(key: string) { return innerStore.delete(key) },
44
+ }
45
+
46
+ const mppx = Mppx.create({
47
+ secretKey: opts.payeeSecretKey,
48
+ methods: [
49
+ stellar.channel({
50
+ channel: opts.channelContract,
51
+ commitmentKey: opts.commitmentPublicKey,
52
+ network: networkId,
53
+ store: wrappedStore,
54
+ sourceAccount: payeeKeypair.publicKey(),
55
+ feePayer: { envelopeSigner: payeeKeypair },
56
+ }),
57
+ ],
58
+ })
59
+
60
+ // Track the last signature from the authorization header
61
+ let lastSignatureHex = ''
62
+
63
+ return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
64
+ try {
65
+ if (req.method === 'DELETE') {
66
+ const body = req.body as { amount?: string; signature?: string } | undefined
67
+ const closeAmount = body?.amount ? BigInt(body.amount) : lastCumulativeAmount
68
+ const closeSig = body?.signature ?? lastSignatureHex
69
+
70
+ if (closeAmount > 0n && closeSig) {
71
+ const closeTxHash = await channelClose({
72
+ channel: opts.channelContract,
73
+ amount: closeAmount,
74
+ signature: Buffer.from(closeSig, 'hex'),
75
+ feePayer: { envelopeSigner: payeeKeypair },
76
+ network: networkId,
77
+ })
78
+
79
+ if (opts.onSettled) {
80
+ const totalPaid = (Number(lastCumulativeAmount) / 1e7).toFixed(7)
81
+ await opts.onSettled(closeTxHash, totalPaid, 'mpp-session')
82
+ }
83
+
84
+ res.json({ closeTxHash })
85
+ } else {
86
+ res.json({ closeTxHash: null, message: 'no vouchers received' })
87
+ }
88
+ return
89
+ }
90
+
91
+ // Extract the signature from the authorization header before passing to mppx
92
+ const authHeader = req.headers['authorization']
93
+ if (typeof authHeader === 'string' && authHeader.startsWith('Payment ')) {
94
+ try {
95
+ const credB64 = authHeader.replace(/^Payment\s+/, '').split(',').find(p => p.trim().startsWith('credential='))
96
+ if (credB64) {
97
+ const credJson = Buffer.from(credB64.split('=').slice(1).join('=').replace(/^"|"$/g, ''), 'base64').toString('utf8')
98
+ const cred = JSON.parse(credJson) as { payload?: { signature?: string } }
99
+ if (cred.payload?.signature) {
100
+ lastSignatureHex = cred.payload.signature
101
+ }
102
+ }
103
+ } catch {
104
+ // non-fatal — signature extraction is best-effort
105
+ }
106
+ }
107
+
108
+ const fetchReq = MppxRequest.fromNodeListener(req, res)
109
+
110
+ const result = await (mppx as unknown as {
111
+ channel: (o: { amount: string; description?: string }) =>
112
+ (r: globalThis.Request) => Promise<{
113
+ status: number
114
+ challenge?: globalThis.Response
115
+ withReceipt?: (r: globalThis.Response) => globalThis.Response
116
+ }>
117
+ }).channel({
118
+ amount: rateHuman,
119
+ description: opts.manifest.name,
120
+ })(fetchReq)
121
+
122
+ if (result.status === 402) {
123
+ const challenge = result.challenge!
124
+ res.status(402)
125
+ challenge.headers.forEach((v: string, k: string) => res.setHeader(k, v))
126
+ const body = await challenge.text()
127
+ res.send(body)
128
+ return
129
+ }
130
+
131
+ next()
132
+ } catch (err) {
133
+ next(err)
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,7 @@
1
+ // Provider-only exports (Express middleware + handlers)
2
+ export * from '../types.js'
3
+ export * from './routedockMiddleware.js'
4
+ export * from './x402Handler.js'
5
+ export * from './MppChargeHandler.js'
6
+ export * from './MppSessionHandler.js'
7
+ export * from '../store/SessionStore.js'