@voidly/session 1.0.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/README.md ADDED
@@ -0,0 +1,501 @@
1
+ # @voidly/session
2
+
3
+ A client for the Voidly private-hire session rail: a hirer commissions sealed
4
+ work from a provider it has verified, pays for it on-chain with a pre-signed
5
+ EIP-3009 authorization bound to the hire, and reads back the sealed result.
6
+
7
+ Both halves are here. The hirer builds and signs its own envelopes — the brief is
8
+ sealed to the provider's key before it leaves the machine, so nothing else is
9
+ possible — and the provider half is the validators and builders a daemon needs.
10
+ Which hires a provider accepts, and on what terms, is a daemon's own business and
11
+ is not in this package.
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ > **This package is not on the public registry.** `npm install @voidly/session`
18
+ > answers `E404`, and you did not mistype it: `package.json` declares
19
+ > `private: true` and nothing has ever been published under this name. That is a
20
+ > deliberate gate, and this document does not carry a date for lifting it.
21
+ >
22
+ > **Meanwhile, install the tarball.** Ask the operator who gave you this document
23
+ > for one, or build it yourself from a checkout of the source tree:
24
+ >
25
+ > ```bash
26
+ > npm run build && npm pack # → voidly-session-1.0.0.tgz
27
+ > npm install /path/to/voidly-session-1.0.0.tgz
28
+ > ```
29
+ >
30
+ > Those are the same bytes `npm publish` would upload — `npm run gate` scans that
31
+ > tarball and nothing else — so a hire that works against it works unchanged
32
+ > against the published package on the day there is one.
33
+
34
+ ESM only. Node ≥ 18 or any runtime with WebCrypto, `fetch` and `TextEncoder`.
35
+ Two runtime dependencies: `tweetnacl` and `tweetnacl-util`.
36
+
37
+ ---
38
+
39
+ ## Doors, not pieces
40
+
41
+ Six numbered steps carry a hire from a URL to an opened result. Each door
42
+ **composes** the steps under it in an order that matters, and each is shaped so
43
+ the mistake it guards against is not expressible.
44
+
45
+ **Step 3 is a fork, and step 5 is the same fork's other half.** Read the next
46
+ section before you write either.
47
+
48
+ | | | |
49
+ |---|---|---|
50
+ | 1 | `fetchVerifiedProvider` | a URL → a signed, **pinned** provider |
51
+ | 2 | `buildHire` | the offer, the grant, the sealed brief |
52
+ | 3 | **the payment fork** | `buildReceivePaymentAuthorization` *(default)* **or** `buildTransferPaymentAuthorization` |
53
+ | 4 | `submitHire` | build the envelope, send it, **authenticate** the answer |
54
+ | 5 | **who settles** | the provider relays — *nothing to call* **or** `payForGrant`, then `submitSettlementHint` |
55
+ | 6 | `recoverResult` | ask for the result, **authenticate** it, open it |
56
+
57
+ The pieces beneath them are exported too, because a caller with its own carrier
58
+ or its own wallet plumbing needs them. **The order is only guaranteed by the
59
+ doors.**
60
+
61
+ ---
62
+
63
+ ## The fork: who settles the payment
64
+
65
+ **Both authorizations carry the same nonce.** It is
66
+ `settlementBindingReference(grantHash)` — a function of the hire and of nothing
67
+ else — and USDC marks the `(authorizer, nonce)` pair spent forever on first use.
68
+ So the two are **alternatives, never steps**. Sign both and let both go out and
69
+ the second one spends real gas on a guaranteed revert, while the call that sent
70
+ it still hands you a transaction hash, because no submitter here waits for a
71
+ receipt.
72
+
73
+ | | **the provider relays** — default | **you settle** — opt-out |
74
+ |---|---|---|
75
+ | step 3 | `buildReceivePaymentAuthorization` | `buildTransferPaymentAuthorization` |
76
+ | step 5 | nothing to call | `payForGrant`, then `submitSettlementHint` |
77
+ | what you sign | `receiveWithAuthorization` | `transferWithAuthorization` |
78
+ | who can spend it | **only the payee named in it** | anyone holding the bytes |
79
+ | who pays the gas | the provider | you, or your facilitator |
80
+ | who writes the settlement pointer | the provider, from the chain | you, with the hint |
81
+
82
+ **The default is the provider relaying**, for one reason. The token restricts a
83
+ receive authorization to `msg.sender == to`, so it is not spendable by anyone but
84
+ the payee it already names. Every payment `payForGrant` produces is a bearer
85
+ payload: it is exposed to a front-run, and to a zero-cost decoy that is
86
+ unrecoverable once it lands. Take the opt-out when the provider does not relay —
87
+ and **ask the operator which it runs, because the signed manifest does not say.**
88
+ `PROVIDER_MANIFEST_KEYS` carries no relay field, exactly as it carries no hint
89
+ URL.
90
+
91
+ **On the default path, do not send a settlement hint.** The provider writes the
92
+ pointer itself, from the hash the chain agrees spent the nonce, and a hirer's
93
+ hint arriving after it is refused `409 hint_too_late` — permanently, and by
94
+ design: a pointer able to overwrite that one is how a settlement that landed gets
95
+ recorded as rejected. The retry advice under `submitSettlementHint` belongs to
96
+ the opt-out path only; here, retrying with a fresh clock never succeeds.
97
+
98
+ ---
99
+
100
+ ## A hire, end to end — the default
101
+
102
+ ```ts
103
+ import {
104
+ fetchVerifiedProvider,
105
+ buildHire,
106
+ buildReceivePaymentAuthorization,
107
+ submitHire,
108
+ recoverResult,
109
+ x402SessionAccountCaip10,
110
+ } from "@voidly/session";
111
+
112
+ // 1. DISCOVERY. The only supported way to turn a URL into a provider. The
113
+ // manifest is signed and the pin is REQUIRED: an attacker that keeps the
114
+ // honest DID and swaps only the encryption key and the accept URL satisfies
115
+ // DID derivation perfectly, and the brief is sealed to that key and posted to
116
+ // that URL before any acceptance exists. Refusing to pay does not un-disclose
117
+ // a brief.
118
+ const found = await fetchVerifiedProvider({
119
+ manifestUrl,
120
+ expectedProviderDid, // the pin. REQUIRED — there is no unpinned arm.
121
+ fetchImpl: fetch,
122
+ });
123
+ if (!found.ok) throw new Error(found.reason);
124
+
125
+ // 2. THE HIRE. `wire` is transmitted; `keep` is retained and never leaves the
126
+ // machine — `keep.sessionKey` is the only secret in the protocol.
127
+ //
128
+ // THE OFFERING IS LOOKED UP FIRST, AND EVERY MONEY FIELD BELOW IS COPIED OFF
129
+ // IT. Four of the five are compared against this document with `===` and no
130
+ // normalisation, so a value you type yourself is refused by name — see "the
131
+ // price is not yours to type" below.
132
+ const SERVICE_REF = "voidly.observatory.query/v1";
133
+ const offering = found.provider.manifest.services.find((s) => s.ref === SERVICE_REF);
134
+ if (!offering) throw new Error(`this provider does not offer ${SERVICE_REF}`);
135
+
136
+ const hire = await buildHire({
137
+ hirer: { did, signingPublicKeyBase64, sign }, // `sign` is Ed25519, detached
138
+ provider: found.provider,
139
+ // ONE OF THE REFS THE VERIFIED MANIFEST ALREADY OFFERS —
140
+ // `found.provider.manifest.services[].ref`, compared with `===`. A ref this
141
+ // provider does not offer is refused `provider_service_not_offered` here, by
142
+ // name and before anything is signed.
143
+ service: { ref: SERVICE_REF },
144
+ task: { brief: "…" },
145
+ price: {
146
+ // FROM THE SIGNED DOCUMENT, not from your keyboard. `chain`, `asset` and
147
+ // `payeeAccount` are compared `!==` against this offering and refused
148
+ // `provider_price_chain_not_offered`, `provider_price_asset_not_offered`
149
+ // and `provider_payee_not_manifested`.
150
+ chain: offering.price.chain,
151
+ asset: offering.price.asset,
152
+ // THE ONE FIELD THAT IS GENUINELY YOURS. The payer account appears in no
153
+ // manifest — it is the account the money LEAVES, and nothing the provider
154
+ // publishes has anything to say about it. This is where the CAIP helper
155
+ // belongs, and the only place it does.
156
+ payerAccount: x402SessionAccountCaip10(offering.price.chain, payer)!,
157
+ payeeAccount: offering.price.payee_account,
158
+ // THE BAND MUST NEST INSIDE THE PUBLISHED BAND: `minAmount` may not fall
159
+ // below `min_amount` (`provider_price_below_manifest_floor`) and
160
+ // `maxAmount` may not rise above `max_amount`
161
+ // (`provider_price_above_manifest_ceiling`). Copying both is the exact
162
+ // price; pass them equal to any value inside the band to bid within it.
163
+ minAmount: offering.price.min_amount,
164
+ maxAmount: offering.price.max_amount,
165
+ },
166
+ // THE GRANT MAY NOT OUTLIVE THE OFFER. `grantMs > offerMs` is refused
167
+ // `invalid_ttl` before anything is signed — an authorization can never outlive
168
+ // the terms it points at — and the floor is 54s, twelve Base blocks plus the
169
+ // clock skew the rail allows (`grant_ttl_below_settlement_depth`).
170
+ //
171
+ // AND IT MUST ALSO NEST INSIDE THE PROVIDER'S PUBLISHED WINDOW.
172
+ // `found.provider.manifest.grant_ttl_ms` is `{min, max}`, covered by the same
173
+ // signature as the price, and a `grantMs` outside it is refused
174
+ // `provider_grant_ttl_below_manifest_floor` /
175
+ // `provider_grant_ttl_above_manifest_ceiling`. The 600,000 below is a literal
176
+ // because it has to be readable; check it against that band before you send.
177
+ ttl: { offerMs: 30 * 60_000, grantMs: 10 * 60_000 },
178
+ nowMs: Date.now(),
179
+ });
180
+ if (!hire.ok) throw new Error(hire.reason);
181
+
182
+ // 3. THE PAYMENT AUTHORIZATION — the RECEIVE variant, which only the payee can
183
+ // spend. Every money-steering field is derived from the grant, none is an
184
+ // argument. The binding nonce is a function of the grant hash, which is what
185
+ // ties the money to this hire and nothing else.
186
+ //
187
+ // THE OTHER VARIANT IS BELOW, AND IT IS NOT A LATER STEP. Same nonce, one
188
+ // spend. Choosing this one means step 5 is the provider's, not yours.
189
+ const paid = await buildReceivePaymentAuthorization({
190
+ grant: hire.wire.grant,
191
+ grantHash: hire.keep.grant_hash,
192
+ nowMs: Date.now(),
193
+ sign: signReceive, // your wallet's EIP-712 signer, over the RECEIVE struct
194
+ });
195
+ if (!paid.ok) throw new Error(paid.reason);
196
+
197
+ // 4. SUBMIT. Builds the envelope, sends it, and AUTHENTICATES the answer —
198
+ // `accepted` is returned only when the countersignature verifies under the
199
+ // provider key the hirer itself put in the grant, and only when the
200
+ // acceptance names that same provider as redeemer.
201
+ const out = await submitHire({
202
+ url: found.provider.manifest.accept_url,
203
+ wire: hire.wire,
204
+ grantHash: hire.keep.grant_hash,
205
+ authorization: paid.authorization,
206
+ sign,
207
+ nowMs: Date.now(),
208
+ fetchImpl: fetch,
209
+ });
210
+ switch (out.kind) {
211
+ case "accepted": break; // proceed
212
+ // Call `submitHire` again with the SAME `wire`, `authorization` and `sign`.
213
+ // The envelope carries no nonce and no timestamp, so it rebuilds byte for
214
+ // byte, and the provider is idempotent on `grant_hash`.
215
+ case "undelivered": break;
216
+ case "refused": /* see `steersPayment` below */ break;
217
+ case "unverifiable": /* do NOT pay */ break;
218
+ case "unbuildable": /* fix the arguments */ break;
219
+ }
220
+
221
+ // 5. NOTHING. The provider spends the authorization it now holds and writes its
222
+ // own settlement pointer. You do not pay, and you do not hint.
223
+
224
+ // 6. READ WHAT YOU PAID FOR. One call: it mints the recovery request off the
225
+ // grant, signs it, POSTs it, verifies the provider's delivery receipt under
226
+ // the key the HIRER put in the grant, and opens the capsule against the
227
+ // commitment on the receipt IT verified.
228
+ //
229
+ // `baseUrl` COMES OFF THE SIGNED MANIFEST. `worker_base_url` is one of the
230
+ // two doors the manifest publishes; a literal here would be a rail host
231
+ // pasted into your code that no signature covers.
232
+ //
233
+ // THERE IS NO `resultCommitment` PARAMETER, and that is the design. The only
234
+ // commitment worth opening against is one that came off a verified receipt;
235
+ // a door that took one could be handed the unverified copy that arrived on
236
+ // the same wire as the bytes, which checks the answer against itself.
237
+ const read = await recoverResult({
238
+ endpoint: { baseUrl: found.provider.manifest.worker_base_url },
239
+ wire: hire.wire,
240
+ grantHash: hire.keep.grant_hash,
241
+ sessionKey: hire.keep.sessionKey,
242
+ sign,
243
+ nowMs: Date.now(),
244
+ });
245
+ if (read.kind === "opened") {
246
+ console.log(read.result); // the work
247
+ console.log(read.receipt); // the provider's signed statement about it
248
+ }
249
+ // `no_result` is the normal answer until the provider has delivered — poll it.
250
+ // read.kind: "opened" | "unopenable" | "unverifiable" | "no_result"
251
+ // | "undelivered" | "unrecognized" | "unbuildable"
252
+ ```
253
+
254
+ **If the delivery reaches you some other way** — pushed straight from the
255
+ provider, off a queue — use `openDeliveredResult` instead of `recoverResult`. It
256
+ is the same authentication and the same open, with no round trip, and it takes no
257
+ commitment either.
258
+
259
+ ---
260
+
261
+ ## The opt-out: you settle it yourself
262
+
263
+ This **replaces steps 3 and 5** above. Steps 1, 2, 4 and 6 are unchanged, so it
264
+ is written here as a function over what those steps already produced.
265
+
266
+ ```ts
267
+ import {
268
+ buildTransferPaymentAuthorization,
269
+ payForGrant,
270
+ submitHire,
271
+ submitSettlementHint,
272
+ x402SessionEvidence,
273
+ } from "@voidly/session";
274
+ import type {
275
+ HireKeep,
276
+ HireWire,
277
+ SignTypedData,
278
+ Signer,
279
+ VerifiedProvider,
280
+ } from "@voidly/session";
281
+
282
+ export async function settleItYourself(input: {
283
+ provider: VerifiedProvider; // step 1
284
+ wire: HireWire; // step 2
285
+ keep: HireKeep; // step 2
286
+ sign: Signer; // the Ed25519 identity signer from step 2
287
+ signTransfer: SignTypedData; // NOT the receive signer — a DIFFERENT struct
288
+ facilitatorUrl: string;
289
+ hintUrl: string; // from the operator; see the note below
290
+ }) {
291
+ // 3′. THE TRANSFER VARIANT, instead of `buildReceivePaymentAuthorization`.
292
+ const paid = await buildTransferPaymentAuthorization({
293
+ grant: input.wire.grant,
294
+ grantHash: input.keep.grant_hash,
295
+ nowMs: Date.now(),
296
+ sign: input.signTransfer,
297
+ });
298
+ if (!paid.ok) throw new Error(paid.reason);
299
+
300
+ // 4. Unchanged.
301
+ const out = await submitHire({
302
+ url: input.provider.manifest.accept_url,
303
+ wire: input.wire,
304
+ grantHash: input.keep.grant_hash,
305
+ authorization: paid.authorization,
306
+ sign: input.sign,
307
+ nowMs: Date.now(),
308
+ fetchImpl: fetch,
309
+ });
310
+ if (out.kind !== "accepted") return out;
311
+
312
+ // 5′. PAY. Preflight the facilitator, THEN sign, THEN submit — in that order,
313
+ // guaranteed by the door. Chain, payee, amount and window all come off the
314
+ // grant; none of them is an argument, which is also why this re-derives
315
+ // the IDENTICAL authorization signed above rather than a second one.
316
+ // `broadcast` instead of `facilitator` sends it from your own wallet.
317
+ const settled = await payForGrant({
318
+ grant: input.wire.grant,
319
+ grantHash: input.keep.grant_hash,
320
+ nowMs: Date.now(),
321
+ signer: input.signTransfer,
322
+ facilitator: { baseUrl: input.facilitatorUrl, fetchImpl: fetch },
323
+ });
324
+ if (!settled.ok) throw new Error(`${settled.reason}: ${settled.detail}`);
325
+
326
+ // 5″. POINT THE PROVIDER AT THE PAYMENT. One call: it builds the hint, signs
327
+ // it, POSTs it, reads the answer. `provider_did` comes off the GRANT and
328
+ // the hash off the call that sent the money — neither is yours to choose.
329
+ // Its success arm is `acknowledged`, NOT `accepted`: nothing signs this
330
+ // door's answer, and the weaker word says so.
331
+ //
332
+ // SEND IT AS SOON AS THE PAYMENT IS AWAY. On a provider that also relays,
333
+ // this pointer is what stops its relay arm; a daemon that gets there first
334
+ // cannot spend a transfer authorization and fails the hire instead.
335
+ //
336
+ // RETRY BY CALLING IT AGAIN WITH A CURRENT CLOCK — never by re-sending kept
337
+ // bytes. The daemon admits only a strictly newer hint, so a replay reads,
338
+ // wrongly, as "my pointer never landed".
339
+ return await submitSettlementHint({
340
+ url: input.hintUrl,
341
+ grant: input.wire.grant,
342
+ grantHash: input.keep.grant_hash,
343
+ evidence: x402SessionEvidence(settled.transactionHash),
344
+ sign: input.sign,
345
+ nowMs: Date.now(),
346
+ fetchImpl: fetch,
347
+ });
348
+ // .kind: "acknowledged" | "refused" | "undelivered" | "unrecognized" | "unbuildable"
349
+ }
350
+ ```
351
+
352
+ **The hint URL is a parameter, and the signed manifest does not publish one.**
353
+ `PROVIDER_MANIFEST_KEYS` carries `accept_url` and `worker_base_url` and nothing
354
+ else that is a door, so deriving a hint endpoint from `accept_url`'s origin would
355
+ be inventing a convention no signature covers. Pass the URL the operator gave
356
+ you.
357
+
358
+ ---
359
+
360
+ ## The price is not yours to type
361
+
362
+ **Four of the five money fields are copied off the signed manifest, and the
363
+ fifth is the only one you own.** `buildHire` compares `chain`, `asset` and
364
+ `payeeAccount` against the offering with `===` and no normalisation, and it
365
+ requires your `[minAmount, maxAmount]` to nest inside the published
366
+ `[min_amount, max_amount]`. Every mismatch is refused by name, before anything
367
+ is signed — `provider_price_chain_not_offered`,
368
+ `provider_price_asset_not_offered`, `provider_payee_not_manifested`,
369
+ `provider_price_below_manifest_floor`, `provider_price_above_manifest_ceiling`.
370
+ So the honest source for all four is `manifest.services[].price`, and the
371
+ example above reads them from there. `payerAccount` is the exception: it is the
372
+ account the money LEAVES, it appears in no manifest, and it is the one field
373
+ `x402SessionAccountCaip10` is for.
374
+
375
+ **A hard-coded `minAmount` is a hazard, and `"10000"` is a good illustration of
376
+ why.** Over 28 contiguous days of Base blocks (1,208,324 joint cost samples,
377
+ sampled 2026-08-23/24) one relayed `receiveWithAuthorization` cost a median of
378
+ about **1,519 micro-USDC** and a p99.9 of **11,456**. A published floor of
379
+ 10,000 is 6.6× the median and **0.87× the p99.9** — solvent on an ordinary
380
+ afternoon and short of the gas on the days that decide whether a provider stays
381
+ up. It is not wrong by a lot; it is
382
+ wrong in the direction that only shows on the days that matter. A provider
383
+ whose floor does not clear its own relay cost watches USDC revenue climb until
384
+ the gas wallet empties and every accepted hire answers `relayer_cannot_pay_gas`
385
+ at once. Read the band off the manifest; do not carry a number from a document.
386
+
387
+ ---
388
+
389
+ ## Three more things that cost real money
390
+
391
+ **`validAfter` / `validBefore` are SECONDS.** Everything else in this protocol is
392
+ milliseconds. Milliseconds are refused by name rather than signed.
393
+
394
+ **The redemption proof header is single use.** Mint a fresh one per attempt; a
395
+ reused one is `409 provider_proof_replayed`.
396
+
397
+ **Simulate with `eth_call` before you spend** — on the opt-out path, where the
398
+ spending is yours. A reverted `transferWithAuthorization` emits no
399
+ `AuthorizationUsed`, so the settlement binding has nowhere to live and the
400
+ redemption can never resolve — while the relayer has already paid for the
401
+ failure. `createReadOnlyEvmRpc` and `simulateTransaction` do this without a key;
402
+ `createReadOnlyEvmRpc` refuses every write method before it touches the injected
403
+ `fetch`.
404
+
405
+ And one about refusals: a hire refusal carries no signature, so **any party in
406
+ the path can emit any refusal in the vocabulary.** `SubmitHireResult` sets
407
+ `steersPayment` on every refusal, and a `true` value means the only way to act on
408
+ it is to sign a NEW payment instrument. Never auto-remedy a `true`.
409
+
410
+ ---
411
+
412
+ ## A `VerifiedProvider` is a live value, not data
413
+
414
+ **It does not survive a round trip.** What makes a value a `VerifiedProvider` is
415
+ membership in a private table this package keeps, keyed by object identity — not
416
+ a field on the object. That is deliberate: a mark stored *on* the object can be
417
+ copied onto a different one by an ordinary `{ ...provider, manifest: other }`,
418
+ and a mark that survives a spread is not a mark. Membership cannot be copied.
419
+
420
+ The cost is a constraint worth knowing before you design around it:
421
+
422
+ * `structuredClone`, `JSON.parse(JSON.stringify(…))`, `postMessage` to a worker,
423
+ and anything that persists the value and reads it back all produce an object
424
+ that **still typechecks as `VerifiedProvider`** and is refused
425
+ `provider_not_verified` by `buildHire`.
426
+ * Two copies of this package in one process keep two tables, so a provider
427
+ verified through one is refused by the other.
428
+
429
+ **Keep the value in memory for the life of the hire, or keep the raw manifest
430
+ document and call `verifyProvider` on it again** — verifying a document you
431
+ already hold, with no fetch, is exactly what that export is for. Re-verifying is
432
+ cheap; it is signature checking over one small document.
433
+
434
+ ---
435
+
436
+ ## What `ok:true` does not mean
437
+
438
+ Every `validate*` on this surface answers about **structure** and about nothing
439
+ else. None of them checks a signature. Where this package publishes something
440
+ stronger about the same artifact, **only the stronger one ships** — which is why
441
+ you will find `verifyDeliveryReceipt` here and no `validateDeliveryReceipt`, and
442
+ `authenticateHireAcceptance` here and no `validateHireAccepted`.
443
+
444
+ One exception, named rather than hidden: `validateRedemptionAttestation`'s
445
+ artifact **is** signed, by the rail, and this package has no authenticated source
446
+ for the rail's signing key — so there is no stronger call it could offer instead.
447
+ `ok:true` from it is a statement about shape and carries no claim that the rail
448
+ issued the thing.
449
+
450
+ **Nine validators ship**, and the rule that decided each one is: if this surface
451
+ publishes a call taking THE SAME SINGLE ARTIFACT and answering a strictly
452
+ stronger question, only the stronger one ships. A tenth,
453
+ `validateDeliveryReceipt`, was removed under exactly that rule — `verifyDeliveryReceipt`
454
+ takes the same receipt and additionally checks the provider's signature. The
455
+ row-by-row working lives in the source header, which is not in this package:
456
+ `files` is `["dist", "README.md", "LICENSE", "NOTICE"]`, so what you have
457
+ installed is the bundle, the declarations, this document and the grant.
458
+
459
+ ---
460
+
461
+ ## The surface
462
+
463
+ | | |
464
+ |---|---|
465
+ | **Discovery** | `fetchVerifiedProvider` · `verifyProvider` · `isVerifiedProvider` · `PROVIDER_MANIFEST_KEYS` |
466
+ | **Hirer** | `buildHire` · `submitHire` · `authenticateHireAcceptance` · `buildHireMessage` · `signHireAuthorization` · `verifyDeliveryReceipt` · `hashArtifact` |
467
+ | **Recovery** | `recoverResult` (the door) · `openDeliveredResult` · `buildRecoveryRequest` · `postRecover` · `validateRecoveryRequest` |
468
+ | **Payment** | `buildReceivePaymentAuthorization` · `buildTransferPaymentAuthorization` · `settlementNonce` · `buildReceiveAuthorizationTypedData` · `EVM_USDC_EIP712_DOMAINS` · `x402SessionEvidence` |
469
+ | **Submission** | `payForGrant` (the door) · `signReceiveAuthorization` · `createFacilitatorSubmitter` · `createSelfSubmitter` · `preflightFacilitator` · the calldata and x402 payload builders |
470
+ | **Relay** | `createReadOnlyEvmRpc` · `createPayeeRelayBroadcaster` · `simulateTransaction` · `estimateRelayCost` · `resolveSettlementTransaction` · `checkSingleAuthorizationRelay` |
471
+ | **Settlement** | `submitSettlementHint` (the door) · `buildSettlementHint` · `settlementBindingReference` · `SETTLEMENT_BINDING_DOMAIN` |
472
+ | **Provider** | `reviewHire` · `acceptHire` · `openBrief` · `sealTaskResult` · `signDelivery` · `buildRedemptionProofHeader` |
473
+ | **Primitives** | `canonicalBytes` · `envelopeHash` · `signCanonical` · `verifyDetached` · `timestampMs` · `deriveDidFromSigningKey` · the CAIP predicates and the schema bounds |
474
+
475
+ Every validator returns `Validated<T, Reason>` — `{ ok: true, env }` or
476
+ `{ ok: false, reason }`. Nothing throws for a protocol refusal.
477
+
478
+ ---
479
+
480
+ ## Identity
481
+
482
+ `did:voidly:<base58>` derived from an Ed25519 signing key. Signatures are
483
+ detached Ed25519 over a canonical JSON encoding (`canonicalBytes`), which both
484
+ sides compute with the code in this package — that is the point of publishing it.
485
+ Bring your own key: nothing here mints, stores or transmits a private key, and
486
+ every signer is an injected `(bytes: Uint8Array) => Uint8Array`.
487
+
488
+ ---
489
+
490
+ ## Licence
491
+
492
+ Apache-2.0. `LICENSE` carries the full text and `NOTICE` carries the
493
+ attribution required by section 4(d); both ship inside the tarball, so the
494
+ grant travels with the bytes rather than living only in the manifest.
495
+
496
+ The grant covers a patent licence and terminates for anyone who brings a
497
+ patent action over this work. Trademarks are not granted — section 6.
498
+
499
+ The two runtime dependencies, `tweetnacl` and `tweetnacl-util`, are public
500
+ domain (Unlicense) and are marked external at build time, so the published
501
+ bundle contains no third-party code.
@@ -0,0 +1,86 @@
1
+ // @voidly/session — GENERATED by scripts/build-types.mjs. Do not edit.
2
+ //
3
+ // The transitive closure of what src/breakEven.ts exports, flattened into one
4
+ // module. Server declarations are absent because nothing here reaches them.
5
+
6
+ export type BreakEvenRefusal = "gas_price_unreadable" | "l1_fee_unreadable" | "native_price_unreadable" | "native_price_not_positive" | "native_price_stale";
7
+ export type BreakEvenResult = {
8
+ readonly ok: true;
9
+ readonly breakEven: bigint;
10
+ readonly costWei: bigint;
11
+ } | {
12
+ readonly ok: false;
13
+ readonly reason: BreakEvenRefusal;
14
+ readonly detail: string;
15
+ };
16
+ export declare function breakEvenSmallestUnits(facts: RelayCostFacts): bigint;
17
+ export declare const CHAINLINK_ETH_USD_BASE = "0x71041dddad3595f9ced3dccfbe3d1f4b0a16bb70";
18
+ export declare function checkConfiguredFloor(input: {
19
+ readonly configuredMinAmount: string;
20
+ readonly facts: RelayCostFacts;
21
+ readonly multiple: bigint;
22
+ }): ConfiguredFloorVerdict;
23
+ export declare function checkOfferedAmount(input: {
24
+ readonly amount: string;
25
+ readonly facts: RelayCostFacts;
26
+ readonly multiple: bigint;
27
+ }): OfferedAmountVerdict;
28
+ export type ConfiguredFloorVerdict = {
29
+ readonly ok: true;
30
+ } | {
31
+ readonly ok: false;
32
+ readonly reason: "configured_min_amount_below_break_even";
33
+ readonly detail: string;
34
+ };
35
+ export type FactsResult = {
36
+ readonly ok: true;
37
+ readonly facts: RelayCostFacts;
38
+ readonly updatedAtSeconds: bigint;
39
+ } | {
40
+ readonly ok: false;
41
+ readonly reason: BreakEvenRefusal;
42
+ readonly detail: string;
43
+ };
44
+ export declare const GAS_PRICE_ORACLE = "0x420000000000000000000000000000000000000f";
45
+ export type OfferedAmountVerdict = {
46
+ readonly ok: true;
47
+ readonly floor: bigint;
48
+ } | {
49
+ readonly ok: false;
50
+ readonly reason: "price_below_break_even";
51
+ readonly detail: string;
52
+ };
53
+ export declare const QUOTE_MULTIPLE = 12n;
54
+ interface ReadOnlyEvmRpc {
55
+ readonly url: string;
56
+ request(method: string, params: readonly unknown[]): Promise<RpcResult>;
57
+ }
58
+ export declare function readRelayCostFacts(input: {
59
+ readonly rpc: ReadOnlyEvmRpc;
60
+ readonly rawTransactionHex: string;
61
+ readonly priceFeed: string;
62
+ readonly nowSeconds: bigint;
63
+ readonly maxPriceAgeSeconds: bigint;
64
+ readonly gasUnits: bigint;
65
+ readonly assetDecimals: number;
66
+ }): Promise<FactsResult>;
67
+ export declare const RELAY_GAS_UNITS = 102883n;
68
+ export interface RelayCostFacts {
69
+ readonly gasPriceWei: bigint;
70
+ readonly gasUnits: bigint;
71
+ readonly l1DataFeeWei: bigint;
72
+ readonly nativeUsd: bigint;
73
+ readonly nativeUsdDecimals: number;
74
+ readonly assetDecimals: number;
75
+ }
76
+ export declare function relayFloorSmallestUnits(facts: RelayCostFacts, multiple: bigint): bigint;
77
+ type RpcRefusal = "rpc_method_not_read_only" | "rpc_url_not_https" | "rpc_unreachable" | "rpc_response_unreadable" | "rpc_error" | "rpc_result_malformed";
78
+ type RpcResult = {
79
+ readonly ok: true;
80
+ readonly result: unknown;
81
+ } | {
82
+ readonly ok: false;
83
+ readonly reason: RpcRefusal;
84
+ readonly detail: string;
85
+ };
86
+ export declare const WIRE_MULTIPLE = 3n;
@@ -0,0 +1 @@
1
+ var RELAY_GAS_UNITS=102883n,WIRE_MULTIPLE=3n,QUOTE_MULTIPLE=12n;function pow10(n){let out=1n;for(let i=0;i<n;i++)out*=10n;return out}function ceilDiv(a,b){return a%b===0n?a/b:a/b+1n}function breakEvenSmallestUnits(facts){let numerator=(facts.gasUnits*facts.gasPriceWei+facts.l1DataFeeWei)*facts.nativeUsd*pow10(facts.assetDecimals),denominator=pow10(18)*pow10(facts.nativeUsdDecimals);return ceilDiv(numerator,denominator)}function relayFloorSmallestUnits(facts,multiple){if(multiple<=0n)throw new Error("relayFloorSmallestUnits: multiple must be positive");return breakEvenSmallestUnits(facts)*multiple}function checkConfiguredFloor(input){let floor=relayFloorSmallestUnits(input.facts,input.multiple),configured;try{if(!/^[0-9]+$/.test(input.configuredMinAmount))throw new Error("not a decimal string");configured=BigInt(input.configuredMinAmount)}catch{return{ok:!1,reason:"configured_min_amount_below_break_even",detail:`minAmount ${JSON.stringify(input.configuredMinAmount)} is not a decimal string in the asset's smallest unit, so it cannot be compared with the derived floor of ${floor}.`}}if(configured>=floor)return{ok:!0};let breakEven=breakEvenSmallestUnits(input.facts);return{ok:!1,reason:"configured_min_amount_below_break_even",detail:`minAmount is set to ${configured} and one relay costs ${breakEven} right now (${input.facts.gasUnits} gas at ${input.facts.gasPriceWei} wei plus ${input.facts.l1DataFeeWei} wei of L1 data fee, at ${input.facts.nativeUsd} / 1e${input.facts.nativeUsdDecimals} per native unit). At ${input.multiple}x that is a floor of ${floor}. Serving at ${configured} pays ${configured} of the asset in and spends the equivalent of ${breakEven} of native currency out on every job, which shows up as revenue growth until the gas wallet empties and every accepted row answers relayer_cannot_pay_gas at once. Set VOIDLY_PRICE_MIN_AMOUNT to at least ${floor}.`}}function checkOfferedAmount(input){let floor=relayFloorSmallestUnits(input.facts,input.multiple),amount;try{if(!/^[0-9]+$/.test(input.amount))throw new Error("not a decimal string");amount=BigInt(input.amount)}catch{return{ok:!1,reason:"price_below_break_even",detail:`amount ${JSON.stringify(input.amount)} is not a decimal string; refused as below the floor of ${floor}.`}}return amount>=floor?{ok:!0,floor}:{ok:!1,reason:"price_below_break_even",detail:`this hire pays ${amount} and relaying it costs ${breakEvenSmallestUnits(input.facts)} at the chain state read a moment ago, so the floor is ${floor}. Accepting would countersign a promise to lose money on a job that has not been done yet.`}}var GAS_PRICE_ORACLE="0x420000000000000000000000000000000000000f",CHAINLINK_ETH_USD_BASE="0x71041dddad3595f9ced3dccfbe3d1f4b0a16bb70",SEL_LATEST_ROUND_DATA="0xfeaf968c",SEL_GET_L1_FEE="0x49948e0e";function words(hex){let body=hex.startsWith("0x")?hex.slice(2):hex,out=[];for(let i=0;i+64<=body.length;i+=64)out.push(BigInt("0x"+body.slice(i,i+64)));return out}function asInt256(u){return u>=1n<<255n?u-(1n<<256n):u}function encodeBytesArg(rawHex){let body=rawHex.startsWith("0x")?rawHex.slice(2):rawHex,len=body.length/2,pad="0".repeat((32-len%32)%32*2);return 32n.toString(16).padStart(64,"0")+BigInt(len).toString(16).padStart(64,"0")+body+pad}async function readRelayCostFacts(input){let priceRes=await input.rpc.request("eth_gasPrice",[]);if(!priceRes.ok||typeof priceRes.result!="string")return{ok:!1,reason:"gas_price_unreadable",detail:priceRes.ok?`unreadable gas price ${String(priceRes.result)}`:`${priceRes.reason}: ${priceRes.detail}`};let gasPriceWei;try{gasPriceWei=BigInt(priceRes.result)}catch{return{ok:!1,reason:"gas_price_unreadable",detail:`unreadable gas price ${priceRes.result}`}}let l1Res=await input.rpc.request("eth_call",[{to:GAS_PRICE_ORACLE,data:SEL_GET_L1_FEE+encodeBytesArg(input.rawTransactionHex)},"latest"]);if(!l1Res.ok||typeof l1Res.result!="string"||words(l1Res.result).length<1)return{ok:!1,reason:"l1_fee_unreadable",detail:l1Res.ok?`GasPriceOracle.getL1Fee returned ${String(l1Res.result)}`:`${l1Res.reason}: ${l1Res.detail}`};let l1DataFeeWei=words(l1Res.result)[0],feedRes=await input.rpc.request("eth_call",[{to:input.priceFeed,data:SEL_LATEST_ROUND_DATA},"latest"]);if(!feedRes.ok||typeof feedRes.result!="string"||words(feedRes.result).length<5)return{ok:!1,reason:"native_price_unreadable",detail:feedRes.ok?`latestRoundData returned ${String(feedRes.result)}`:`${feedRes.reason}: ${feedRes.detail}`};let w=words(feedRes.result),answer=asInt256(w[1]),updatedAtSeconds=w[3];if(answer<=0n)return{ok:!1,reason:"native_price_not_positive",detail:`the price feed answered ${answer}. A non-positive price would make the floor zero or negative, which is the one value that cannot be refused against.`};if(updatedAtSeconds===0n||input.nowSeconds-updatedAtSeconds>input.maxPriceAgeSeconds)return{ok:!1,reason:"native_price_stale",detail:`the price feed last updated at UNIX second ${updatedAtSeconds} and the clock reads ${input.nowSeconds}, which is beyond the ${input.maxPriceAgeSeconds}s the caller allows. A stale price silently freezes the floor at whatever the token was worth when the feed stopped.`};let decRes=await input.rpc.request("eth_call",[{to:input.priceFeed,data:"0x313ce567"},"latest"]);return!decRes.ok||typeof decRes.result!="string"||words(decRes.result).length<1?{ok:!1,reason:"native_price_unreadable",detail:decRes.ok?`decimals() returned ${String(decRes.result)}`:`${decRes.reason}: ${decRes.detail}`}:{ok:!0,updatedAtSeconds,facts:{gasPriceWei,gasUnits:input.gasUnits,l1DataFeeWei,nativeUsd:answer,nativeUsdDecimals:Number(words(decRes.result)[0]),assetDecimals:input.assetDecimals}}}export{CHAINLINK_ETH_USD_BASE,GAS_PRICE_ORACLE,QUOTE_MULTIPLE,RELAY_GAS_UNITS,WIRE_MULTIPLE,breakEvenSmallestUnits,checkConfiguredFloor,checkOfferedAmount,readRelayCostFacts,relayFloorSmallestUnits};