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.
- package/CONFORMANCE.md +110 -0
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/README.md +273 -2
- package/bench/eip3009-beleid.mjs +113 -0
- package/bench/robustness.mjs +264 -0
- package/bin/cli.mjs +95 -0
- package/examples/receipt-live-base-sepolia-2.json +21 -0
- package/examples/receipt-live-base-sepolia-3.json +21 -0
- package/examples/receipt-live-base-sepolia.json +21 -0
- package/package.json +61 -4
- package/profiles/profile.schema.json +115 -0
- package/profiles/x402-deployed-ladder-v1.json +178 -0
- package/src/eip3009-autorisatie.mjs +228 -0
- package/src/inam-countersigned.mjs +162 -0
- package/src/mcp-server.mjs +262 -0
- package/src/profiel.mjs +145 -0
- package/src/x402-conformance-check.mjs +194 -0
- package/src/x402-conformance-treden.mjs +246 -0
- package/src/x402-discovery-census.mjs +247 -0
- package/src/x402-index-liveness.mjs +201 -0
- package/src/x402-link-onchain.mjs +280 -0
- package/src/x402-metingen-bundel.mjs +96 -0
- package/src/x402-offer-receipt.mjs +84 -0
- package/src/x402-poortwachters.mjs +241 -0
- package/tools/bundel-bouw.mjs +126 -0
- package/tools/pack-inhoud.mjs +96 -0
- package/vectors/inam/BRON-README.md +38 -0
- package/vectors/inam/canonical-vectors.json +122 -0
- package/vectors/inam/vectors.json +96 -0
- package/vectors/kanon/amount-insufficient-001.json +52 -0
- package/vectors/kanon/cross-chain-replay-001.json +54 -0
- package/vectors/kanon/cross-contract-replay-001.json +54 -0
- package/vectors/kanon/domain-name-mismatch-001.json +53 -0
- package/vectors/kanon/expired-001.json +51 -0
- package/vectors/kanon/network-mismatch-001.json +52 -0
- package/vectors/kanon/nonce-replay-001.json +56 -0
- package/vectors/kanon/not-yet-valid-001.json +51 -0
- package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
- 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
|
+
}
|