agent-receipt-verify 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CONFORMANCE.md +110 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +39 -0
  4. package/README.md +273 -2
  5. package/bench/eip3009-beleid.mjs +113 -0
  6. package/bench/robustness.mjs +264 -0
  7. package/bin/cli.mjs +95 -0
  8. package/examples/receipt-live-base-sepolia-2.json +21 -0
  9. package/examples/receipt-live-base-sepolia-3.json +21 -0
  10. package/examples/receipt-live-base-sepolia.json +21 -0
  11. package/package.json +61 -4
  12. package/profiles/profile.schema.json +115 -0
  13. package/profiles/x402-deployed-ladder-v1.json +178 -0
  14. package/src/eip3009-autorisatie.mjs +228 -0
  15. package/src/inam-countersigned.mjs +162 -0
  16. package/src/mcp-server.mjs +262 -0
  17. package/src/profiel.mjs +145 -0
  18. package/src/x402-conformance-check.mjs +194 -0
  19. package/src/x402-conformance-treden.mjs +246 -0
  20. package/src/x402-discovery-census.mjs +247 -0
  21. package/src/x402-index-liveness.mjs +201 -0
  22. package/src/x402-link-onchain.mjs +280 -0
  23. package/src/x402-metingen-bundel.mjs +96 -0
  24. package/src/x402-offer-receipt.mjs +84 -0
  25. package/src/x402-poortwachters.mjs +241 -0
  26. package/tools/bundel-bouw.mjs +126 -0
  27. package/tools/pack-inhoud.mjs +96 -0
  28. package/vectors/inam/BRON-README.md +38 -0
  29. package/vectors/inam/canonical-vectors.json +122 -0
  30. package/vectors/inam/vectors.json +96 -0
  31. package/vectors/kanon/amount-insufficient-001.json +52 -0
  32. package/vectors/kanon/cross-chain-replay-001.json +54 -0
  33. package/vectors/kanon/cross-contract-replay-001.json +54 -0
  34. package/vectors/kanon/domain-name-mismatch-001.json +53 -0
  35. package/vectors/kanon/expired-001.json +51 -0
  36. package/vectors/kanon/network-mismatch-001.json +52 -0
  37. package/vectors/kanon/nonce-replay-001.json +56 -0
  38. package/vectors/kanon/not-yet-valid-001.json +51 -0
  39. package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
  40. package/vectors/kanon/valid-baseline-001.json +52 -0
