agent-receipt-verify 0.0.0-stage → 0.2.1

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 (37) hide show
  1. package/CONFORMANCE.md +110 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +39 -0
  4. package/README.md +256 -2
  5. package/bin/cli.mjs +89 -0
  6. package/examples/receipt-live-base-sepolia-2.json +21 -0
  7. package/examples/receipt-live-base-sepolia-3.json +21 -0
  8. package/examples/receipt-live-base-sepolia.json +21 -0
  9. package/package.json +59 -4
  10. package/profiles/profile.schema.json +115 -0
  11. package/profiles/x402-deployed-ladder-v1.json +178 -0
  12. package/src/eip3009-autorisatie.mjs +195 -0
  13. package/src/inam-countersigned.mjs +162 -0
  14. package/src/mcp-server.mjs +262 -0
  15. package/src/profiel.mjs +145 -0
  16. package/src/x402-conformance-check.mjs +194 -0
  17. package/src/x402-conformance-treden.mjs +246 -0
  18. package/src/x402-discovery-census.mjs +247 -0
  19. package/src/x402-index-liveness.mjs +201 -0
  20. package/src/x402-link-onchain.mjs +280 -0
  21. package/src/x402-metingen-bundel.mjs +96 -0
  22. package/src/x402-offer-receipt.mjs +84 -0
  23. package/src/x402-poortwachters.mjs +241 -0
  24. package/tools/bundel-bouw.mjs +126 -0
  25. package/vectors/inam/BRON-README.md +38 -0
  26. package/vectors/inam/canonical-vectors.json +122 -0
  27. package/vectors/inam/vectors.json +96 -0
  28. package/vectors/kanon/amount-insufficient-001.json +52 -0
  29. package/vectors/kanon/cross-chain-replay-001.json +54 -0
  30. package/vectors/kanon/cross-contract-replay-001.json +54 -0
  31. package/vectors/kanon/domain-name-mismatch-001.json +53 -0
  32. package/vectors/kanon/expired-001.json +51 -0
  33. package/vectors/kanon/network-mismatch-001.json +52 -0
  34. package/vectors/kanon/nonce-replay-001.json +56 -0
  35. package/vectors/kanon/not-yet-valid-001.json +51 -0
  36. package/vectors/kanon/sig-malleable-high-s-001.json +54 -0
  37. package/vectors/kanon/valid-baseline-001.json +52 -0
