@routedock/routedock 0.1.2 → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +532 -4
- package/dist/MppSessionHandler-1EL8QBAn.d.cts +215 -0
- package/dist/MppSessionHandler-Bq4S-JTE.d.ts +215 -0
- package/dist/ProviderRegistry-CA_TdREG.d.ts +142 -0
- package/dist/ProviderRegistry-D-0rqK0N.d.cts +142 -0
- package/dist/RouteDockClient-Bh86bVly.d.ts +469 -0
- package/dist/RouteDockClient-go9aopfF.d.cts +469 -0
- package/dist/SessionReconciler-Bw-PoBVl.d.ts +134 -0
- package/dist/SessionReconciler-Cz2nuOlJ.d.cts +134 -0
- package/dist/SessionStore-B4-Hi4H8.d.cts +143 -0
- package/dist/SessionStore-BaYiegP0.d.ts +143 -0
- package/dist/client.cjs +2746 -217
- package/dist/client.d.cts +51 -81
- package/dist/client.d.ts +51 -81
- package/dist/client.js +2690 -218
- package/dist/index.cjs +3634 -420
- package/dist/index.d.cts +12 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.js +3569 -419
- package/dist/logger-D8_Q2ytp.d.cts +467 -0
- package/dist/logger-D8_Q2ytp.d.ts +467 -0
- package/dist/provider/fastify.cjs +1099 -0
- package/dist/provider/fastify.d.cts +40 -0
- package/dist/provider/fastify.d.ts +40 -0
- package/dist/provider/fastify.js +1097 -0
- package/dist/provider/hono.cjs +1152 -0
- package/dist/provider/hono.d.cts +101 -0
- package/dist/provider/hono.d.ts +101 -0
- package/dist/provider/hono.js +1142 -0
- package/dist/provider.cjs +1105 -147
- package/dist/provider.d.cts +38 -27
- package/dist/provider.d.ts +38 -27
- package/dist/provider.js +1079 -148
- package/dist/react.cjs +2931 -0
- package/dist/react.d.cts +107 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +2924 -0
- package/dist/routedockMiddleware-57gUaEMf.d.ts +64 -0
- package/dist/routedockMiddleware-DlyLrxp7.d.cts +64 -0
- package/dist/schema.cjs +404 -0
- package/dist/schema.d.cts +3 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +399 -0
- package/dist/sign-AKH67sft.d.cts +53 -0
- package/dist/sign-BEq-OsSq.d.ts +53 -0
- package/dist/testing.cjs +59 -0
- package/dist/testing.d.cts +114 -0
- package/dist/testing.d.ts +114 -0
- package/dist/testing.js +56 -0
- package/package.json +143 -23
- package/PUBLISH_CHECKLIST.md +0 -70
- package/dist/SessionStore-CCVO0PHM.d.cts +0 -152
- package/dist/SessionStore-CCVO0PHM.d.ts +0 -152
- package/dist/routedockMiddleware-B7WTB3LT.d.cts +0 -53
- package/dist/routedockMiddleware-DRgdrycG.d.ts +0 -53
- package/src/client/ModeRouter.ts +0 -104
- package/src/client/MppChargeClient.ts +0 -44
- package/src/client/MppSessionClient.ts +0 -144
- package/src/client/RouteDockClient.ts +0 -124
- package/src/client/index.ts +0 -8
- package/src/client/x402Client.ts +0 -84
- package/src/index.ts +0 -9
- package/src/provider/MppChargeHandler.ts +0 -90
- package/src/provider/MppSessionHandler.ts +0 -157
- package/src/provider/index.ts +0 -7
- package/src/provider/routedockMiddleware.ts +0 -154
- package/src/provider/x402Handler.ts +0 -164
- package/src/schemas/routedock.schema.json +0 -159
- package/src/store/SessionStore.ts +0 -90
- package/src/tests/smoke.test.ts +0 -228
- package/src/types.ts +0 -165
- package/tsconfig.json +0 -14
- package/tsup.config.ts +0 -15
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RouteDock
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
# @routedock/
|
|
1
|
+
# @routedock/sdk
|
|
2
2
|
|
|
3
3
|
Unified payment execution layer for autonomous agents on Stellar. One SDK for x402, MPP charge, and MPP session — mode selected automatically from the provider's `routedock.json` manifest.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @routedock/
|
|
8
|
+
npm install @routedock/sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Agent Usage
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
|
-
import { RouteDockClient } from '@routedock/
|
|
14
|
+
import { RouteDockClient } from '@routedock/sdk/client'
|
|
15
15
|
import { Keypair } from '@stellar/stellar-sdk'
|
|
16
16
|
|
|
17
17
|
const client = new RouteDockClient({
|
|
@@ -35,11 +35,40 @@ for await (const update of session.stream()) {
|
|
|
35
35
|
await session.close() // triggers on-chain settlement
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### Durable spend cap
|
|
39
|
+
|
|
40
|
+
The `spendCap` is enforced via a pluggable `SpendStore` (`read()` / `write()`).
|
|
41
|
+
By default the client uses an in-memory store that resets on every process
|
|
42
|
+
restart — and logs a startup warning — so the cap is **not** durable. For
|
|
43
|
+
production, inject a persistent implementation so a crash or restart can't reset
|
|
44
|
+
the accumulator and bypass the cap:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { RouteDockClient, type SpendStore, type DailySpend } from '@routedock/sdk/client'
|
|
48
|
+
|
|
49
|
+
const redisSpendStore: SpendStore = {
|
|
50
|
+
async read(): Promise<DailySpend | null> {
|
|
51
|
+
const raw = await redis.get('routedock:dailySpend')
|
|
52
|
+
return raw ? (JSON.parse(raw) as DailySpend) : null
|
|
53
|
+
},
|
|
54
|
+
async write(state: DailySpend): Promise<void> {
|
|
55
|
+
await redis.set('routedock:dailySpend', JSON.stringify(state))
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const client = new RouteDockClient({
|
|
60
|
+
wallet: Keypair.fromSecret(process.env.AGENT_SECRET),
|
|
61
|
+
network: 'mainnet',
|
|
62
|
+
spendCap: { daily: '1.00', asset: 'USDC' },
|
|
63
|
+
spendStore: redisSpendStore,
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
38
67
|
## Provider Usage
|
|
39
68
|
|
|
40
69
|
```ts
|
|
41
70
|
import express from 'express'
|
|
42
|
-
import { routedock } from '@routedock/
|
|
71
|
+
import { routedock } from '@routedock/sdk/provider'
|
|
43
72
|
|
|
44
73
|
const app = express()
|
|
45
74
|
|
|
@@ -107,6 +136,480 @@ app.use('/stream', routedock({
|
|
|
107
136
|
| `onVoucher(index, cumulativeAmount)` | Each verified ed25519 commitment | No |
|
|
108
137
|
| `onSettled(txHash, amount, mode)` | Channel close transaction confirmed | Yes |
|
|
109
138
|
|
|
139
|
+
## Running a provider in production
|
|
140
|
+
|
|
141
|
+
The `routedockHono` defaults — `InMemorySeenTxStore` for `seenTxStore` and
|
|
142
|
+
`Store.memory()` for `sessionStore` — are only safe in a single long-lived
|
|
143
|
+
process. Any runtime that can route requests to different isolates or
|
|
144
|
+
processes (Cloudflare Workers, Deno Deploy, Lambda@Edge, or even a
|
|
145
|
+
multi-instance Node deployment) needs the durable stores below, or an agent
|
|
146
|
+
retry can settle a payment twice or lose voucher tracking mid-session.
|
|
147
|
+
|
|
148
|
+
### Settlement idempotency (`seenTxStore`)
|
|
149
|
+
|
|
150
|
+
`seenTxStore` guards against double-settlement when an agent retries the same
|
|
151
|
+
payment after a timeout. The in-memory default is per-handler and per-process,
|
|
152
|
+
so a retry landing in a fresh isolate misses the cache and settles again,
|
|
153
|
+
charging twice on-chain.
|
|
154
|
+
|
|
155
|
+
`SupabaseSeenTxStore` backs it with Postgres, which gives the read-your-writes
|
|
156
|
+
consistency this needs. **Don't use Workers KV here** — it's eventually
|
|
157
|
+
consistent, so a retry can outrun propagation and reintroduce the double
|
|
158
|
+
settle.
|
|
159
|
+
|
|
160
|
+
Requires the `settlements` table from migration `003_settlement_idempotency`
|
|
161
|
+
(see `supabase/migrations/003_settlement_idempotency.sql`; the follow-up
|
|
162
|
+
migration `004_settlement_retention.sql` adds cleanup for old rows). The store
|
|
163
|
+
needs two required methods, `get`/`set`, plus an optional `claimReplay`;
|
|
164
|
+
`SupabaseSeenTxStore` also needs `006_settlement_replay_limit.sql`. The
|
|
165
|
+
idempotency key is scoped to the method, path, amount and payee, and a cached
|
|
166
|
+
settlement is only replayed within a 60s window and a bounded number of times:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { createClient } from '@supabase/supabase-js'
|
|
170
|
+
import { routedockHono, SupabaseSeenTxStore } from '@routedock/routedock/provider/hono'
|
|
171
|
+
|
|
172
|
+
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY)
|
|
173
|
+
const seenTxStore = new SupabaseSeenTxStore(supabase)
|
|
174
|
+
|
|
175
|
+
app.use('*', routedockHono({
|
|
176
|
+
modes: ['x402', 'mpp-charge'],
|
|
177
|
+
pricing: { x402: '0.001', 'mpp-charge': '0.0008' },
|
|
178
|
+
asset: 'USDC',
|
|
179
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
180
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
181
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
182
|
+
network: 'testnet',
|
|
183
|
+
manifest,
|
|
184
|
+
seenTxStore,
|
|
185
|
+
}))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
On Cloudflare Workers, `InMemorySeenTxStore` logs a `console.warn` when
|
|
189
|
+
constructed so this isn't missed silently — but that check only detects the
|
|
190
|
+
Workers user agent, so Bun and Deno operators get no warning today. Don't rely
|
|
191
|
+
on it; wire a durable store explicitly.
|
|
192
|
+
|
|
193
|
+
### Session state (`sessionStore`)
|
|
194
|
+
|
|
195
|
+
`sessionStore` backs the `mpp-session` channel store (voucher monotonicity
|
|
196
|
+
tracking). The in-memory default doesn't survive isolate eviction, and
|
|
197
|
+
`MppSessionClient` issues one HTTP request per voucher — so voucher N and
|
|
198
|
+
voucher N+1 can land in different isolates entirely.
|
|
199
|
+
|
|
200
|
+
> **Note:** this `sessionStore` option takes a `@stellar/mpp` `Store.Store`.
|
|
201
|
+
> That's a different thing from this SDK's own `SessionStore` /
|
|
202
|
+
> `SupabaseSessionStore` (in `packages/sdk/src/store/SessionStore.ts`), which
|
|
203
|
+
> is a separate interface over the Supabase `sessions` table used for
|
|
204
|
+
> dashboards and reconciliation. Don't confuse the two.
|
|
205
|
+
|
|
206
|
+
On Cloudflare Workers, back it with a Durable Object per channel so voucher
|
|
207
|
+
reads and writes serialize through one instance:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
211
|
+
import { Hono } from 'hono'
|
|
212
|
+
import { routedockHono } from '@routedock/routedock/provider/hono'
|
|
213
|
+
import { Store } from '@stellar/mpp/channel/server'
|
|
214
|
+
|
|
215
|
+
// The provider needs @stellar/mpp as a direct dependency for Store.
|
|
216
|
+
|
|
217
|
+
export class ChannelSession extends DurableObject<Env> {
|
|
218
|
+
private app: Hono | null = null
|
|
219
|
+
|
|
220
|
+
private buildApp(): Hono {
|
|
221
|
+
const env = this.env
|
|
222
|
+
const sessionStore = Store.from({
|
|
223
|
+
get: (key: string) => this.ctx.storage.get(key),
|
|
224
|
+
put: (key: string, value: unknown) => this.ctx.storage.put(key, value),
|
|
225
|
+
delete: async (key: string) => {
|
|
226
|
+
await this.ctx.storage.delete(key)
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const app = new Hono()
|
|
231
|
+
app.use('*', routedockHono({
|
|
232
|
+
modes: ['mpp-session'],
|
|
233
|
+
pricing: {
|
|
234
|
+
'mpp-session': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
235
|
+
},
|
|
236
|
+
asset: 'USDC',
|
|
237
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
238
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
239
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
240
|
+
network: 'testnet',
|
|
241
|
+
commitmentPublicKey: env.COMMITMENT_PUBLIC_KEY!,
|
|
242
|
+
sessionStore,
|
|
243
|
+
manifest,
|
|
244
|
+
}))
|
|
245
|
+
return app
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override async fetch(request: Request): Promise<Response> {
|
|
249
|
+
this.app ??= this.buildApp()
|
|
250
|
+
return this.app.fetch(request)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Route all requests for a given channel to the same object, keyed by channel
|
|
256
|
+
contract, so vouchers for one channel always serialize through one instance:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
export default {
|
|
260
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
261
|
+
const id = env.CHANNEL_SESSION.idFromName(env.CHANNEL_CONTRACT_ID)
|
|
262
|
+
return env.CHANNEL_SESSION.get(id).fetch(request)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Wire the binding and migration in `wrangler.jsonc`:
|
|
268
|
+
|
|
269
|
+
```jsonc
|
|
270
|
+
{
|
|
271
|
+
"durable_objects": {
|
|
272
|
+
"bindings": [{ "name": "CHANNEL_SESSION", "class_name": "ChannelSession" }]
|
|
273
|
+
},
|
|
274
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChannelSession"] }]
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Limitation:** the Durable Object persists the `@stellar/mpp` channel store
|
|
279
|
+
only. `routedockHono` still tracks `lastCumulativeAmount`, `voucherCount`,
|
|
280
|
+
`lastSignatureHex` and `sessionPayerAddress` in closure scope, so if the
|
|
281
|
+
instance is evicted mid-session those reset, and the close path falls back to
|
|
282
|
+
the client-supplied amount and signature. Moving them into the same durable
|
|
283
|
+
store is tracked separately — check the current state of that work before
|
|
284
|
+
relying on it.
|
|
285
|
+
|
|
286
|
+
### Orphaned sessions and reconciliation
|
|
287
|
+
|
|
288
|
+
Without recovery wiring, a session abandoned mid-stream (client disconnects,
|
|
289
|
+
goes idle) just sits open forever with funds locked in the channel. Two pieces
|
|
290
|
+
are required together:
|
|
291
|
+
|
|
292
|
+
`onOrphaned` writes the session row as `status: 'closing'` (the `sessions`
|
|
293
|
+
table comes from migration `001_init.sql`) so the reconciler can find it.
|
|
294
|
+
|
|
295
|
+
`idleTimeoutMs` has to be set explicitly. Without it no idle timer is armed,
|
|
296
|
+
and an idle (as opposed to disconnected) session is never flagged orphaned.
|
|
297
|
+
|
|
298
|
+
On Cloudflare Workers it doesn't help yet. The idle timer is a `setTimeout`
|
|
299
|
+
inside the Durable Object, and Cloudflare evicts an idle object after roughly
|
|
300
|
+
70 to 140 seconds, so the timer never fires. provider-b has the same gap, see
|
|
301
|
+
[#314](https://github.com/winsznx/routedock/issues/314).
|
|
302
|
+
|
|
303
|
+
The status and cumulative-amount fields need to be written separately.
|
|
304
|
+
Writing `cumulative_amount` in the same update as `status: 'closing'` can
|
|
305
|
+
collide with the `monotonic_cumulative` trigger (`supabase/migrations/001_init.sql:40,48-50`),
|
|
306
|
+
which rejects any update where the new amount isn't strictly greater than the
|
|
307
|
+
stored one — and `onVoucher` has usually already stored that exact amount, so
|
|
308
|
+
the combined update silently fails and the row never reaches `closing`. This
|
|
309
|
+
is the same bug tracked as [#338](https://github.com/winsznx/routedock/issues/338):
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
app.use('*', routedockHono({
|
|
313
|
+
// ...
|
|
314
|
+
idleTimeoutMs: 5 * 60_000,
|
|
315
|
+
onOrphaned: async (channelId, info) => {
|
|
316
|
+
await supabase.from('sessions')
|
|
317
|
+
.update({ status: 'closing' })
|
|
318
|
+
.eq('channel_id', channelId)
|
|
319
|
+
await supabase.from('sessions')
|
|
320
|
+
.update({
|
|
321
|
+
cumulative_amount: info.cumulativeAmount,
|
|
322
|
+
last_signature: info.lastSignature || null,
|
|
323
|
+
voucher_count: info.voucherCount,
|
|
324
|
+
})
|
|
325
|
+
.eq('channel_id', channelId)
|
|
326
|
+
.lt('cumulative_amount', info.cumulativeAmount)
|
|
327
|
+
},
|
|
328
|
+
}))
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Then schedule `reconcileAbandonedSessions` to actually settle those `closing`
|
|
332
|
+
sessions with their latest voucher:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { reconcileAbandonedSessions } from '@routedock/routedock/provider/hono'
|
|
336
|
+
|
|
337
|
+
export default {
|
|
338
|
+
async scheduled(_controller: unknown, env: Env): Promise<void> {
|
|
339
|
+
await reconcileAbandonedSessions({
|
|
340
|
+
supabase,
|
|
341
|
+
network: 'testnet',
|
|
342
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
343
|
+
onRecovered: async (channelId, txHash, totalPaid) => {
|
|
344
|
+
console.log(`[reconcile] recovered ${channelId}: ${txHash} (${totalPaid} USDC)`)
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Trigger it on a schedule in `wrangler.jsonc`:
|
|
352
|
+
|
|
353
|
+
```jsonc
|
|
354
|
+
{
|
|
355
|
+
"triggers": {
|
|
356
|
+
"crons": ["*/15 * * * *"]
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Registering in discovery
|
|
362
|
+
|
|
363
|
+
A provider doesn't show up in the discovery registry unless it registers
|
|
364
|
+
itself. Sign the manifest, then call `registerProvider` inside
|
|
365
|
+
`ctx.waitUntil(...)` with a `.catch` so a registry hiccup never blocks serving
|
|
366
|
+
a request:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { registerProvider, signManifest } from '@routedock/routedock/provider/hono'
|
|
370
|
+
|
|
371
|
+
const signed = signManifest(manifest, env.STELLAR_PAYEE_SECRET)
|
|
372
|
+
ctx.waitUntil(
|
|
373
|
+
registerProvider({
|
|
374
|
+
supabase,
|
|
375
|
+
manifest: signed,
|
|
376
|
+
baseUrl: 'https://your-provider.example.com',
|
|
377
|
+
}).catch((err) => {
|
|
378
|
+
console.error('[registry] failed to register provider:', err)
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
This writes to the `providers` table from migration `001_init.sql`.
|
|
384
|
+
|
|
385
|
+
### Serving `mpp-session-ws`
|
|
386
|
+
|
|
387
|
+
`mpp-session-ws` upgrades the HTTP connection to a WebSocket after the
|
|
388
|
+
handshake's payment is verified. Hono's `upgradeWebSocket` calls its callback
|
|
389
|
+
before it looks at the `Upgrade` header, so a guard placed only inside that
|
|
390
|
+
callback (throwing when `mppSessionWsVerified(c)` is false) also fires — and
|
|
391
|
+
throws — for an ordinary `mpp-session` GET on the same route, which has
|
|
392
|
+
nothing to do with a WebSocket handshake. Check the header in a separate
|
|
393
|
+
middleware ahead of the upgrade route instead, and let a second, plain route
|
|
394
|
+
serve `mpp-session` requests once the payment middleware has verified them:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { upgradeWebSocket } from 'hono/cloudflare-workers'
|
|
398
|
+
import { routedockHono, mppSessionWsVerified } from '@routedock/routedock/provider/hono'
|
|
399
|
+
|
|
400
|
+
app.use('*', routedockHono({
|
|
401
|
+
modes: ['mpp-session', 'mpp-session-ws'],
|
|
402
|
+
pricing: {
|
|
403
|
+
'mpp-session': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
404
|
+
'mpp-session-ws': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
405
|
+
},
|
|
406
|
+
asset: 'USDC',
|
|
407
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
408
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
409
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
410
|
+
network: 'testnet',
|
|
411
|
+
commitmentPublicKey: env.COMMITMENT_PUBLIC_KEY!,
|
|
412
|
+
manifest,
|
|
413
|
+
}))
|
|
414
|
+
|
|
415
|
+
app.get(
|
|
416
|
+
'/stream',
|
|
417
|
+
async (c, next) => {
|
|
418
|
+
const isUpgrade = c.req.header('upgrade')?.toLowerCase() === 'websocket'
|
|
419
|
+
if (isUpgrade && !mppSessionWsVerified(c)) {
|
|
420
|
+
return c.json({ error: 'refusing unverified WebSocket handshake' }, 403)
|
|
421
|
+
}
|
|
422
|
+
await next()
|
|
423
|
+
},
|
|
424
|
+
upgradeWebSocket(() => ({
|
|
425
|
+
onMessage: (_evt, ws) => {
|
|
426
|
+
ws.send(JSON.stringify({ type: 'ack' }))
|
|
427
|
+
},
|
|
428
|
+
})),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// mpp-session (HTTP) requests land here after the middleware verified the voucher.
|
|
432
|
+
app.get('/stream', (c) => c.json({ data: '...' }))
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Workers-safe imports
|
|
436
|
+
|
|
437
|
+
Every snippet above imports from `@routedock/routedock/provider/hono`, which
|
|
438
|
+
re-exports `routedockHono`, `mppSessionWsVerified`, `registerProvider`,
|
|
439
|
+
`signManifest`, `SupabaseSeenTxStore` and `reconcileAbandonedSessions`. Don't
|
|
440
|
+
import from `@routedock/routedock/provider` in a Workers snippet — that's the
|
|
441
|
+
Node-only Express entry point and pulls in dependencies (like `express`) that
|
|
442
|
+
don't run on Workers.
|
|
443
|
+
|
|
444
|
+
## React Integration
|
|
445
|
+
|
|
446
|
+
> **Warning:** These hooks create a RouteDockClient in the browser, so the wallet secret and any commitmentSecret are readable by anyone who loads the page.
|
|
447
|
+
>
|
|
448
|
+
> Next.js inlines every `NEXT_PUBLIC_*` variable into the client bundle at build time, so never put a Stellar secret in one.
|
|
449
|
+
>
|
|
450
|
+
> Only use these hooks with a dedicated, low-balance testnet key, and spendCap does not protect a key that has leaked.
|
|
451
|
+
>
|
|
452
|
+
> For production or mainnet, keep RouteDockClient on the server (a route handler or server action) and read the key from a non-public variable such as `AGENT_SECRET`, as the Agent Usage section does.
|
|
453
|
+
|
|
454
|
+
`@routedock/sdk/react` provides hooks for client construction, payments, sessions, and live tx log subscription. Wrap your app with `RouteDockProvider`:
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
import { RouteDockProvider, useRouteDockClient } from '@routedock/sdk/react'
|
|
458
|
+
import { createClient } from '@supabase/supabase-js'
|
|
459
|
+
|
|
460
|
+
// Dedicated throwaway testnet key. Never a funded or mainnet key.
|
|
461
|
+
const THROWAWAY_TESTNET_SECRET = 'S...'
|
|
462
|
+
|
|
463
|
+
function App({ children }: { children: React.ReactNode }) {
|
|
464
|
+
const client = useRouteDockClient({
|
|
465
|
+
wallet: THROWAWAY_TESTNET_SECRET,
|
|
466
|
+
network: 'testnet',
|
|
467
|
+
spendCap: { daily: '1.00', asset: 'USDC' },
|
|
468
|
+
})
|
|
469
|
+
const supabase = createClient(
|
|
470
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
471
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
472
|
+
)
|
|
473
|
+
return (
|
|
474
|
+
<RouteDockProvider client={client} supabase={supabase}>
|
|
475
|
+
{children}
|
|
476
|
+
</RouteDockProvider>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Paying from the server
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
// app/api/price/route.ts (runs on the server only)
|
|
485
|
+
import { RouteDockClient } from '@routedock/sdk/client'
|
|
486
|
+
|
|
487
|
+
const client = new RouteDockClient({
|
|
488
|
+
wallet: process.env.AGENT_SECRET!,
|
|
489
|
+
network: 'testnet',
|
|
490
|
+
spendCap: { daily: '1.00', asset: 'USDC' },
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
export async function POST() {
|
|
494
|
+
const result = await client.pay('https://provider.example.com/price')
|
|
495
|
+
return Response.json({ mode: result.mode, txHash: result.txHash, data: result.data })
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### `usePay(url, options?)`
|
|
500
|
+
|
|
501
|
+
```tsx
|
|
502
|
+
import { usePay } from '@routedock/sdk/react'
|
|
503
|
+
|
|
504
|
+
function PriceButton() {
|
|
505
|
+
const { pay, result, loading, error } = usePay('https://provider.example.com/price')
|
|
506
|
+
if (error) return <span>Error: {error.message}</span>
|
|
507
|
+
return (
|
|
508
|
+
<button onClick={pay} disabled={loading}>
|
|
509
|
+
{loading ? 'Paying…' : result ? `Paid: ${result.txHash}` : 'Pay 0.001 USDC'}
|
|
510
|
+
</button>
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### `useSession(url)`
|
|
516
|
+
|
|
517
|
+
```tsx
|
|
518
|
+
import { useEffect } from 'react'
|
|
519
|
+
import { useSession } from '@routedock/sdk/react'
|
|
520
|
+
|
|
521
|
+
function StreamingFeed() {
|
|
522
|
+
const { open, close, status, vouchers, cumulative } = useSession(
|
|
523
|
+
'https://provider.example.com/stream/orderbook',
|
|
524
|
+
)
|
|
525
|
+
useEffect(() => { void open() }, [])
|
|
526
|
+
return (
|
|
527
|
+
<div>
|
|
528
|
+
<p>Status: {status} — vouchers: {vouchers} — paid: {cumulative} USDC</p>
|
|
529
|
+
<button onClick={close} disabled={status !== 'open'}>Close & settle</button>
|
|
530
|
+
</div>
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The hook automatically fires `session.close()` in the background on unmount when status is `open` (best-effort settlement).
|
|
536
|
+
|
|
537
|
+
### `useTxLog(filter?)`
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
import { useTxLog } from '@routedock/sdk/react'
|
|
541
|
+
|
|
542
|
+
function ActivityFeed({ channelId }: { channelId: string }) {
|
|
543
|
+
const txLog = useTxLog({ channelId, limit: 25 })
|
|
544
|
+
return (
|
|
545
|
+
<ul>
|
|
546
|
+
{txLog.map((row) => (
|
|
547
|
+
<li key={row.id}>{row.mode} — {row.amount} USDC — {row.tx_hash}</li>
|
|
548
|
+
))}
|
|
549
|
+
</ul>
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
| Hook | Returns | Requires from `RouteDockProvider` |
|
|
555
|
+
|---|---|---|
|
|
556
|
+
| `useRouteDockClient(config)` | Memoized `RouteDockClient` | — (creator hook) |
|
|
557
|
+
| `usePay(url, options?)` | `{ pay, result, loading, error }` | `client` |
|
|
558
|
+
| `useSession(url)` | `{ session, status, vouchers, cumulative, error, open, close }` | `client` (with `commitmentSecret`) |
|
|
559
|
+
| `useTxLog(filter?)` | `TxLogRow[]` (newest first) | `supabase` |
|
|
560
|
+
|
|
561
|
+
React is a peer dependency — install `react@^18` (or `^19`) in your app.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Error Handling
|
|
566
|
+
|
|
567
|
+
All SDK failures extend `RouteDockError` with a stable `code`, `retryable` flag, and optional `cause`. Transient failures (network timeouts, facilitator 5xx, Horizon RPC errors) are retried automatically with exponential backoff unless you disable retries.
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
import {
|
|
571
|
+
RouteDockClient,
|
|
572
|
+
RouteDockError,
|
|
573
|
+
RouteDockFacilitatorError,
|
|
574
|
+
RouteDockManifestError,
|
|
575
|
+
RouteDockPolicyRejectError,
|
|
576
|
+
} from '@routedock/sdk/client'
|
|
577
|
+
|
|
578
|
+
const client = new RouteDockClient({
|
|
579
|
+
wallet: process.env.AGENT_SECRET!,
|
|
580
|
+
network: 'testnet',
|
|
581
|
+
retryPolicy: { maxAttempts: 4, baseDelayMs: 250 },
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const result = await client.pay('https://provider.example.com/price')
|
|
586
|
+
console.log(result.mode, result.txHash)
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err instanceof RouteDockPolicyRejectError) {
|
|
589
|
+
console.error('Spend cap exceeded:', err.reason)
|
|
590
|
+
} else if (err instanceof RouteDockFacilitatorError) {
|
|
591
|
+
console.error(`Facilitator HTTP ${err.status} (retryable=${err.retryable})`)
|
|
592
|
+
} else if (err instanceof RouteDockManifestError) {
|
|
593
|
+
console.error('Manifest or config problem:', err.message)
|
|
594
|
+
} else if (err instanceof RouteDockError) {
|
|
595
|
+
console.error(`[${err.code}] ${err.message} (retryable=${err.retryable})`)
|
|
596
|
+
} else {
|
|
597
|
+
throw err
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
| Error | `retryable` | Typical cause |
|
|
603
|
+
|---|---|---|
|
|
604
|
+
| `RouteDockManifestError` | no | Invalid manifest, missing pricing fields |
|
|
605
|
+
| `RouteDockNoSupportedModeError` | no | Provider has no compatible payment mode |
|
|
606
|
+
| `RouteDockFacilitatorError` | yes | Facilitator/provider 429 or 5xx |
|
|
607
|
+
| `RouteDockNetworkError` | yes | Timeouts, connection failures |
|
|
608
|
+
| `RouteDockSignatureError` | no | Signing or commitment failure |
|
|
609
|
+
| `RouteDockVoucherMonotonicityError` | no | Non-increasing voucher amount |
|
|
610
|
+
| `RouteDockPolicyRejectError` | no | Local spend cap exceeded |
|
|
611
|
+
| `RouteDockChannelStateError` | no | Channel simulate/close invariant violation |
|
|
612
|
+
|
|
110
613
|
## Mode Selection Logic
|
|
111
614
|
|
|
112
615
|
The `ModeRouter` uses this decision tree (deterministic, no randomness):
|
|
@@ -118,6 +621,31 @@ The `ModeRouter` uses this decision tree (deterministic, no randomness):
|
|
|
118
621
|
|
|
119
622
|
Override with `{ forceMode: 'x402' }` to bypass auto-selection.
|
|
120
623
|
|
|
624
|
+
## Dispute Resolution
|
|
625
|
+
|
|
626
|
+
For `mpp-session` mode, the session handle exposes three methods to handle server unavailability or crashes:
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
const session = await client.openSession('https://provider.example.com/stream')
|
|
630
|
+
|
|
631
|
+
// Check the current dispute status (open, in-refund-window, refundable, settled)
|
|
632
|
+
const status = await session.getDisputeStatus()
|
|
633
|
+
|
|
634
|
+
// If server is unresponsive, request a refund (starts the refund window)
|
|
635
|
+
const refundTxHash = await session.requestRefund()
|
|
636
|
+
|
|
637
|
+
// Server-side: settle with the latest signed voucher before refund window expires
|
|
638
|
+
const settleTxHash = await session.settleWithLatestVoucher()
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
| Method | Purpose | Requires | Returns |
|
|
642
|
+
|--------|---------|----------|---------|
|
|
643
|
+
| `getDisputeStatus()` | Query channel state | None | `'open' \| 'in-refund-window' \| 'refundable' \| 'settled'` |
|
|
644
|
+
| `requestRefund()` | Initiate refund process | Signed agent keypair | Transaction hash (string) |
|
|
645
|
+
| `settleWithLatestVoucher()` | Server counter-settle | Latest voucher signature | Transaction hash (string) |
|
|
646
|
+
|
|
647
|
+
Raises: `RouteDockDisputeError`, `RouteDockChannelStateError`, `RouteDockRefundWindowError`
|
|
648
|
+
|
|
121
649
|
## Security
|
|
122
650
|
|
|
123
651
|
The `one-way-channel` Soroban contract wrapped by `MppSessionClient` is **unaudited** (`stellar-experimental/one-way-channel`). Safe defaults are enforced: 17280 ledger refund window, monotonic cumulative enforcement at application, database, and contract layers. Production mainnet use should await a formal audit.
|