@@ -0,0 +1,201 @@
1
+ // Van de hosts die in een discovery-index STAAN, hoeveel antwoorden er vandaag
2
+ // werkelijk zoals x402 voorschrijft.
3
+ //
4
+ // WAAROM DEZE VRAAG, en niet de vraag waar de ladder voor gebouwd is. De census
5
+ // (x402-discovery-census.mjs) heeft op 2026-10-17 geteld dat nul van de 1127 hosts
6
+ // de offer-receipt-extensie declareert. Trede 1 tot 5 van de ladder hebben dus
7
+ // geen populatie. Een uitslag daarover zou een tabel met nullen zijn plus ons
8
+ // eigen eindpunt, en dat is geen meting maar een advertentie. Trede 0 heeft wel
9
+ // een populatie, en die vraag is nog door niemand gepubliceerd: een registratie
10
+ // in een index zegt niets over of het eindpunt nog leeft.
11
+ //
12
+ // DE STEEKPROEFREGEL STAAT HIER, zodat iemand anders exact dezelfde hosts kan
13
+ // natrekken en kan zien waar hij afwijkt:
14
+ // 1. Per host de resource met de LAAGSTE index-positie (de census bepaalt die).
15
+ // 2. Hosts gesorteerd op die positie, oplopend.
16
+ // 3. Daaruit elke n-de, met n zo gekozen dat de steekproef over de hele
17
+ // registratievolgorde ligt. Niet de eerste N: die zijn van een handvol
18
+ // partijen die vroeg massaal aanmeldden, en dat meet dan die partijen in
19
+ // plaats van de index.
20
+ // 4. De methode die de partij ZELF declareert in de index. Blind POST sturen
21
+ // laat een GET-route zakken op een 405 die niets over x402 zegt.
22
+ //
23
+ // TWEE CONTROLES, want een meting waarin alles faalt bewijst niet dat de
24
+ // werkelijkheid faalt, hij kan ook bewijzen dat het meetgereedschap kapot is:
25
+ // - positief: een eindpunt waarvan wij weten dat het trede 2 haalt.
26
+ // - negatief: een gewone pagina die niets met x402 doet.
27
+ // Gaan die twee niet zoals verwacht, dan is de uitslag ongeldig en zegt het
28
+ // script dat, in plaats van de cijfers alsnog op te leveren.
29
+
30
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
31
+ import { dirname } from 'node:path'
32
+ import { meetEindpunt, hoogsteTrede } from './x402-conformance-check.mjs'
33
+
34
+ const POSITIEVE_CONTROLE = { url: 'https://api.tokenizen.nl/api/paid/verify-ownership', methode: 'POST', verwacht: 2 }
35
+ const NEGATIEVE_CONTROLE = { url: 'https://example.com/', methode: 'GET', verwacht: -1 }
36
+
37
+ const slaap = (ms) => new Promise((r) => setTimeout(r, ms))
38
+
39
+ /**
40
+ * Vult sjabloon-parameters in een resource-URL met de waarden die de index zelf geeft.
41
+ *
42
+ * TOEGEVOEGD NA EEN EIGEN FOUT, 2026-10-17. De index bevat URL's als
43
+ * "https://api.apibooth.com/v1/ip/:ip" en wij vroegen die LETTERLIJK op, inclusief
44
+ * de dubbele punt. Dat gaf een 400 die wij bijna als "weigert het verzoek" hadden
45
+ * gepubliceerd, terwijl de index de waarde ("8.8.8.8") ernaast vermeldde. Dat is je
46
+ * eigen verzoek meten in plaats van het eindpunt.
47
+ *
48
+ * Geeft {url, ingevuld, resterend} terug: resterend is wat NIET ingevuld kon worden,
49
+ * zodat een meting op een half-ingevuld sjabloon zichzelf kan diskwalificeren in
50
+ * plaats van een getal te produceren dat nergens op rust.
51
+ */
52
+ export function vulSjabloon(url, pathParams) {
53
+ const gevonden = [...String(url).matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1])
54
+ const uniek = [...new Set(gevonden)]
55
+ if (uniek.length === 0) return { url, ingevuld: [], resterend: [] }
56
+ let uit = url
57
+ const ingevuld = []
58
+ const resterend = []
59
+ for (const naam of uniek) {
60
+ const waarde = pathParams?.[naam]
61
+ if (waarde === undefined || waarde === null || String(waarde) === '') {
62
+ resterend.push(naam)
63
+ continue
64
+ }
65
+ uit = uit.split(':' + naam).join(encodeURIComponent(String(waarde)))
66
+ ingevuld.push(naam)
67
+ }
68
+ return { url: uit, ingevuld, resterend }
69
+ }
70
+
71
+ /** Stap 1 tot 3 van de steekproefregel hierboven, puur rekenwerk, geen netwerk. */
72
+ export function kiesSteekproef(operators, aantal) {
73
+ const opPositie = [...operators].sort((a, b) => a.eersteOffset - b.eersteOffset)
74
+ if (aantal >= opPositie.length) return opPositie
75
+ const stap = opPositie.length / aantal
76
+ const uit = []
77
+ for (let i = 0; i < aantal; i++) uit.push(opPositie[Math.floor(i * stap)])
78
+ return uit
79
+ }
80
+
81
+ /**
82
+ * Verdeelt een meting in emmers die NIET door elkaar lopen.
83
+ *
84
+ * Het verschil tussen "doet geen x402" en "wees mijn verzoek af voor het ooit bij
85
+ * de betaalmuur kwam" is echt, en een meting die beide als mislukking telt is met
86
+ * een enkele controle onderuit te halen. Een correcte implementatie geeft zijn
87
+ * 402 voor hij een lichaam valideert; niet iedereen doet dat.
88
+ */
89
+ export function emmer(meting) {
90
+ const trede = hoogsteTrede(meting)
91
+ if (trede >= 0) return 'x402_ok'
92
+ const s = meting.bewijs?.status
93
+ if (s == null) return 'onbereikbaar'
94
+ if (s === 402) return 'status_402_onbruikbaar'
95
+ if (s === 401 || s === 403) return 'afgeschermd'
96
+ if (s === 404 || s === 410) return 'pad_weg'
97
+ if (s === 405) return 'methode_geweigerd'
98
+ if (s === 429) return 'tempo_geweigerd'
99
+ if (s >= 400 && s < 500) return 'verzoek_geweigerd'
100
+ if (s >= 500) return 'serverfout'
101
+ return 'geen_betaalmuur'
102
+ }
103
+
104
+ export async function meetSteekproef(gekozen, { pauzeMs = 1200, fetchImpl = fetch } = {}) {
105
+ const uit = []
106
+ for (const o of gekozen) {
107
+ const methode = o.eersteMethode ?? 'POST'
108
+ const sjab = vulSjabloon(o.eersteResource, o.eersteInvoer?.pathParams)
109
+ const m = await meetEindpunt(sjab.url, { methode, fetchImpl, lichaam: o.eersteInvoer?.body ?? null })
110
+ uit.push({
111
+ host: o.host,
112
+ url: sjab.url,
113
+ urlInIndex: o.eersteResource,
114
+ sjabloonIngevuld: sjab.ingevuld,
115
+ sjabloonNietIngevuld: sjab.resterend,
116
+ lichaamUitIndex: o.eersteInvoer?.body != null,
117
+ indexPositie: o.eersteOffset,
118
+ registratiesVanDezeHost: o.aantalRegistraties,
119
+ gedeclareerdeMethode: o.eersteMethode,
120
+ gebruikteMethode: methode,
121
+ status: m.bewijs?.status ?? null,
122
+ hoogsteTrede: hoogsteTrede(m),
123
+ emmer: emmer(m),
124
+ treden: m.treden,
125
+ bewijs: m.bewijs,
126
+ })
127
+ if (pauzeMs) await slaap(pauzeMs)
128
+ }
129
+ return uit
130
+ }
131
+
132
+ if (process.argv[1]?.endsWith('x402-index-liveness.mjs')) {
133
+ const censusPad = process.argv[2]
134
+ const aantal = Number(process.argv[3] ?? 50)
135
+ const uitPad = process.argv[4]
136
+ if (!censusPad) {
137
+ console.log('usage: node src/x402-index-liveness.mjs <census.json> [sample size] [out.json]')
138
+ process.exitCode = 2
139
+ } else {
140
+ const census = JSON.parse(readFileSync(censusPad, 'utf8'))
141
+ const gekozen = kiesSteekproef(census.operators, aantal)
142
+
143
+ console.log('--- controls first, because a sweep where everything fails proves nothing ---')
144
+ const controles = []
145
+ for (const c of [POSITIEVE_CONTROLE, NEGATIEVE_CONTROLE]) {
146
+ const m = await meetEindpunt(c.url, { methode: c.methode })
147
+ const t = hoogsteTrede(m)
148
+ const ok = t === c.verwacht
149
+ controles.push({ ...c, gemeten: t, emmer: emmer(m), status: m.bewijs?.status ?? null, ok })
150
+ console.log((ok ? ' ok ' : ' FAIL ') + c.url + ' expected L' + c.verwacht + ', measured L' + t)
151
+ await slaap(1200)
152
+ }
153
+ const controlesGoed = controles.every((c) => c.ok)
154
+ if (!controlesGoed) {
155
+ console.log('')
156
+ console.log('The harness did not behave as expected on the controls. Treat the numbers below')
157
+ console.log('as invalid until that is explained. A measurement you cannot trust on a known')
158
+ console.log('case tells you nothing about the unknown ones.')
159
+ }
160
+
161
+ console.log('')
162
+ console.log('--- sample: ' + gekozen.length + ' of ' + census.operators.length + ' hosts ---')
163
+ const metingen = await meetSteekproef(gekozen)
164
+
165
+ const tel = new Map()
166
+ for (const m of metingen) tel.set(m.emmer, (tel.get(m.emmer) ?? 0) + 1)
167
+ for (const m of metingen) {
168
+ console.log(
169
+ (m.hoogsteTrede >= 0 ? 'L' + m.hoogsteTrede : '--') +
170
+ ' ' + String(m.status ?? '---').padStart(3) +
171
+ ' ' + m.emmer.padEnd(22) +
172
+ ' ' + m.host
173
+ )
174
+ }
175
+ console.log('')
176
+ for (const [k, v] of [...tel].sort((a, b) => b[1] - a[1])) {
177
+ console.log(' ' + String(v).padStart(4) + ' ' + k + ' (' + ((v / metingen.length) * 100).toFixed(1) + '%)')
178
+ }
179
+ const ok = metingen.filter((m) => m.hoogsteTrede >= 0).length
180
+ console.log('')
181
+ console.log(' ' + ok + ' of ' + metingen.length + ' sampled hosts answer the way x402 says (level 0)')
182
+ console.log(' levels 1 to 5 are not reported here: the census found 0 of ' + census.operators.length +
183
+ ' hosts declaring the offer-receipt extension, so there is no population to measure')
184
+
185
+ if (uitPad) {
186
+ mkdirSync(dirname(uitPad), { recursive: true })
187
+ writeFileSync(uitPad, JSON.stringify({
188
+ bron: census.bron,
189
+ steekproefregel: 'per host the resource with the lowest index position; hosts sorted by that position ascending; every nth taken so the sample spans the whole registration order; the method each party declares in the index',
190
+ hostsInCensus: census.operators.length,
191
+ steekproefGrootte: metingen.length,
192
+ controles,
193
+ controlesGoed,
194
+ emmers: Object.fromEntries(tel),
195
+ metingen,
196
+ }, null, 2))
197
+ console.log('')
198
+ console.log('written: ' + uitPad)
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,280 @@
1
+ // De tweede helft van 5.6.4: het commitment openen tegen de KETEN zelf.
2
+ //
3
+ // WAT HIER HET VERSCHIL MAAKT. Het commitment narekenen kan iedereen die het
4
+ // zout en de nonce al heeft. Maar wie alleen een bonnetje en een zout krijgt
5
+ // toegestuurd, HEEFT die nonce niet. 5.6.4 zegt daarom: vraag de keten welke
6
+ // nonces deze betaler ooit heeft verbruikt, en reken het commitment na voor elke
7
+ // kandidaat. Precies een MOET matchen. Dat is het moment waarop een bewering van
8
+ // de ondertekenaar verandert in bewijs, en volgens 5.6.5 is dat ook het enige
9
+ // moment waarop een teller een afrekening mag meetellen.
10
+ //
11
+ // GEEN SLEUTEL, GEEN ACCOUNT, GEEN PAKKETTEN. Alleen een publieke RPC en de
12
+ // ingebouwde fetch. Dat is bewust: een controle die een API-sleutel vereist, is
13
+ // voor een vreemde geen controle maar een drempel.
14
+ //
15
+ // HET ZOEKVENSTER, en waarom het niet "de hele keten" is. De publieke Base
16
+ // Sepolia-RPC staat per aanroep maar 200 blokken toe, GEMETEN en niet uit
17
+ // documentatie. Terugscannen over een dag zou dus ruim 200 aanroepen kosten. Een
18
+ // bonnetje draagt echter zijn eigen tijdstip (issuedAt), en daar zoeken we
19
+ // SYMMETRISCH rond.
20
+ //
21
+ // Symmetrisch, en niet alleen ervoor, en dat is een gecorrigeerde aanname. Een
22
+ // eerdere versie van dit commentaar beweerde dat de afrekening per definitie
23
+ // vlak VOOR het bonnetje ligt. Dat is onjuist: volgens 5.6.3 houdt de verkoper
24
+ // de nonce al vast voordat de afrekening draait, dus een versie-2-bonnetje kan
25
+ // ondertekend zijn voordat zijn betaling op de keten staat. De code zocht al
26
+ // symmetrisch, de uitleg erbij was fout. Gecorrigeerd door babyblueviper1 op
27
+ // x402#3827, en het staat hier omdat de volgende lezer anders dezelfde verkeerde
28
+ // reden overneemt.
29
+ //
30
+ // We melden ALTIJD welk venster is afgezocht, zodat een lege uitslag leest als
31
+ // "niet in dit venster" en nooit als "bestaat niet". Verbreden gaat via
32
+ // --window, en dat is ook wat de spec-notitie nu aanraadt bij geen match.
33
+
34
+ import { readFileSync, existsSync } from 'node:fs'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { opentCommitment } from './x402-offer-receipt.mjs'
37
+
38
+ // keccak256("AuthorizationUsed(address,bytes32)"). Hardgecodeerd omdat dit
39
+ // pakket geen afhankelijkheden heeft en een keccak-implementatie meeslepen meer
40
+ // risico is dan deze ene constante. Zelf na te rekenen met een regel ethers:
41
+ // ethers.id('AuthorizationUsed(address,bytes32)')
42
+ export const AUTHORIZATION_USED_TOPIC =
43
+ '0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5'
44
+
45
+ const hex = n => '0x' + Number(n).toString(16)
46
+ const getal = h => parseInt(h, 16)
47
+
48
+ /** Een adres als 32-byte topic. Een onjuiste lengte zou stil op niets matchen. */
49
+ export function adresAlsTopic(adres) {
50
+ const kaal = String(adres).replace(/^0x/i, '').toLowerCase()
51
+ if (!/^[0-9a-f]{40}$/.test(kaal)) throw new Error(`geen adres: ${adres}`)
52
+ return '0x' + kaal.padStart(64, '0')
53
+ }
54
+
55
+ export function maakRpc(url, fetchImpl = fetch) {
56
+ let id = 0
57
+ return async function rpc(method, params) {
58
+ const r = await fetchImpl(url, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }),
62
+ signal: AbortSignal.timeout(25000),
63
+ })
64
+ const j = await r.json()
65
+ if (j.error) throw new Error(`${method}: ${j.error.message}`)
66
+ return j.result
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Zoek het blok dat bij een tijdstip hoort, door te meten in plaats van te rekenen.
72
+ *
73
+ * Een vaste bloktijd aannemen is precies het soort gok dat later stil misgaat:
74
+ * ketens veranderen hun tempo. Daarom eerst twee echte blokken meten voor een
75
+ * eerste schatting, en die daarna corrigeren tot het verschil klein genoeg is.
76
+ */
77
+ export async function blokBijTijd(rpc, doelTijd, maxRondes = 6) {
78
+ const laatsteNr = getal(await rpc('eth_blockNumber', []))
79
+ const laatste = await rpc('eth_getBlockByNumber', [hex(laatsteNr), false])
80
+ const eerder = await rpc('eth_getBlockByNumber', [hex(laatsteNr - 1000), false])
81
+ const blokTijd = (getal(laatste.timestamp) - getal(eerder.timestamp)) / 1000
82
+ if (!(blokTijd > 0)) throw new Error('kon de bloktijd niet meten')
83
+
84
+ let kandidaat = laatsteNr - Math.round((getal(laatste.timestamp) - doelTijd) / blokTijd)
85
+ kandidaat = Math.max(1, Math.min(kandidaat, laatsteNr))
86
+ for (let i = 0; i < maxRondes; i++) {
87
+ const b = await rpc('eth_getBlockByNumber', [hex(kandidaat), false])
88
+ const verschil = getal(b.timestamp) - doelTijd
89
+ if (Math.abs(verschil) <= blokTijd * 2) break
90
+ const stap = Math.round(verschil / blokTijd)
91
+ const volgende = Math.max(1, Math.min(kandidaat - stap, laatsteNr))
92
+ if (volgende === kandidaat) break
93
+ kandidaat = volgende
94
+ }
95
+ return { blok: kandidaat, blokTijd, laatsteBlok: laatsteNr }
96
+ }
97
+
98
+ /**
99
+ * Alle nonces die deze autorisator in dit venster heeft verbruikt.
100
+ *
101
+ * In stukken van `stap` blokken, want de publieke RPC weigert een groter bereik.
102
+ * Een geweigerde aanroep wordt NOOIT als "geen treffers" doorgegeven: dan stopt
103
+ * dit met een fout, zodat een onmeetbaar venster niet als leeg venster leest.
104
+ */
105
+ export async function haalKandidaten(rpc, { asset, authorizer, vanBlok, totBlok, stap = 200, maxAanroepen = 200 }) {
106
+ // Een bovengrens op het aantal aanroepen, want een te ruim venster is hier
107
+ // geen trage controle maar een onbedoelde aanval op een publieke dienst: bij
108
+ // 200 blokken per aanroep kost een venster van een miljoen blokken vijfduizend
109
+ // verzoeken. Liever vooraf stoppen met een duidelijke reden dan iemands IP
110
+ // laten blokkeren door ons gereedschap.
111
+ const nodig = Math.ceil((totBlok - vanBlok + 1) / stap)
112
+ if (nodig > maxAanroepen) {
113
+ throw new Error(
114
+ `that window needs ${nodig} RPC calls (limit ${maxAanroepen}); narrow it, or raise maxAanroepen deliberately`,
115
+ )
116
+ }
117
+ const topics = [AUTHORIZATION_USED_TOPIC, adresAlsTopic(authorizer)]
118
+ const nonces = []
119
+ let aanroepen = 0
120
+ for (let van = vanBlok; van <= totBlok; van += stap) {
121
+ const tot = Math.min(van + stap - 1, totBlok)
122
+ const logs = await rpc('eth_getLogs', [{ address: asset, topics, fromBlock: hex(van), toBlock: hex(tot) }])
123
+ aanroepen++
124
+ for (const l of logs) {
125
+ // Drie topics: de gebeurtenis zelf plus twee geindexeerde velden. Een log
126
+ // met een andere vorm hoort niet bij deze gebeurtenis en wordt genegeerd
127
+ // in plaats van half geinterpreteerd.
128
+ if (!Array.isArray(l.topics) || l.topics.length !== 3) continue
129
+ nonces.push({ nonce: l.topics[2], blok: getal(l.blockNumber), tx: l.transactionHash })
130
+ }
131
+ }
132
+ return { nonces, aanroepen }
133
+ }
134
+
135
+ /** Het volledige 5.6.4-pad: van bonnetje plus zout naar precies een afrekening. */
136
+ export async function linkBonnetje(rpc, { payload, salt, asset, venster = 400 }) {
137
+ if (!payload?.payer) throw new Error('het bonnetje heeft geen payer, dus er is geen autorisator')
138
+ const issuedAt = Number(payload.issuedAt)
139
+ if (!Number.isFinite(issuedAt) || issuedAt <= 0) throw new Error('het bonnetje heeft geen bruikbare issuedAt')
140
+
141
+ const { blok, blokTijd, laatsteBlok } = await blokBijTijd(rpc, issuedAt)
142
+ const vanBlok = Math.max(1, blok - venster)
143
+ const totBlok = Math.min(laatsteBlok, blok + venster)
144
+
145
+ const { nonces, aanroepen } = await haalKandidaten(rpc, {
146
+ asset,
147
+ // 5.6.4 stap 1: de autorisator komt uit het ONDERTEKENDE payer-veld. Nooit
148
+ // uit een waarde die naast het zout is aangeleverd, want dan kan iemand het
149
+ // commitment laten openen op de afrekening van een ander.
150
+ authorizer: payload.payer,
151
+ vanBlok,
152
+ totBlok,
153
+ })
154
+
155
+ const uitslag = opentCommitment({
156
+ receipt: { payload },
157
+ salt,
158
+ asset,
159
+ kandidaatIdentifiers: nonces.map(n => n.nonce),
160
+ })
161
+ const treffer = uitslag.linked ? nonces.find(n => n.nonce.toLowerCase() === uitslag.identifier.toLowerCase()) : null
162
+
163
+ return {
164
+ ...uitslag,
165
+ venster: { vanBlok, totBlok, blokTijd, aanroepen, kandidaten: nonces.length },
166
+ afrekening: treffer ?? null,
167
+ }
168
+ }
169
+
170
+ // ── commandoregel ────────────────────────────────────────────────────────────
171
+ //
172
+ // De vlaggen en de uitslag zijn ENGELS, het commentaar hierboven Nederlands. Dat
173
+ // is geen slordigheid: dit gereedschap is bedoeld om door een vreemde in een
174
+ // protocoldraad gedraaid te worden, en een uitslag die hij niet kan lezen is
175
+ // geen controle. De Nederlandse namen blijven wel werken.
176
+
177
+ function vlag(namen, standaard) {
178
+ for (const naam of [].concat(namen)) {
179
+ const v = process.argv.find(a => a.startsWith(`--${naam}=`))
180
+ if (v) return v.slice(naam.length + 3)
181
+ }
182
+ return standaard
183
+ }
184
+
185
+ /**
186
+ * Waar ligt dit bonnetje: in de werkmap van de aanroeper, of meegeleverd met het pakket.
187
+ *
188
+ * WAAROM DIT BESTAAT. De drie live voorbeelden worden met het pakket meegeleverd, en
189
+ * elke publieke uitleg zegt `--receipt=examples/...`. Wie dat via npx draait staat in
190
+ * zijn EIGEN map, heeft daar geen examples/, en kreeg tot vandaag een kale
191
+ * ENOENT-stacktrace. Voor een gereedschap dat om narekenbaarheid vraagt is dat de
192
+ * ergste uitkomst die er is: het commando dat wij publiceren moet draaien bij de
193
+ * vreemde die het probeert, niet alleen bij ons.
194
+ *
195
+ * Een pad dat de aanroeper zelf meegeeft gaat ALTIJD voor. Pas als dat niet bestaat
196
+ * komt het meegeleverde bestand in beeld, en dan meldt de aanroeper dat ook, want stil
197
+ * een ander bestand lezen dan gevraagd is erger dan een foutmelding.
198
+ *
199
+ * Pure functie met een injecteerbare bestaat-check, zodat een test beide takken kan
200
+ * aantonen zonder bestanden aan te maken of het proces af te breken.
201
+ */
202
+ export function kiesBonnetjePad(pad, { bestaat = existsSync } = {}) {
203
+ if (bestaat(pad)) return { pad, uitPakket: false }
204
+ const inPakket = fileURLToPath(new URL('../' + String(pad).replace(/^\.?\//, ''), import.meta.url))
205
+ if (bestaat(inPakket)) return { pad: inPakket, uitPakket: true }
206
+ return null
207
+ }
208
+
209
+ if (process.argv[1]?.endsWith('x402-link-onchain.mjs')) {
210
+ const pad = vlag(['receipt', 'bonnetje'])
211
+ if (!pad) {
212
+ console.log(`usage: node src/x402-link-onchain.mjs --receipt=<file.json> [--salt=0x..] [--asset=0x..] [--rpc=<url>] [--window=400]
213
+
214
+ The file may be the bare artifact ({format,payload,signature}), or any JSON that
215
+ already stores the salt and the asset next to the receipt. Section 5.6.4 needs all
216
+ three: receipt, salt and asset. A receipt and a salt alone cannot link.`)
217
+ process.exit(2)
218
+ }
219
+
220
+ const gekozen = kiesBonnetjePad(pad)
221
+ if (!gekozen) {
222
+ console.log(`cannot find ${pad}, neither here nor inside the package.`)
223
+ console.log('The three live examples ship with it: examples/receipt-live-base-sepolia.json, -2.json and -3.json.')
224
+ process.exit(2)
225
+ }
226
+ if (gekozen.uitPakket) console.log(`(not found in the working directory, using the copy shipped with the package: ${pad})`)
227
+ const bestand = JSON.parse(readFileSync(gekozen.pad, 'utf8'))
228
+ const artefact = bestand.receipt ?? bestand.bonnetje ?? bestand
229
+ const payload = artefact?.payload ?? artefact
230
+ const salt = vlag(['salt', 'zout'], bestand.authorizationSalt ?? bestand.salt)
231
+ const asset = vlag('asset', bestand.asset)
232
+ const rpcUrl = vlag('rpc', 'https://sepolia.base.org')
233
+ const venster = Number(vlag(['window', 'venster'], '400'))
234
+
235
+ const ontbreekt = [
236
+ !payload?.payer && 'payer inside the receipt',
237
+ !salt && 'salt (--salt=)',
238
+ !asset && 'asset (--asset=)',
239
+ ].filter(Boolean)
240
+ if (ontbreekt.length) {
241
+ console.error('missing: ' + ontbreekt.join(', '))
242
+ process.exit(2)
243
+ }
244
+
245
+ console.log(`receipt : version ${payload.version}, payer ${payload.payer}`)
246
+ console.log(`commitment : ${payload.authorizationCommitment ?? 'none (version 1)'}`)
247
+ console.log(`asset : ${asset}`)
248
+ console.log(`rpc : ${rpcUrl}`)
249
+
250
+ const rpc = maakRpc(rpcUrl)
251
+
252
+ // Drie uitkomsten, drie exitcodes, en dat is precies het onderscheid dat
253
+ // 5.6.4 sinds faa611f eist: gelinkt, niet gelinkt op een VERKREGEN
254
+ // kandidatenlijst, of een controle die niet kon draaien. De module gooide al
255
+ // bij een geweigerde aanroep, maar een onbehandelde fout ziet er voor een
256
+ // script hetzelfde uit als "niet gelinkt", en dat is nu juist het verschil
257
+ // waarop een teller een bonnetje ten onrechte zou weggooien.
258
+ let r
259
+ try {
260
+ r = await linkBonnetje(rpc, { payload, salt, asset, venster })
261
+ } catch (e) {
262
+ console.log('LINKED : unknown, the check could not be completed')
263
+ console.log(` reason : ${e.message}`)
264
+ console.log(' note: this is an incomplete check, not a receipt that fails to link.')
265
+ process.exit(2)
266
+ }
267
+
268
+ console.log(`searched : blocks ${r.venster.vanBlok} to ${r.venster.totBlok} (${r.venster.aanroepen} calls, measured block time ${r.venster.blokTijd.toFixed(2)}s)`)
269
+ console.log(`candidates : ${r.venster.kandidaten} nonce(s) consumed by this payer in that window`)
270
+ if (r.linked) {
271
+ console.log('LINKED : yes, to exactly one settlement')
272
+ console.log(` nonce : ${r.identifier}`)
273
+ console.log(` tx : ${r.afrekening.tx}`)
274
+ console.log(` block : ${r.afrekening.blok}`)
275
+ process.exit(0)
276
+ }
277
+ console.log(`LINKED : no (${r.reden})`)
278
+ console.log(' note: that is a statement about the window searched, not about the whole chain.')
279
+ process.exit(1)
280
+ }
@@ -0,0 +1,96 @@
1
+ // Vouwt alle edities samen tot EEN platte lijst: welk eindpunt haalde welke trede,
2
+ // op welke datum, in welke editie.
3
+ //
4
+ // WAAROM DIT BESTAAT. Een badge die een operator op zijn eigen voordeur plakt, moet
5
+ // uit een VASTGELEGDE meting komen en nooit uit een live meting op het moment dat
6
+ // iemand de badge opvraagt. Twee redenen, en de tweede is de belangrijkste:
7
+ // - live meten betekent dat iedereen onze server kan gebruiken om willekeurige
8
+ // hosts af te tasten, op ons IP en in ons logboek. Dat is andermans rate limit
9
+ // en niet de onze om weg te geven.
10
+ // - een badge die bij elke pagina-weergave opnieuw meet, geeft bij elke storing
11
+ // een andere uitslag. Een cijfer dat per minuut verandert is geen meting.
12
+ //
13
+ // De prijs daarvan is dat een badge kan VEROUDEREN, en daar is maar een eerlijk
14
+ // antwoord op: de datum staat IN de badge. Dan is een oude badge zichtbaar oud in
15
+ // plaats van stil verkeerd, en dat is precies het patroon dat ons eerder een
16
+ // publieke tekst heeft gekost die weken iets onwaars beweerde.
17
+
18
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
19
+ import { resolve, join } from 'node:path'
20
+
21
+ const WORTEL = resolve(import.meta.dirname, '..')
22
+
23
+ /** Alle editie-mappen, nieuwste eerst. Een map is een editie als hij een datum heet. */
24
+ export function editieMappen(basis = join(WORTEL, 'editions')) {
25
+ if (!existsSync(basis)) return []
26
+ return readdirSync(basis)
27
+ .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d))
28
+ .sort()
29
+ .reverse()
30
+ }
31
+
32
+ /**
33
+ * Leest de live-metingen uit een editie.
34
+ *
35
+ * Een editie kan meerdere metingbestanden hebben, waarvan latere eerdere CORRIGEREN
36
+ * (zoals de hermeting na een ladderwijziging). De later gelezen waarde wint daarom,
37
+ * en de bestandsnaam waaruit een uitslag komt gaat mee in de bundel zodat niemand
38
+ * hoeft te raden welke van de twee hij ziet.
39
+ */
40
+ export function leesEditie(map) {
41
+ const uit = new Map()
42
+ const bestanden = ['liveness-payai.json', 'liveness-payai-na-ladderwijziging.json', 'liveness-payai-na-invoerfix.json']
43
+ for (const naam of bestanden) {
44
+ const pad = join(map, naam)
45
+ if (!existsSync(pad)) continue
46
+ let j
47
+ try { j = JSON.parse(readFileSync(pad, 'utf8')) } catch { continue }
48
+ for (const m of j.metingen ?? []) {
49
+ const trede = m.hoogsteTrede
50
+ if (typeof trede !== 'number') continue
51
+ uit.set(m.url, { url: m.url, host: m.host, trede, uitBestand: naam })
52
+ }
53
+ // De CONTROLES zijn ook echte metingen, en ze ontbraken hier.
54
+ //
55
+ // Gevonden door de badge live na te kijken: ons EIGEN eindpunt kwam terug als
56
+ // "niet gemeten", omdat het in die ronde de positieve controle was en controles
57
+ // niet in de metinglijst stonden. Een meetlat die zijn eigen ijkpunt niet kan
58
+ // tonen, is precies zo kapot als hij klinkt. Een controle krijgt wel een eigen
59
+ // rol mee, zodat niemand later denkt dat hij uit de steekproef kwam.
60
+ for (const c of j.controles ?? []) {
61
+ if (typeof c.gemeten !== 'number' || !c.url) continue
62
+ let host = null
63
+ try { host = new URL(c.url).host } catch { host = null }
64
+ uit.set(c.url, { url: c.url, host, trede: c.gemeten, uitBestand: naam, rol: 'controle' })
65
+ }
66
+ }
67
+ return uit
68
+ }
69
+
70
+ export function bundel(basis = join(WORTEL, 'editions')) {
71
+ const metingen = []
72
+ const gezien = new Set()
73
+ // Nieuwste editie eerst, en een eindpunt dat daar al in staat wordt niet door een
74
+ // oudere editie overschreven: een badge hoort de MEEST RECENTE meting te tonen.
75
+ for (const map of editieMappen(basis)) {
76
+ for (const m of leesEditie(join(basis, map)).values()) {
77
+ if (gezien.has(m.url)) continue
78
+ gezien.add(m.url)
79
+ metingen.push({ ...m, editie: map })
80
+ }
81
+ }
82
+ return { gegenereerd: new Date().toISOString().slice(0, 10), aantal: metingen.length, metingen }
83
+ }
84
+
85
+ if (process.argv[1]?.endsWith('x402-metingen-bundel.mjs')) {
86
+ const b = bundel()
87
+ const uit = process.argv[2] ?? join(WORTEL, 'editions', 'metingen.json')
88
+ writeFileSync(uit, JSON.stringify(b, null, 2))
89
+ const perTrede = new Map()
90
+ for (const m of b.metingen) perTrede.set(m.trede, (perTrede.get(m.trede) ?? 0) + 1)
91
+ console.log('endpoints in the bundle: ' + b.aantal)
92
+ for (const [t, n] of [...perTrede].sort((a, b2) => b2[0] - a[0])) {
93
+ console.log(' ' + (t >= 0 ? 'L' + t : 'L-') + ' ' + n)
94
+ }
95
+ console.log('written: ' + uit)
96
+ }
@@ -0,0 +1,84 @@
1
+ // Onafhankelijke verificatie van het x402 offer-receipt autorisatie-commitment.
2
+ //
3
+ // Geschreven tegen de spec-tekst van de extensie (§5.6 Authorization Commitment),
4
+ // niet tegen een implementatie. Het commitment bindt een bonnetje aan precies één
5
+ // afrekening op de keten, zonder dat het bonnetje die afrekening prijsgeeft.
6
+ //
7
+ // Waarom dit ertoe doet: sinds §5.6.5 (16 oktober 2026) staat in de spec dat een
8
+ // consument die afrekeningen telt ALLEEN geopende commitments mag tellen. Zolang een
9
+ // commitment dicht is, is het een uitspraak van de ondertekenaar over zichzelf: hij
10
+ // kan twee bonnetjes maken voor één afrekening, of een bonnetje dat nergens op
11
+ // uitkomt. Openen is dus geen formaliteit maar het moment waarop een bewering bewijs
12
+ // wordt, en dat is precies wat hier narekenbaar wordt gemaakt.
13
+
14
+ import { createHash } from 'node:crypto'
15
+
16
+ const TAG = Buffer.from('x402-receipt-authorization/0', 'ascii')
17
+ const NUL = Buffer.from([0])
18
+
19
+ const bytes = (h, naam, verwacht) => {
20
+ const b = Buffer.from(String(h ?? '').replace(/^0x/, ''), 'hex')
21
+ if (verwacht !== undefined && b.length !== verwacht) {
22
+ throw new Error(`${naam} moet ${verwacht} bytes zijn, kreeg ${b.length}`)
23
+ }
24
+ return b
25
+ }
26
+
27
+ // §5.6.2. Elk veld na `method` heeft een vaste lengte, dus het voorbeeld is
28
+ // ondubbelzinnig. Lengtes worden hier afgedwongen: een veld van de verkeerde lengte
29
+ // zou stilzwijgend een ander commitment opleveren dat nergens op opent.
30
+ export function berekenCommitment({ method, asset, authorizer, identifier, salt }) {
31
+ if (!method) throw new Error('method ontbreekt')
32
+ const lengtes = { eip3009: { asset: 20, authorizer: 20 } }[method]
33
+ if (!lengtes) throw new Error(`onbekende authorizationMethod: ${method}`)
34
+
35
+ const pre = Buffer.concat([
36
+ TAG, NUL,
37
+ Buffer.from(method, 'ascii'), NUL,
38
+ bytes(asset, 'asset', lengtes.asset),
39
+ bytes(authorizer, 'authorizer', lengtes.authorizer),
40
+ bytes(identifier, 'identifier', 32),
41
+ bytes(salt, 'salt', 32),
42
+ ])
43
+ return '0x' + createHash('sha256').update(pre).digest('hex')
44
+ }
45
+
46
+ // §5.6.4 stap 1: de authorizer komt uit het ONDERTEKENDE payer-veld van het bonnetje,
47
+ // nooit uit een waarde die naast het zout wordt aangeleverd. Anders kan iemand een
48
+ // commitment laten openen op een afrekening van een ander.
49
+ export function opentCommitment({ receipt, salt, asset, kandidaatIdentifiers }) {
50
+ const payload = receipt?.payload ?? receipt
51
+ const method = payload?.authorizationMethod
52
+ const commitment = payload?.authorizationCommitment
53
+ if (!method || !commitment) return { linked: false, reden: 'receipt carries no commitment' }
54
+
55
+ const treffers = []
56
+ for (const identifier of kandidaatIdentifiers ?? []) {
57
+ let berekend
58
+ try { berekend = berekenCommitment({ method, asset, authorizer: payload.payer, identifier, salt }) } catch { continue }
59
+ if (berekend.toLowerCase() === String(commitment).toLowerCase()) treffers.push(identifier)
60
+ }
61
+
62
+ // §5.6.4 stap 3: precies een MOET matchen, anders linkt het bonnetje niet.
63
+ // Bewust fail-closed: nul en twee zijn allebei "niet gelinkt", niet "waarschijnlijk".
64
+ if (treffers.length === 1) return { linked: true, identifier: treffers[0] }
65
+ if (treffers.length === 0) return { linked: false, reden: 'no candidate opens this commitment' }
66
+ return { linked: false, reden: `${treffers.length} candidates open the same commitment, so it is ambiguous` }
67
+ }
68
+
69
+ // Het voorbeeld dat in hun eigen spec staat, met de verwachte uitkomst erbij.
70
+ // Het commit staat erbij omdat die PR nog open is: een tak beweegt, een commit niet.
71
+ // Zonder dat nummer zou deze vector stil kunnen verouderen zonder dat de test valt.
72
+ // Dit is de hele interop-toets voor deze kant: rekenen wij uit hun tekst hetzelfde
73
+ // getal uit als zij publiceren?
74
+ export const SPEC_VOORBEELD = {
75
+ bron: 'x402-foundation/x402, specs/extensions/extension-offer-and-receipt.md §5.6.2, tak receipt-authorization-commitment op commit 0e492d6730bef14b9c722428386c114f3475a914',
76
+ invoer: {
77
+ method: 'eip3009',
78
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
79
+ authorizer: '0x857b06519E91e3A54538791bDbb0E22373e36b66',
80
+ identifier: '0x5135a00b531830a13a751f3c48944d7a5fdfde7a3b87f73493591cd77efdef4f',
81
+ salt: '0x10d2ae5f0c8536e82d882bf3d2c31a663597e890ca6c3fede0d278fd2a78f655',
82
+ },
83
+ verwacht: '0x5d905bbf08a97e04ab551d276d8e94b1abaff7d0737324d360e983c2796af23b',
84
+ }