@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/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +501 -0
- package/dist/breakEven.d.ts +86 -0
- package/dist/breakEven.mjs +1 -0
- package/dist/index.d.ts +1348 -0
- package/dist/index.mjs +1 -0
- package/package.json +62 -0
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};
|