@@ -0,0 +1,195 @@
1
+ // Een onafhankelijke verifieerder voor EIP-3009 betaalautorisaties, geschreven tegen
2
+ // de vectoren van iamonuwa/kanon en niet tegen zijn code.
3
+ //
4
+ // WAAROM DIT HIER STAAT, want het is de ANDERE kant van wat deze repo tot nu toe deed.
5
+ // Alles hier ging over de VERKOPER: zijn ondertekende aanbod, zijn bonnetje, en de
6
+ // vraag of dat bonnetje op precies een afrekening op de keten opent. Een EIP-3009
7
+ // autorisatie is de KOPER-kant: de handtekening waarmee een agent zijn geld vrijgeeft,
8
+ // voor er iets afgerekend is. Samen zijn dat de twee uiteinden van dezelfde transactie,
9
+ // en ze overlappen nergens. Dat is precies waarom een tweede implementatie hier iets
10
+ // toevoegt in plaats van iets te verdubbelen.
11
+ //
12
+ // DE REGEL DIE DEZE HELE REPO DRAAGT, ook hier: geschreven tegen de GEPUBLICEERDE
13
+ // vectoren en de specificaties (EIP-3009, EIP-712), nooit tegen de broncode van de
14
+ // referentie-implementatie. Twee implementaties die elkaars code niet gezien hebben en
15
+ // toch op dezelfde uitslagen komen, dat is interop. Een implementatie die met zichzelf
16
+ // instemt bewijst niets.
17
+ //
18
+ // DE VOLGORDE VAN DE CONTROLES IS ZELF EEN UITSPRAAK, en zij volgt uit zijn eigen
19
+ // beschrijvingen bij de vectoren:
20
+ // 1. netwerk (platte tekst, voor de handtekening; zijn tekst: "the plaintext mismatch")
21
+ // 2. kneedbaarheid van de handtekening (voor het herstellen, anders herstel je een
22
+ // tweede geldige vorm van dezelfde handtekening)
23
+ // 3. geldigheidsvenster
24
+ // 4. herstellen en de ondertekenaar vergelijken
25
+ // 5. bedrag (zijn tekst: "caught only after the signature verifies")
26
+ // 6. nonce al verbruikt
27
+ // Een andere volgorde geeft bij een vector met twee gebreken een andere redencode. Waar
28
+ // wij van hem afwijken hoort dat GEMELD te worden en niet weggepoetst tot het groen is.
29
+ //
30
+ // ethers is optioneel in dit pakket. Zonder dat pakket kan stap 4 niet en komt de
31
+ // uitslag terug als ONGEMETEN, nooit als afgekeurd: een gat in onze meter is geen
32
+ // gebrek van de betaling.
33
+
34
+ import { laadEthers } from './x402-conformance-treden.mjs'
35
+
36
+ /** De helft van de secp256k1-groepsorde. Daarboven is een handtekening kneedbaar. */
37
+ const HALVE_ORDE = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n
38
+
39
+ export const REDENEN = [
40
+ 'VALID',
41
+ 'NETWORK_MISMATCH',
42
+ 'SIG_MALLEABLE',
43
+ 'SIGNER_MISMATCH',
44
+ 'NOT_YET_VALID',
45
+ 'EXPIRED',
46
+ 'NONCE_REPLAY',
47
+ 'AMOUNT_INSUFFICIENT',
48
+ ]
49
+
50
+ /** De EIP-712-types van EIP-3009, normatief en niet over de lijn verzonden. */
51
+ export const TRANSFER_TYPES = {
52
+ TransferWithAuthorization: [
53
+ { name: 'from', type: 'address' },
54
+ { name: 'to', type: 'address' },
55
+ { name: 'value', type: 'uint256' },
56
+ { name: 'validAfter', type: 'uint256' },
57
+ { name: 'validBefore', type: 'uint256' },
58
+ { name: 'nonce', type: 'bytes32' },
59
+ ],
60
+ }
61
+
62
+ /** `eip155:84532` naar 84532. Geeft null bij een vorm die wij niet kennen. */
63
+ export function chainIdUitNetwerk(netwerk) {
64
+ const m = /^eip155:(\d+)$/.exec(String(netwerk ?? ''))
65
+ return m ? Number(m[1]) : null
66
+ }
67
+
68
+ /**
69
+ * Het domein waaronder deze autorisatie hoort te zijn ondertekend.
70
+ *
71
+ * Alle drie de replay-vectoren draaien hierom: wie onder een ander domein tekent
72
+ * (andere keten, ander contract, andere naam) levert een handtekening die op een ANDER
73
+ * adres herstelt. Daarom is er geen aparte controle voor cross-chain of cross-contract
74
+ * nodig: het domein doet dat werk, en de uitslag heet dan terecht SIGNER_MISMATCH.
75
+ */
76
+ export function domeinUit(accepted) {
77
+ return {
78
+ name: accepted?.extra?.name,
79
+ version: accepted?.extra?.version,
80
+ chainId: chainIdUitNetwerk(accepted?.network),
81
+ verifyingContract: accepted?.asset,
82
+ }
83
+ }
84
+
85
+ /** Is de s-waarde van deze handtekening boven de halve groepsorde. */
86
+ export function isKneedbaar(signature) {
87
+ const h = String(signature ?? '').replace(/^0x/i, '')
88
+ if (h.length < 130) return false
89
+ const s = BigInt('0x' + h.slice(64, 128))
90
+ return s > HALVE_ORDE
91
+ }
92
+
93
+ /**
94
+ * Beoordeelt een enkele autorisatie.
95
+ *
96
+ * Geeft { valid, reason_code } terug in exact de vorm die kanon se vectoren in hun
97
+ * `expected` gebruiken, plus `ongemeten` met een reden als de controle niet KON
98
+ * draaien. Die derde mogelijkheid staat niet in zijn schema, en dat is precies het
99
+ * punt dat wij in x402#3396 open hebben gelegd: een gat in de verifieerder is iets
100
+ * anders dan een afgekeurde betaling.
101
+ */
102
+ export async function beoordeelAutorisatie(input, context = {}) {
103
+ const doelNetwerk = context.doelNetwerk ?? input?.accepted?.network
104
+ const accepted = input?.accepted ?? {}
105
+ const auth = input?.payload?.authorization ?? {}
106
+ const signature = input?.payload?.signature
107
+ const nu = Number(context.verification_time ?? Math.floor(Date.now() / 1000))
108
+
109
+ // 1. Netwerk, in platte tekst, voor er iets met de handtekening gebeurt.
110
+ if (String(doelNetwerk) !== String(accepted.network)) {
111
+ return { valid: false, reason_code: 'NETWORK_MISMATCH' }
112
+ }
113
+
114
+ // 2. Kneedbaarheid, voor het herstellen. Een hoge s is een tweede geldige vorm van
115
+ // dezelfde handtekening, en wie eerst herstelt accepteert die vorm stilletjes.
116
+ if (isKneedbaar(signature)) {
117
+ return { valid: false, reason_code: 'SIG_MALLEABLE' }
118
+ }
119
+
120
+ // 3. Het geldigheidsvenster.
121
+ if (nu < Number(auth.validAfter)) return { valid: false, reason_code: 'NOT_YET_VALID' }
122
+ if (nu > Number(auth.validBefore)) return { valid: false, reason_code: 'EXPIRED' }
123
+
124
+ // 4. Herstellen en de ondertekenaar vergelijken. Zonder ethers kan dit niet, en dan
125
+ // is de eerlijke uitslag ONGEMETEN.
126
+ const ethers = await laadEthers()
127
+ if (!ethers) {
128
+ return { valid: false, reason_code: null, ongemeten: true, reden: 'ethers ontbreekt, dus de handtekening is niet te herstellen' }
129
+ }
130
+ let hersteld
131
+ try {
132
+ hersteld = ethers.verifyTypedData(domeinUit(accepted), TRANSFER_TYPES, {
133
+ from: auth.from, to: auth.to, value: auth.value,
134
+ validAfter: auth.validAfter, validBefore: auth.validBefore, nonce: auth.nonce,
135
+ }, signature)
136
+ } catch (e) {
137
+ // Een handtekening die niet eens te herstellen is, is geen geldige ondertekenaar.
138
+ return { valid: false, reason_code: 'SIGNER_MISMATCH', detail: String(e?.message ?? e).slice(0, 120) }
139
+ }
140
+ if (String(hersteld).toLowerCase() !== String(auth.from).toLowerCase()) {
141
+ return { valid: false, reason_code: 'SIGNER_MISMATCH', hersteld }
142
+ }
143
+
144
+ // 5. Het bedrag, pas NA de handtekening: een authentiek ondertekende onderbetaling.
145
+ if (BigInt(auth.value ?? 0) < BigInt(accepted.amount ?? 0)) {
146
+ return { valid: false, reason_code: 'AMOUNT_INSUFFICIENT' }
147
+ }
148
+
149
+ // 6. Is deze nonce al verbruikt. Dit is toestand van buiten, geen eigenschap van de
150
+ // autorisatie, en komt daarom uit de context in plaats van uit de betaling.
151
+ const gezien = (context.seen_nonces ?? []).map((n) => String(n).toLowerCase())
152
+ if (gezien.includes(String(auth.nonce).toLowerCase())) {
153
+ return { valid: false, reason_code: 'NONCE_REPLAY' }
154
+ }
155
+
156
+ return { valid: true, reason_code: 'VALID', ondertekenaar: hersteld }
157
+ }
158
+
159
+ /** Een hele vector beoordelen, met zijn eigen context en doelnetwerk. */
160
+ export async function beoordeelVector(vector) {
161
+ return beoordeelAutorisatie(vector.input, { ...(vector.context ?? {}), doelNetwerk: vector.network })
162
+ }
163
+
164
+ // Een commandoregel, zodat een vreemde de kruiscontrole in EEN regel kan narekenen in
165
+ // plaats van ons op ons woord te geloven. Dat is het hele punt van deze repo.
166
+ if (process.argv[1]?.endsWith('eip3009-autorisatie.mjs')) {
167
+ const fs = await import('node:fs')
168
+ const path = await import('node:path')
169
+ const { fileURLToPath } = await import('node:url')
170
+ const map = path.join(fileURLToPath(new URL('..', import.meta.url)), 'vectors', 'kanon')
171
+ if (!fs.existsSync(map)) {
172
+ console.log('geen vectoren gevonden in ' + map)
173
+ process.exitCode = 2
174
+ } else {
175
+ const bestanden = fs.readdirSync(map).filter((f) => f.endsWith('.json')).sort()
176
+ let eens = 0, oneens = 0, ongemeten = 0
177
+ console.log('Cross-check against iamonuwa/kanon, EIP-3009 payment authorizations')
178
+ console.log('Vectors pinned at commit 0d5a7461e996, Apache-2.0, redistributed unmodified.')
179
+ console.log('This implementation was written against those vectors and against EIP-3009')
180
+ console.log('and EIP-712, never against that project\'s source.')
181
+ console.log('')
182
+ for (const f of bestanden) {
183
+ const v = JSON.parse(fs.readFileSync(path.join(map, f), 'utf8'))
184
+ const u = await beoordeelVector(v)
185
+ if (u.ongemeten) { ongemeten++; console.log(' not measured ' + v.id + ' ' + u.reden); continue }
186
+ const zelfde = u.valid === v.expected.valid && u.reason_code === v.expected.reason_code
187
+ if (zelfde) { eens++; console.log(' agree ' + v.id.padEnd(42) + ' ' + u.reason_code) }
188
+ else { oneens++; console.log(' DISAGREE ' + v.id.padEnd(42) + ' ours ' + u.reason_code + ', theirs ' + v.expected.reason_code) }
189
+ }
190
+ console.log('')
191
+ console.log('agree on ' + eens + ' of ' + bestanden.length + ', disagree ' + oneens + ', not measured ' + ongemeten)
192
+ if (ongemeten) console.log('Install the optional ethers dependency to run the signature recovery.')
193
+ process.exitCode = oneens === 0 ? 0 : 1
194
+ }
195
+ }
@@ -0,0 +1,162 @@
1
+ // Onafhankelijke verificatie van INAM-tegengetekende bonnetjes.
2
+ //
3
+ // Geschreven tegen hun SPEC.md (§4.2 content-addressing, §4.3 lifecycle, §15 over
4
+ // HTTP) en hun README, NIET tegen hun verify.mjs. Dat is het hele punt: als twee
5
+ // implementaties die elkaars code niet hebben gezien dezelfde uitspraak doen over
6
+ // dezelfde vectoren, is dat interop-bewijs. Eén implementatie die zichzelf
7
+ // bevestigt, bewijst niets.
8
+ //
9
+ // Read-only, geen netwerk, geen sleutels van onszelf.
10
+
11
+ import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'
12
+ import { readFileSync } from 'node:fs'
13
+
14
+ const DIRECT = process.argv[1]?.endsWith('inam-countersigned.mjs')
15
+ const VECTOREN = process.argv[2]
16
+ if (DIRECT && !VECTOREN) { console.error('usage: node src/inam-countersigned.mjs <vectors.json>'); process.exit(2) }
17
+
18
+ // ── bouwstenen ───────────────────────────────────────────────────────────────
19
+
20
+ // §4.2: "recursive key-sorted, whitespace-free JSON serializer"
21
+ export function canoniek(x) {
22
+ if (x === null || typeof x !== 'object') return JSON.stringify(x)
23
+ if (Array.isArray(x)) return '[' + x.map(canoniek).join(',') + ']'
24
+ return '{' + Object.keys(x).sort().map(k => JSON.stringify(k) + ':' + canoniek(x[k])).join(',') + '}'
25
+ }
26
+
27
+ const sha256hex = s => createHash('sha256').update(s, 'utf8').digest('hex')
28
+
29
+ // base58btc, zelf geschreven zodat we geen code van hen of van een pakket delen
30
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
31
+ export function base58decode(s) {
32
+ let getal = 0n
33
+ for (const teken of s) {
34
+ const i = B58.indexOf(teken)
35
+ if (i < 0) throw new Error('geen base58: ' + teken)
36
+ getal = getal * 58n + BigInt(i)
37
+ }
38
+ const bytes = []
39
+ while (getal > 0n) { bytes.unshift(Number(getal & 255n)); getal >>= 8n }
40
+ for (const teken of s) { if (teken === '1') bytes.unshift(0); else break }
41
+ return Buffer.from(bytes)
42
+ }
43
+
44
+ // did:key met multicodec 0xed01 = Ed25519 publieke sleutel
45
+ export function didNaarSleutel(did) {
46
+ const deel = did.replace(/^did:key:/, '').split('#')[0]
47
+ if (!deel.startsWith('z')) throw new Error('verwacht base58btc (z...)')
48
+ const ruw = base58decode(deel.slice(1))
49
+ if (ruw[0] !== 0xed || ruw[1] !== 0x01) throw new Error('geen ed25519-multicodec')
50
+ const sleutel = ruw.subarray(2)
51
+ if (sleutel.length !== 32) throw new Error('verkeerde sleutellengte ' + sleutel.length)
52
+ // minimale SPKI-DER-omhulling rond een rauwe ed25519-sleutel
53
+ const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), sleutel])
54
+ return createPublicKey({ key: der, format: 'der', type: 'spki' })
55
+ }
56
+
57
+ export function ed25519Klopt(did, inhoud, handtekeningB64) {
58
+ try {
59
+ const sig = Buffer.from(handtekeningB64, handtekeningB64.includes('-') || handtekeningB64.includes('_') ? 'base64url' : 'base64')
60
+ return edVerify(null, Buffer.from(inhoud, 'utf8'), didNaarSleutel(did), sig)
61
+ } catch { return false }
62
+ }
63
+
64
+ let keccak256
65
+ try {
66
+ // Een BARE import, en bewust geen pad op iemands schijf. Dit stond hier als
67
+ // file:///C:/Code/... en dat werkte alleen op de machine van de schrijver: voor
68
+ // iedere vreemde viel controle 1 stil weg en kreeg hij zonder het te weten een
69
+ // zwakkere uitslag dan wij. Dat is het tegenovergestelde van een onafhankelijke
70
+ // verifieerder. ethers staat nu als optionele afhankelijkheid in package.json.
71
+ const ethers = await import('ethers')
72
+ keccak256 = b => ethers.keccak256(b)
73
+ } catch {
74
+ keccak256 = null
75
+ }
76
+
77
+ // ── de acht controles, zoals hun README ze beschrijft ────────────────────────
78
+
79
+ export function toetsGeval(geval, vectoren) {
80
+ const mislukt = []
81
+ const bestand = geval.feedbackFile
82
+ const f = JSON.parse(bestand)
83
+ const r = f?.inam?.receipt ?? {}
84
+ const ev = geval.onchainEvent ?? {}
85
+ const obs = vectoren.observed ?? {}
86
+
87
+ // 1. keccak256 van de bestandsbytes is de feedbackHash uit het event
88
+ if (keccak256) {
89
+ const berekend = keccak256(Buffer.from(bestand, 'utf8'))
90
+ if (String(berekend).toLowerCase() !== String(ev.feedbackHash).toLowerCase()) mislukt.push('feedbackHashMatchesFile')
91
+ }
92
+
93
+ // 2. §4.2: receiptId = sha256 over canoniek van zeven velden
94
+ const zeven = {}
95
+ for (const k of ['jobId', 'agentA', 'agentB', 'task', 'result', 'settlement', 'verification']) {
96
+ if (r[k] !== undefined) zeven[k] = r[k]
97
+ }
98
+ if ('sha256:' + sha256hex(canoniek(zeven)) !== r.receiptId) mislukt.push('receiptIdRecomputes')
99
+
100
+ // 3+4. §4.3: beide partijen tekenen DEZELFDE canonieke inhoud, alles behalve
101
+ // signatures, status en dispute
102
+ const teTekenen = { ...r }
103
+ delete teTekenen.signatures; delete teTekenen.status; delete teTekenen.dispute
104
+ const inhoud = canoniek(teTekenen)
105
+ const sigs = r.signatures ?? {}
106
+ const didB = r.agentB?.did ?? r.agentB?.id ?? vectoren.parties?.provider?.did
107
+ const didA = r.agentA?.did ?? r.agentA?.id ?? vectoren.parties?.requester?.did
108
+ const sigB = sigs.agentB?.signature ?? sigs.agentB ?? sigs.worker
109
+ const sigA = sigs.agentA?.signature ?? sigs.agentA ?? sigs.requester
110
+ if (!sigB || !ed25519Klopt(didB, inhoud, sigB)) mislukt.push('workerSignature')
111
+ if (!sigA || !ed25519Klopt(didA, inhoud, sigA)) mislukt.push('requesterSignature')
112
+
113
+ // 5. waarde hoort bij de uitkomst: 100 / 50 / 0
114
+ const uitkomst = r.result?.outcome ?? r.result?.status
115
+ const hoort = { success: 100, partial: 50, failed: 0 }[uitkomst]
116
+ const waarde = ev.value ?? f.value
117
+ if (hoort !== undefined && Number(waarde) !== hoort) mislukt.push('valueMatchesOutcome')
118
+
119
+ // 6. het bestand noemt het adres dat het event verstuurde
120
+ const inBestand = String(f.clientAddress ?? '').split(':').pop().toLowerCase()
121
+ if (inBestand !== String(ev.clientAddress ?? '').toLowerCase()) mislukt.push('fileClientAddressMatchesSender')
122
+
123
+ // 7. dat adres is de wallet waarvan de requester controle bewees
124
+ const bewezen = String(vectoren.parties?.requester?.erc8004Address ?? '').toLowerCase()
125
+ if (String(ev.clientAddress ?? '').toLowerCase() !== bewezen) mislukt.push('senderIsRequesterWallet')
126
+
127
+ // 8+9. §15: hashes over de bytes die daadwerkelijk over de lijn gingen
128
+ const u = new URL(obs.request?.url ?? 'https://x/')
129
+ const verzoekBytes = `${obs.request?.method} ${u.pathname}${u.search}\n${obs.request?.body ?? ''}`
130
+ if ('sha256:' + sha256hex(verzoekBytes) !== r.task?.specHash) mislukt.push('specHashMatchesObservedBytes')
131
+ if ('sha256:' + sha256hex(obs.response?.body ?? '') !== r.result?.outputHash) mislukt.push('outputHashMatchesObservedBytes')
132
+
133
+ return { accepted: mislukt.length === 0, failedChecks: mislukt }
134
+ }
135
+
136
+ // ── draaien en vergelijken met wat de vectoren zelf verwachten ───────────────
137
+ //
138
+ // Alleen bij directe aanroep. Importeren levert de functies, niets anders.
139
+ if (DIRECT) {
140
+
141
+ const v = JSON.parse(readFileSync(VECTOREN, 'utf8'))
142
+ console.log(`suite: ${v.suite}`)
143
+ console.log(`keccak256 beschikbaar: ${keccak256 ? 'ja' : 'NEE (controle 1 overgeslagen)'}\n`)
144
+
145
+ let gelijk = 0, afwijkend = 0
146
+ for (const geval of v.cases) {
147
+ const onze = toetsGeval(geval, v)
148
+ const hunne = geval.expected
149
+ const zelfdeOordeel = onze.accepted === hunne.accepted
150
+ const onzeSet = [...onze.failedChecks].sort().join(',')
151
+ const hunSet = [...(hunne.failedChecks ?? [])].sort().join(',')
152
+ const zelfdeChecks = onzeSet === hunSet
153
+ const merk = zelfdeOordeel && zelfdeChecks ? 'GELIJK ' : (zelfdeOordeel ? 'deels ' : 'AFWIJKEND')
154
+ if (zelfdeOordeel && zelfdeChecks) gelijk++; else afwijkend++
155
+ console.log(`${merk} ${geval.id}`)
156
+ console.log(` zij : accepted=${hunne.accepted} [${hunSet || '-'}]`)
157
+ console.log(` wij : accepted=${onze.accepted} [${onzeSet || '-'}]`)
158
+ }
159
+ console.log(`\n${gelijk} van ${v.cases.length} volledig gelijk, ${afwijkend} afwijkend`)
160
+ process.exit(afwijkend === 0 ? 0 : 1)
161
+
162
+ }
@@ -0,0 +1,262 @@
1
+ // Een MCP-server, zodat een agent deze controle AANROEPT in plaats van hem te moeten
2
+ // aanbevelen.
3
+ //
4
+ // WAAROM DIT BESTAAT, en het is gemeten en niet bedacht. Op 2026-10-17 classificeerde
5
+ // de gratis laag 32 marktsignalen over agent-betalingen: 14 benoemen een concreet
6
+ // probleem en alle 14 staan aan de KOPERSKANT. Niet een enkel verkopersprobleem. De
7
+ // titels zeggen het zelf: "5 Ways Your Paid Endpoint Gets Drained", "your agent still
8
+ // needs a bill", en "Your AI Agent Paid a Supplier Twice. Can You Prove What It Did?"
9
+ // In dezelfde ronde bleken er in de MCP-directories al zeven partijen DATA te
10
+ // verkopen over x402 en geen enkele die de betaallaag zelf controleert.
11
+ //
12
+ // Dat is de hele reden voor deze twee gereedschappen: het voor en het na van die ene
13
+ // pijn. Voor je betaalt, klopt dit eindpunt. Na je betaling, hoort dit bonnetje bij
14
+ // precies een echte betaling.
15
+ //
16
+ // GEEN AFHANKELIJKHEDEN, EN DAT IS EEN KEUZE EN GEEN LUIHEID. Dit protocol is
17
+ // newline-gescheiden JSON-RPC over stdio, dat is een paar regels. De officiele
18
+ // bibliotheek erbij halen zou betekenen dat een verifieerder wiens hele waarde
19
+ // onafhankelijkheid is, een boom afhankelijkheden meeneemt om te zeggen of een
20
+ // handtekening klopt. De vormen hieronder zijn overgenomen uit de officiele sdk die
21
+ // in ons andere project staat, niet uit het hoofd: protocolversie, de tools-vorm en
22
+ // de content-vorm komen daar letterlijk uit.
23
+ //
24
+ // DE VALKUIL DIE EEN MCP-SERVER STIL KAPOT MAAKT: alles wat naar stdout gaat en geen
25
+ // JSON-RPC-frame is, breekt de verbinding. Daarom gaat ELKE melding naar stderr en
26
+ // schrijft alleen `stuur()` naar stdout.
27
+ //
28
+ // GEBRUIK (in een MCP-client):
29
+ // npx github:holistis/agent-receipt-verify mcp
30
+ //
31
+ // WAT DIT NIET DOET: het oordeelt over de VORM van een eindpunt en een bonnetje, niet
32
+ // over de eerlijkheid van de partij erachter. Een eindpunt dat alles goed doet kan je
33
+ // alsnog rommel verkopen. Zie CONFORMANCE.md voor wat met opzet niet gemeten wordt.
34
+
35
+ import { meetEindpunt, hoogsteTrede } from './x402-conformance-check.mjs'
36
+ import { TREDEN, PROFIEL } from './x402-conformance-treden.mjs'
37
+ import { maakRpc, linkBonnetje } from './x402-link-onchain.mjs'
38
+
39
+ const PROTOCOL = '2025-11-25'
40
+ const ONDERSTEUND = [PROTOCOL, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
41
+
42
+ const NAAM = 'agent-receipt-verify'
43
+ const VERSIE = '0.2.0'
44
+
45
+ // Een publieke rpc per netwerk, zodat de koper niet eerst een sleutel hoeft te regelen
46
+ // om zijn eigen bonnetje te kunnen natrekken. Een netwerk dat hier niet staat wordt
47
+ // EERLIJK als niet-ondersteund gemeld en nooit stil op een ander netwerk gezocht.
48
+ const RPC_PER_NETWERK = {
49
+ 'eip155:84532': 'https://sepolia.base.org',
50
+ 'eip155:8453': 'https://mainnet.base.org',
51
+ 'base-sepolia': 'https://sepolia.base.org',
52
+ base: 'https://mainnet.base.org',
53
+ }
54
+
55
+ const log = (...a) => process.stderr.write('[' + NAAM + '] ' + a.join(' ') + '\n')
56
+
57
+ const GEREEDSCHAP = [
58
+ {
59
+ name: 'check_x402_endpoint',
60
+ description:
61
+ 'Check a paid x402 endpoint BEFORE paying it. Sends exactly one request, never pays, and reports which levels of ' +
62
+ 'the published conformance ladder it reaches: does it ask for payment the way x402 says, does it declare the ' +
63
+ 'offer-receipt extension, and does the signature on its offer actually verify. Levels that need a real settlement ' +
64
+ 'are reported as not measured, never as failed.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ url: { type: 'string', description: 'The paid endpoint to check, for example https://api.example.com/paid/thing' },
69
+ method: { type: 'string', description: 'HTTP method the endpoint expects. Defaults to POST. Use GET for a GET-only route.' },
70
+ },
71
+ required: ['url'],
72
+ },
73
+ },
74
+ {
75
+ name: 'link_receipt_to_settlement',
76
+ description:
77
+ 'Check AFTER paying that a version 2 offer-receipt belongs to exactly one real on-chain settlement. Takes the ' +
78
+ 'signed receipt payload plus the authorization salt the seller returned outside it, and resolves the salted ' +
79
+ 'commitment against live chain state. Answers linked, not linked, or could-not-check, and never guesses between them.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ payload: { type: 'object', description: 'The signed receipt payload, the object with version, network, payer and authorizationCommitment' },
84
+ salt: { type: 'string', description: 'The authorizationSalt from extensions["offer-receipt"].info, which is deliberately not inside the signed payload' },
85
+ asset: { type: 'string', description: 'The token contract from the accepts entry that was paid, for example the USDC address' },
86
+ rpcUrl: { type: 'string', description: 'Optional. An RPC endpoint to use instead of the public default for that network.' },
87
+ },
88
+ required: ['payload', 'salt', 'asset'],
89
+ },
90
+ },
91
+ ]
92
+
93
+ function tekst(s, fout = false) {
94
+ return { content: [{ type: 'text', text: s }], ...(fout ? { isError: true } : {}) }
95
+ }
96
+
97
+ async function doeCheckEndpoint(args) {
98
+ const url = typeof args?.url === 'string' ? args.url.trim() : ''
99
+ if (!/^https?:\/\//.test(url)) return tekst('url must be an absolute http or https address', true)
100
+ const methode = typeof args?.method === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/i.test(args.method)
101
+ ? args.method.toUpperCase()
102
+ : 'POST'
103
+
104
+ const m = await meetEindpunt(url, { methode })
105
+ const hoogste = hoogsteTrede(m)
106
+ const regels = [
107
+ (hoogste >= 0 ? 'Reaches level ' + hoogste : 'Reaches no level') + ' of the conformance ladder.',
108
+ 'Checked with a single ' + methode + ' request. Nothing was paid.',
109
+ '',
110
+ ]
111
+ for (const t of TREDEN) {
112
+ const staat = m.treden[t.sleutel]
113
+ if (!staat) continue
114
+ const merk = staat === 'HAALT' ? 'reached ' : staat === 'HAALT_NIET' ? 'NOT reached' : 'not measured'
115
+ regels.push('L' + t.nummer + ' ' + merk + ' ' + t.naam)
116
+ if (m.bewijs[t.sleutel]) regels.push(' ' + m.bewijs[t.sleutel])
117
+ }
118
+ if (m.bewijs.t2afwijking) regels.push('', 'note on the key: ' + m.bewijs.t2afwijking)
119
+ // De uitslag draagt nu ook WAAR de ladder vandaan komt. Een cijfer zonder
120
+ // herkomst is een mening, en een trede die tegen een nog niet samengevoegd
121
+ // voorstel meet, hoort dat zelf te zeggen in plaats van het aan de lezer te laten.
122
+ const voorstelTreden = TREDEN.filter((t) => t.citeertVoorstel).map((t) => 'L' + t.nummer)
123
+ regels.push(
124
+ '',
125
+ 'Ladder: profile ' + PROFIEL.profile + ' v' + PROFIEL.version + ' of ' + PROFIEL.published +
126
+ ', which names the clause behind every level: profiles/x402-deployed-ladder-v1.json',
127
+ )
128
+ if (voorstelTreden.length) {
129
+ regels.push(
130
+ voorstelTreden.join(' and ') + ' are measured against sections 5.6 and later of the offer-receipt ' +
131
+ 'extension, which exist only in pull request 3827 and are not merged. Their numbering can still move.',
132
+ )
133
+ }
134
+ regels.push(
135
+ 'Levels 3 to 5 cannot be observed without paying, so they are reported as not measured rather than absent.',
136
+ 'The ladder and what it deliberately does not measure: https://github.com/holistis/agent-receipt-verify/blob/main/CONFORMANCE.md',
137
+ 'This judges the form of an endpoint, not the honesty of the party behind it.',
138
+ )
139
+ return tekst(regels.join('\n'))
140
+ }
141
+
142
+ async function doeLinkReceipt(args) {
143
+ const payload = args?.payload
144
+ const salt = typeof args?.salt === 'string' ? args.salt.trim() : ''
145
+ const asset = typeof args?.asset === 'string' ? args.asset.trim() : ''
146
+ if (!payload || typeof payload !== 'object') return tekst('payload must be the signed receipt object', true)
147
+ if (!/^0x[0-9a-fA-F]{64}$/.test(salt)) return tekst('salt must be a 32 byte hex string starting with 0x', true)
148
+ if (!/^0x[0-9a-fA-F]{40}$/.test(asset)) return tekst('asset must be a 20 byte hex address starting with 0x', true)
149
+
150
+ const netwerk = String(payload.network ?? '')
151
+ const rpcUrl = typeof args?.rpcUrl === 'string' && args.rpcUrl ? args.rpcUrl : RPC_PER_NETWERK[netwerk]
152
+ if (!rpcUrl) {
153
+ return tekst(
154
+ 'could not check: no public RPC is configured for network "' + netwerk + '".\n' +
155
+ 'Pass rpcUrl explicitly. This is reported as could-not-check, not as not-linked, because those are different answers.',
156
+ true,
157
+ )
158
+ }
159
+
160
+ try {
161
+ const uit = await linkBonnetje(maakRpc(rpcUrl), { payload, salt, asset })
162
+ const regels = []
163
+ if (uit.linked) {
164
+ regels.push('LINKED. This receipt opens to exactly one on-chain authorization.')
165
+ regels.push('nonce ' + uit.identifier)
166
+ if (uit.afrekening?.tx) regels.push('transaction ' + uit.afrekening.tx)
167
+ if (uit.afrekening?.blok) regels.push('block ' + uit.afrekening.blok)
168
+ } else {
169
+ // Niet-gelinkt heeft bij deze controle altijd een REDEN mee, en die reden IS
170
+ // het antwoord. Nul kandidaten en twee kandidaten zijn allebei niet-gelinkt en
171
+ // bewust niet waarschijnlijk-gelinkt: fail-closed, zie 5.6.4 stap 3.
172
+ regels.push('NOT LINKED. ' + (uit.reden ?? 'the commitment did not open to exactly one authorization'))
173
+ regels.push('Two candidates opening the same commitment reads as not linked on purpose, not as probably linked.')
174
+ regels.push('A receipt signed before its settlement was mined can also read this way; retry later or widen the window.')
175
+ }
176
+ if (uit.venster) {
177
+ regels.push(
178
+ '',
179
+ 'searched blocks ' + uit.venster.vanBlok + ' to ' + uit.venster.totBlok +
180
+ ' (' + uit.venster.kandidaten + ' candidate authorizations, ' + uit.venster.aanroepen + ' rpc calls)',
181
+ )
182
+ }
183
+ regels.push('checked against ' + rpcUrl + ' for network ' + netwerk + '.')
184
+ return tekst(regels.join('\n'))
185
+ } catch (e) {
186
+ return tekst('could not check: ' + String(e.message).slice(0, 180) + '\nThis is a failure of the check, not a verdict on the receipt.', true)
187
+ }
188
+ }
189
+
190
+ async function behandel(bericht) {
191
+ const { id, method, params } = bericht
192
+
193
+ if (method === 'initialize') {
194
+ const gevraagd = params?.protocolVersion
195
+ return {
196
+ protocolVersion: ONDERSTEUND.includes(gevraagd) ? gevraagd : PROTOCOL,
197
+ capabilities: { tools: {} },
198
+ serverInfo: { name: NAAM, version: VERSIE },
199
+ }
200
+ }
201
+ if (method === 'ping') return {}
202
+ if (method === 'tools/list') return { tools: GEREEDSCHAP }
203
+ if (method === 'tools/call') {
204
+ const naam = params?.name
205
+ const args = params?.arguments ?? {}
206
+ if (naam === 'check_x402_endpoint') return await doeCheckEndpoint(args)
207
+ if (naam === 'link_receipt_to_settlement') return await doeLinkReceipt(args)
208
+ // Een onbekend gereedschap is een fout van de aanroeper en hoort als JSON-RPC-fout
209
+ // terug, niet als een tekst die op een uitslag lijkt.
210
+ const fout = new Error('unknown tool: ' + String(naam))
211
+ fout.code = -32602
212
+ throw fout
213
+ }
214
+ const fout = new Error('method not found: ' + String(method))
215
+ fout.code = -32601
216
+ throw fout
217
+ }
218
+
219
+ function stuur(obj) {
220
+ process.stdout.write(JSON.stringify(obj) + '\n')
221
+ }
222
+
223
+ export function maakVerwerker() {
224
+ let buffer = ''
225
+ return async function verwerk(brok) {
226
+ buffer += brok
227
+ let grens
228
+ while ((grens = buffer.indexOf('\n')) >= 0) {
229
+ const regel = buffer.slice(0, grens).trim()
230
+ buffer = buffer.slice(grens + 1)
231
+ if (!regel) continue
232
+ let bericht
233
+ try {
234
+ bericht = JSON.parse(regel)
235
+ } catch {
236
+ stuur({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })
237
+ continue
238
+ }
239
+ // Een notificatie heeft geen id en krijgt per specificatie GEEN antwoord. Wie
240
+ // daar toch op antwoordt, laat sommige clients vastlopen.
241
+ if (bericht.id === undefined || bericht.id === null) {
242
+ if (bericht.method === 'notifications/initialized') log('client is ready')
243
+ continue
244
+ }
245
+ try {
246
+ stuur({ jsonrpc: '2.0', id: bericht.id, result: await behandel(bericht) })
247
+ } catch (e) {
248
+ stuur({ jsonrpc: '2.0', id: bericht.id, error: { code: e.code ?? -32603, message: String(e.message).slice(0, 200) } })
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ if (process.argv[1]?.endsWith('mcp-server.mjs')) {
255
+ log('listening on stdio, protocol ' + PROTOCOL + ', ' + GEREEDSCHAP.length + ' tools')
256
+ const verwerk = maakVerwerker()
257
+ process.stdin.setEncoding('utf8')
258
+ process.stdin.on('data', (b) => {
259
+ verwerk(b).catch((e) => log('handler error: ' + e.message))
260
+ })
261
+ process.stdin.on('end', () => process.exit(0))
262
+ }