agent-receipt-verify 0.2.7 → 0.2.8
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/bin/cli.mjs +6 -0
- package/package.json +4 -3
- package/src/attempt-identity.mjs +278 -0
- package/vectors/attempt-identity/all-paths-agree.json +126 -0
- package/vectors/attempt-identity/changed-amount-announced.json +86 -0
- package/vectors/attempt-identity/changed-amount-no-event.json +82 -0
- package/vectors/attempt-identity/changed-amount-silent-collapse.json +81 -0
- package/vectors/attempt-identity/client-supplied-key-unverified.json +29 -0
- package/vectors/attempt-identity/countersignature-matches.json +28 -0
- package/vectors/attempt-identity/countersigned-despite-mismatch.json +29 -0
- package/vectors/attempt-identity/derive-once-honoured.json +122 -0
- package/vectors/attempt-identity/idempotency-key-bound.json +28 -0
- package/vectors/attempt-identity/key-order-changes-identity.json +63 -0
- package/vectors/attempt-identity/key-order-stable-identity.json +62 -0
- package/vectors/attempt-identity/nothing-recorded-not-measured.json +21 -0
- package/vectors/attempt-identity/outcome-folded-into-identity.json +35 -0
- package/vectors/attempt-identity/outcome-kept-out-of-identity.json +34 -0
- package/vectors/attempt-identity/paths-disagree.json +81 -0
- package/vectors/attempt-identity/reference-canonicalized-first.json +28 -0
- package/vectors/attempt-identity/reference-over-transmitted.json +26 -0
- package/vectors/attempt-identity/settle-rederives-downstream.json +103 -0
- package/vectors/attempt-identity/settled-despite-mismatch.json +29 -0
package/bin/cli.mjs
CHANGED
|
@@ -75,6 +75,11 @@ function gebruik() {
|
|
|
75
75
|
console.log(' that one settlement? Eleven run offline, the chain-reading one is')
|
|
76
76
|
console.log(' marked and skipped rather than failed.')
|
|
77
77
|
console.log('')
|
|
78
|
+
console.log(' npx -y agent-receipt-verify attempt-identity')
|
|
79
|
+
console.log(' nineteen vectors for the question the other layers assume: are these')
|
|
80
|
+
console.log(' records about the SAME attempt? One acceptance and at least one refusal')
|
|
81
|
+
console.log(' per normative clause of x402 spec section 5.5. Offline.')
|
|
82
|
+
console.log('')
|
|
78
83
|
console.log('What this does NOT do: it judges the SHAPE of a receipt, not the honesty of')
|
|
79
84
|
console.log('the party that issued it. See CONFORMANCE.md for what is deliberately left')
|
|
80
85
|
console.log('unmeasured.')
|
|
@@ -92,6 +97,7 @@ const onderdelen = {
|
|
|
92
97
|
kanon: '../src/eip3009-autorisatie.mjs',
|
|
93
98
|
beleid: '../bench/eip3009-beleid.mjs',
|
|
94
99
|
'link-vectors': '../src/settlement-link-vectors.mjs',
|
|
100
|
+
'attempt-identity': '../src/attempt-identity.mjs',
|
|
95
101
|
'verify-delivery': '../src/verify-delivery.mjs',
|
|
96
102
|
}
|
|
97
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-receipt-verify",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Checks x402 endpoint conformance before an agent pays, and links the receipt to its settlement.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"node": ">=20"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs && node test/methodefout-bench.mjs",
|
|
11
|
+
"test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs && node test/methodefout-bench.mjs && node test/attempt-identity.mjs",
|
|
12
12
|
"link-example": "node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-2.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-3.json",
|
|
13
13
|
"conformance": "node src/x402-conformance-check.mjs",
|
|
14
14
|
"bench": "node bench/robustness.mjs",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"link-vectors": "node src/settlement-link-vectors.mjs",
|
|
24
24
|
"verify-delivery": "node src/verify-delivery.mjs",
|
|
25
25
|
"kanon-vorm": "node tools/naar-kanon-vorm.mjs",
|
|
26
|
-
"bench-methodefout": "node bench/methodefout.mjs"
|
|
26
|
+
"bench-methodefout": "node bench/methodefout.mjs",
|
|
27
|
+
"attempt-identity": "node src/attempt-identity.mjs"
|
|
27
28
|
},
|
|
28
29
|
"optionalDependencies": {
|
|
29
30
|
"ethers": "^6.17.0"
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Toetst de poging-identiteit: hoort dit alles bij DEZELFDE poging.
|
|
2
|
+
//
|
|
3
|
+
// WAAROM DIT BESTAAT. In erc-8004#99 kwamen op 18 oktober 2026 vier bewijslagen op tafel
|
|
4
|
+
// (vooraf-veiligheid, vooraf-oordeel, betaalbewijs na afrekening, en onafhankelijke
|
|
5
|
+
// werk-acceptatie), en de schrijver van de spec stelde vast dat geen van die vier de
|
|
6
|
+
// vraag eronder beantwoordt: welke van deze bewijsstukken gaan eigenlijk over dezelfde
|
|
7
|
+
// POGING. Hij schrijft die regel in x402#3776 paragraaf 5.5, en die paragraaf sluit af
|
|
8
|
+
// met een verwijzing naar "the normative conformance tests ... for the required negative
|
|
9
|
+
// cases". Die testen bestonden nog niet. Dit is dat.
|
|
10
|
+
//
|
|
11
|
+
// WAT HIER GEMETEN WORDT, en het is bewust alleen de INVARIANT. Niet of een facilitator
|
|
12
|
+
// goed rekent, niet of een handtekening klopt, maar of alle paden van een verzoek
|
|
13
|
+
// dezelfde identiteit opleveren en of een veranderd verzoek een andere identiteit krijgt.
|
|
14
|
+
// Dat is precies wat 5.5 eist en niets meer.
|
|
15
|
+
//
|
|
16
|
+
// WAT HIER MET OPZET NIET GEMETEN WORDT. Wij halen geen enkel pad zelf op. Een vector is
|
|
17
|
+
// een vastgelegde reeks WAARNEMINGEN per pad, en deze module toetst de invariant over die
|
|
18
|
+
// reeks. Een facilitator laten sonderen zou betekenen dat wij zijn betaalpaden aanroepen,
|
|
19
|
+
// en dat is niet aan ons.
|
|
20
|
+
//
|
|
21
|
+
// DRIE UITKOMSTEN, NOOIT TWEE: PASS, FAIL, NOT_MEASURED. Een clausule waarover de vector
|
|
22
|
+
// geen gegevens draagt is niet geslaagd en niet gefaald, hij is niet gemeten. Dat
|
|
23
|
+
// onderscheid is dezelfde correctie die de Kanon-auteur op 17 oktober 2026 overnam voor
|
|
24
|
+
// zijn eigen controleur, en de spec-auteur een dag later voor de zijne.
|
|
25
|
+
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* De normatieve clausules van paragraaf 5.5, met het letterlijke citaat erbij.
|
|
30
|
+
*
|
|
31
|
+
* Het citaat staat hier zodat een lezer kan nazien dat wij toetsen wat er STAAT en niet
|
|
32
|
+
* wat wij ervan maakten. Verandert de tekst, dan hoort dit mee te veranderen en is het
|
|
33
|
+
* verschil zichtbaar in de diff in plaats van verstopt in een oordeel.
|
|
34
|
+
*/
|
|
35
|
+
export const CLAUSULES = [
|
|
36
|
+
{
|
|
37
|
+
id: 'derive-once-at-ingress',
|
|
38
|
+
citaat:
|
|
39
|
+
'MUST be derived once, at ingress, from the complete request snapshot as received by the facilitator, ' +
|
|
40
|
+
'and MUST NOT be re-derived or partially re-derived downstream',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'all-paths-same-artifact',
|
|
44
|
+
citaat:
|
|
45
|
+
'/settle, refusal handling, replay-lookup, and challenge-quote responses all resolve the same artifact value',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'changed-request-different-artifact',
|
|
49
|
+
citaat:
|
|
50
|
+
'A request whose amount, query, or body changes between paths MUST produce a different artifact, and that ' +
|
|
51
|
+
'change MUST surface as a settlement or announcement event, never as a silent divergence',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'countersignature-recompute-or-refuse',
|
|
55
|
+
citaat:
|
|
56
|
+
'that counterparty MUST independently recompute the fingerprint from the bytes it observed, and MUST refuse ' +
|
|
57
|
+
'to sign if the recomputed value differs ... on mismatch the interaction MUST NOT settle',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'own-identity-from-decoded-fields',
|
|
61
|
+
citaat:
|
|
62
|
+
"an attempt's own identity ... MUST be derived from named decoded fields and MUST NOT be derived from wire " +
|
|
63
|
+
'bytes, an attempt is the same attempt whether it arrived with a different key order or different whitespace',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'reference-over-transmitted-bytes',
|
|
67
|
+
citaat:
|
|
68
|
+
'that reference is taken over the artifact as transmitted, because the signature covers those bytes, ' +
|
|
69
|
+
'canonicalizing them first would verify a different object than the one that was signed',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'outcome-not-folded-into-identity',
|
|
73
|
+
citaat:
|
|
74
|
+
'Outcome payloads produced later in the attempt ... are not folded into the identity artifact, an ' +
|
|
75
|
+
'implementation MUST NOT extend identity derivation with values that did not exist at ingress',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'name-independent-but-bound',
|
|
79
|
+
citaat:
|
|
80
|
+
'An implementation that already uses Idempotency-Key satisfies it by binding that key to the ingress snapshot',
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
export const UITKOMSTEN = ['PASS', 'FAIL', 'NOT_MEASURED']
|
|
85
|
+
|
|
86
|
+
// Een minimale canonicalisatie: sleutels gesorteerd, geen witruimte. Genoeg om "dezelfde
|
|
87
|
+
// velden, andere sleutelvolgorde" te herkennen, en met opzet NIET uitgegeven als een
|
|
88
|
+
// volledige RFC8785-implementatie: die heeft eigen regels voor getallen, en een halve
|
|
89
|
+
// implementatie onder een bekende naam is erger dan een eigen kleine functie onder een
|
|
90
|
+
// eigen naam.
|
|
91
|
+
export function sorteerStabiel(w) {
|
|
92
|
+
if (Array.isArray(w)) return w.map(sorteerStabiel)
|
|
93
|
+
if (w && typeof w === 'object') {
|
|
94
|
+
const uit = {}
|
|
95
|
+
for (const k of Object.keys(w).sort()) uit[k] = sorteerStabiel(w[k])
|
|
96
|
+
return uit
|
|
97
|
+
}
|
|
98
|
+
return w
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const velddigest = (o) => createHash('sha256').update(JSON.stringify(sorteerStabiel(o))).digest('hex')
|
|
102
|
+
export const bytedigest = (s) => createHash('sha256').update(String(s), 'utf8').digest('hex')
|
|
103
|
+
|
|
104
|
+
const kaal = (w) => (typeof w === 'string' ? w.trim().toLowerCase().replace(/^0x/, '') : null)
|
|
105
|
+
const zelfde = (a, b) => kaal(a) !== null && kaal(a) === kaal(b)
|
|
106
|
+
|
|
107
|
+
const PAD_NAMEN = ['challenge-quote', 'settle', 'refusal', 'replay-lookup']
|
|
108
|
+
|
|
109
|
+
/** Eén clausule, één oordeel. Geen gegevens betekent NOT_MEASURED en nooit FAIL. */
|
|
110
|
+
function toetsClausule(id, inv) {
|
|
111
|
+
const niet = (reden) => ({ clausule: id, uitkomst: 'NOT_MEASURED', reden })
|
|
112
|
+
const faal = (code, detail) => ({ clausule: id, uitkomst: 'FAIL', reason_code: code, detail })
|
|
113
|
+
const goed = (detail) => ({ clausule: id, uitkomst: 'PASS', detail })
|
|
114
|
+
|
|
115
|
+
if (id === 'derive-once-at-ingress') {
|
|
116
|
+
const ingress = inv.ingress
|
|
117
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
118
|
+
if (!ingress?.identity || paden.length === 0) return niet('geen ingress-identiteit of geen paden vastgelegd')
|
|
119
|
+
// Een pad dat de identiteit opnieuw afleidt uit iets dat NIET in de ingress-momentopname
|
|
120
|
+
// zat, is de overtreding. De vector legt dat vast als het veld `derived_from`.
|
|
121
|
+
for (const p of paden) {
|
|
122
|
+
if (p.derived_from && p.derived_from !== 'ingress-snapshot') {
|
|
123
|
+
return faal('IDENTITY_REDERIVED_DOWNSTREAM', `pad ${p.path} leidde opnieuw af uit ${p.derived_from}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return goed(`${paden.length} paden, allemaal uit de ingress-momentopname`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (id === 'all-paths-same-artifact') {
|
|
130
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
131
|
+
const metSnapshot = paden.filter((p) => p.identity)
|
|
132
|
+
if (metSnapshot.length < 2) return niet('minder dan twee paden met een identiteit')
|
|
133
|
+
// Alleen paden met EEN GELIJK verzoek horen dezelfde identiteit te hebben. Een pad
|
|
134
|
+
// met een gewijzigd verzoek valt onder de volgende clausule, niet onder deze.
|
|
135
|
+
const basis = inv.ingress?.snapshot ? velddigest(inv.ingress.snapshot) : null
|
|
136
|
+
const gelijkVerzoek = metSnapshot.filter((p) => !p.snapshot || !basis || velddigest(p.snapshot) === basis)
|
|
137
|
+
if (gelijkVerzoek.length < 2) return niet('minder dan twee paden met een ongewijzigd verzoek')
|
|
138
|
+
const eerste = gelijkVerzoek[0]
|
|
139
|
+
for (const p of gelijkVerzoek.slice(1)) {
|
|
140
|
+
if (!zelfde(p.identity, eerste.identity)) {
|
|
141
|
+
return faal('PATHS_DISAGREE_ON_IDENTITY', `${eerste.path} en ${p.path} geven een andere identiteit`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const gedekt = gelijkVerzoek.map((p) => p.path).filter((p) => PAD_NAMEN.includes(p))
|
|
145
|
+
return goed(`${gelijkVerzoek.length} paden eens, waarvan benoemd in de spec: ${gedekt.join(', ') || 'geen'}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (id === 'changed-request-different-artifact') {
|
|
149
|
+
const paden = Array.isArray(inv.paths) ? inv.paths : []
|
|
150
|
+
const basis = inv.ingress?.snapshot
|
|
151
|
+
if (!basis) return niet('geen ingress-momentopname om tegen af te zetten')
|
|
152
|
+
const gewijzigd = paden.filter((p) => p.snapshot && velddigest(p.snapshot) !== velddigest(basis))
|
|
153
|
+
if (gewijzigd.length === 0) return niet('geen pad met een gewijzigd verzoek vastgelegd')
|
|
154
|
+
for (const p of gewijzigd) {
|
|
155
|
+
if (zelfde(p.identity, inv.ingress.identity)) {
|
|
156
|
+
return faal('CHANGED_REQUEST_SAME_IDENTITY', `pad ${p.path} wijzigde het verzoek en hield dezelfde identiteit`)
|
|
157
|
+
}
|
|
158
|
+
const events = Array.isArray(inv.events) ? inv.events : []
|
|
159
|
+
const erkend = events.some((e) => zelfde(e.identity, p.identity))
|
|
160
|
+
if (!erkend) {
|
|
161
|
+
return faal('CHANGED_REQUEST_NO_EVENT', `pad ${p.path} kreeg een andere identiteit zonder dat er een gebeurtenis bij hoort`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return goed(`${gewijzigd.length} gewijzigd(e) verzoek(en), elk met eigen identiteit en een gebeurtenis`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (id === 'countersignature-recompute-or-refuse') {
|
|
168
|
+
const c = inv.countersignature
|
|
169
|
+
if (!c) return niet('geen medeondertekening vastgelegd')
|
|
170
|
+
if (c.recomputed === undefined || c.ingress_value === undefined) return niet('geen herberekende of ingress-waarde')
|
|
171
|
+
const past = zelfde(c.recomputed, c.ingress_value)
|
|
172
|
+
if (!past && c.signed === true) return faal('COUNTERSIGNED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch ondertekend')
|
|
173
|
+
if (!past && c.settled === true) return faal('SETTLED_DESPITE_MISMATCH', 'de herberekende waarde week af en er is toch afgerekend')
|
|
174
|
+
if (past && c.signed === false) return goed('de waarden kloppen en er is niet ondertekend, dat is toegestaan maar ongebruikelijk')
|
|
175
|
+
return goed(past ? 'herberekend en gelijk, dus ondertekenen mag' : 'herberekend en afwijkend, dus geweigerd en niet afgerekend')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (id === 'own-identity-from-decoded-fields') {
|
|
179
|
+
const v = inv.wire_variants
|
|
180
|
+
if (!Array.isArray(v) || v.length < 2) return niet('minder dan twee draadvarianten vastgelegd')
|
|
181
|
+
// Dezelfde velden, andere sleutelvolgorde of witruimte. De identiteit MOET gelijk
|
|
182
|
+
// blijven; verschilt hij, dan is hij uit de bytes afgeleid.
|
|
183
|
+
const digests = new Set(v.map((x) => velddigest(x.decoded)))
|
|
184
|
+
if (digests.size !== 1) return niet('de varianten hebben niet dezelfde gedecodeerde velden')
|
|
185
|
+
const ids = new Set(v.map((x) => kaal(x.identity)))
|
|
186
|
+
if (ids.size !== 1) {
|
|
187
|
+
return faal('IDENTITY_DERIVED_FROM_WIRE_BYTES', 'dezelfde velden in een andere sleutelvolgorde gaven een andere identiteit')
|
|
188
|
+
}
|
|
189
|
+
return goed(`${v.length} draadvarianten, dezelfde identiteit`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (id === 'reference-over-transmitted-bytes') {
|
|
193
|
+
const r = inv.signed_artifact_reference
|
|
194
|
+
if (!r || r.transmitted === undefined || r.reference_hash === undefined) return niet('geen verwijzing naar een ondertekend stuk')
|
|
195
|
+
// Wij rekenen beide kanten ZELF uit in plaats van een label te geloven.
|
|
196
|
+
const overBytes = bytedigest(r.transmitted)
|
|
197
|
+
let overVelden = null
|
|
198
|
+
try {
|
|
199
|
+
overVelden = velddigest(JSON.parse(r.transmitted))
|
|
200
|
+
} catch {
|
|
201
|
+
overVelden = null
|
|
202
|
+
}
|
|
203
|
+
if (zelfde(r.reference_hash, overBytes)) return goed('de verwijzing is over de verzonden bytes genomen')
|
|
204
|
+
if (overVelden && zelfde(r.reference_hash, overVelden)) {
|
|
205
|
+
return faal('REFERENCE_CANONICALIZED_BEFORE_HASHING', 'de verwijzing is over een gecanonicaliseerde vorm genomen, dus over een ander object dan er ondertekend is')
|
|
206
|
+
}
|
|
207
|
+
return faal('REFERENCE_HASH_UNEXPLAINED', 'de verwijzing komt noch met de verzonden bytes noch met een gecanonicaliseerde vorm overeen')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (id === 'outcome-not-folded-into-identity') {
|
|
211
|
+
const o = inv.outcome_variants
|
|
212
|
+
if (!Array.isArray(o) || o.length < 2) return niet('minder dan twee uitkomst-varianten vastgelegd')
|
|
213
|
+
const ids = new Set(o.map((x) => kaal(x.identity)))
|
|
214
|
+
if (ids.size !== 1) {
|
|
215
|
+
return faal('OUTCOME_FOLDED_INTO_IDENTITY', 'de identiteit verschilt tussen volledige en gedeeltelijke levering, dus uitkomstdata zit erin')
|
|
216
|
+
}
|
|
217
|
+
return goed(`${o.length} leveringsvarianten, dezelfde identiteit`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (id === 'name-independent-but-bound') {
|
|
221
|
+
const k = inv.identity_field
|
|
222
|
+
if (!k || k.bound_to === undefined) return niet('geen identiteitsveld met een binding vastgelegd')
|
|
223
|
+
if (k.bound_to !== 'ingress-snapshot') {
|
|
224
|
+
return faal('IDENTITY_NOT_BOUND_TO_SNAPSHOT', `het veld ${k.name ?? 'onbekend'} is gebonden aan ${k.bound_to} in plaats van aan de ingress-momentopname`)
|
|
225
|
+
}
|
|
226
|
+
if (k.client_supplied === true && k.verified_against_snapshot !== true) {
|
|
227
|
+
return faal('CLIENT_SUPPLIED_IDENTITY_NOT_VERIFIED', 'de client levert de identiteit aan en die wordt niet tegen de momentopname getoetst')
|
|
228
|
+
}
|
|
229
|
+
return goed(`${k.name ?? 'het identiteitsveld'} is aan de ingress-momentopname gebonden`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return niet('onbekende clausule')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Toetst alle clausules waarover de vector gegevens draagt.
|
|
237
|
+
*
|
|
238
|
+
* Het eindoordeel is FAIL zodra EEN clausule faalt, want 5.5 is een invariant en niet een
|
|
239
|
+
* cijferlijst. Zonder enige meetbare clausule is het NOT_MEASURED, nooit PASS: een vector
|
|
240
|
+
* zonder gegevens mag nooit als bewijs van naleving tellen.
|
|
241
|
+
*/
|
|
242
|
+
export function toetsPogingIdentiteit(invoer) {
|
|
243
|
+
const inv = invoer && typeof invoer === 'object' ? invoer : {}
|
|
244
|
+
const perClausule = CLAUSULES.map((c) => toetsClausule(c.id, inv))
|
|
245
|
+
const gefaald = perClausule.filter((r) => r.uitkomst === 'FAIL')
|
|
246
|
+
const geslaagd = perClausule.filter((r) => r.uitkomst === 'PASS')
|
|
247
|
+
const uitkomst = gefaald.length > 0 ? 'FAIL' : geslaagd.length > 0 ? 'PASS' : 'NOT_MEASURED'
|
|
248
|
+
return {
|
|
249
|
+
outcome: uitkomst,
|
|
250
|
+
reason_code: gefaald[0]?.reason_code ?? null,
|
|
251
|
+
detail: gefaald[0]?.detail ?? null,
|
|
252
|
+
gemeten: geslaagd.length + gefaald.length,
|
|
253
|
+
niet_gemeten: perClausule.length - geslaagd.length - gefaald.length,
|
|
254
|
+
per_clausule: perClausule,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Een ingang, zodat dit geen bestand is dat iemand moet vinden maar een regel die hij kan
|
|
259
|
+
// draaien. Zonder sleutel, zonder account, zonder netwerk.
|
|
260
|
+
if (process.argv[1]?.endsWith('attempt-identity.mjs')) {
|
|
261
|
+
const fs = await import('node:fs')
|
|
262
|
+
const path = await import('node:path')
|
|
263
|
+
const { fileURLToPath } = await import('node:url')
|
|
264
|
+
const map = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vectors', 'attempt-identity')
|
|
265
|
+
const bestanden = fs.readdirSync(map).filter((b) => b.endsWith('.json')).sort()
|
|
266
|
+
let past = 0
|
|
267
|
+
for (const b of bestanden) {
|
|
268
|
+
const v = JSON.parse(fs.readFileSync(path.join(map, b), 'utf8'))
|
|
269
|
+
const u = toetsPogingIdentiteit(v.input)
|
|
270
|
+
const ok = u.outcome === v.expected.outcome && (v.expected.reason_code === undefined || u.reason_code === v.expected.reason_code)
|
|
271
|
+
if (ok) past++
|
|
272
|
+
console.log((ok ? ' ok ' : ' FAIL ') + b.replace('.json', '').padEnd(40) + u.outcome + (u.reason_code ? ' / ' + u.reason_code : ''))
|
|
273
|
+
}
|
|
274
|
+
console.log('')
|
|
275
|
+
console.log(past + ' of ' + bestanden.length + ' vectors behave as recorded, across ' + CLAUSULES.length + ' normative clauses of x402 spec section 5.5.')
|
|
276
|
+
console.log('Every clause carries one acceptance and at least one refusal. No account, no key, no network.')
|
|
277
|
+
if (past !== bestanden.length) process.exitCode = 1
|
|
278
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "3.0.0",
|
|
3
|
+
"verifies": "attempt-identity",
|
|
4
|
+
"requires_chain_read": false,
|
|
5
|
+
"provenance": [
|
|
6
|
+
"x402 specification v2 section 5.5, Settlement and Refusal Identity Invariant, as proposed in x402-foundation/x402 pull request 3776 on branch spec/settlement-refusal-identity-invariant and not on main",
|
|
7
|
+
"the gap these cover was named by ruiruii in erc-8004/erc-8004-contracts#99 on 18 October 2026: each of the four evidence layers depends on an attempt identity that none of them defines",
|
|
8
|
+
"section 5.5 closes by referring to \"the normative conformance tests in the specification repository for the required negative cases\"; at the time of writing those did not exist"
|
|
9
|
+
],
|
|
10
|
+
"id": "attempt-identity-all-paths-agree",
|
|
11
|
+
"clause": "all-paths-same-artifact",
|
|
12
|
+
"encodes": "The positive control for section 5.5: four named paths, one identity, derived once at ingress.",
|
|
13
|
+
"description": "All four paths named in the clause resolve the same identity artifact, and each reports that it took that value from the ingress snapshot rather than re-deriving it. Without this vector the set would contain only refusals, which proves the harness says no and nothing about whether it can say yes.",
|
|
14
|
+
"input": {
|
|
15
|
+
"ingress": {
|
|
16
|
+
"snapshot": {
|
|
17
|
+
"method": "POST",
|
|
18
|
+
"path": "/api/paid/verify-ownership",
|
|
19
|
+
"query": "",
|
|
20
|
+
"body": {
|
|
21
|
+
"endpoint": "https://example.com/api/health",
|
|
22
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
23
|
+
"method": "dns-txt"
|
|
24
|
+
},
|
|
25
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
26
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
27
|
+
"network": "eip155:8453",
|
|
28
|
+
"amount": "1000",
|
|
29
|
+
"scheme": "exact"
|
|
30
|
+
},
|
|
31
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196"
|
|
32
|
+
},
|
|
33
|
+
"paths": [
|
|
34
|
+
{
|
|
35
|
+
"path": "challenge-quote",
|
|
36
|
+
"snapshot": {
|
|
37
|
+
"method": "POST",
|
|
38
|
+
"path": "/api/paid/verify-ownership",
|
|
39
|
+
"query": "",
|
|
40
|
+
"body": {
|
|
41
|
+
"endpoint": "https://example.com/api/health",
|
|
42
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
43
|
+
"method": "dns-txt"
|
|
44
|
+
},
|
|
45
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
46
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
47
|
+
"network": "eip155:8453",
|
|
48
|
+
"amount": "1000",
|
|
49
|
+
"scheme": "exact"
|
|
50
|
+
},
|
|
51
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196",
|
|
52
|
+
"derived_from": "ingress-snapshot"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "settle",
|
|
56
|
+
"snapshot": {
|
|
57
|
+
"method": "POST",
|
|
58
|
+
"path": "/api/paid/verify-ownership",
|
|
59
|
+
"query": "",
|
|
60
|
+
"body": {
|
|
61
|
+
"endpoint": "https://example.com/api/health",
|
|
62
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
63
|
+
"method": "dns-txt"
|
|
64
|
+
},
|
|
65
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
66
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
67
|
+
"network": "eip155:8453",
|
|
68
|
+
"amount": "1000",
|
|
69
|
+
"scheme": "exact"
|
|
70
|
+
},
|
|
71
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196",
|
|
72
|
+
"derived_from": "ingress-snapshot"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"path": "refusal",
|
|
76
|
+
"snapshot": {
|
|
77
|
+
"method": "POST",
|
|
78
|
+
"path": "/api/paid/verify-ownership",
|
|
79
|
+
"query": "",
|
|
80
|
+
"body": {
|
|
81
|
+
"endpoint": "https://example.com/api/health",
|
|
82
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
83
|
+
"method": "dns-txt"
|
|
84
|
+
},
|
|
85
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
86
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
87
|
+
"network": "eip155:8453",
|
|
88
|
+
"amount": "1000",
|
|
89
|
+
"scheme": "exact"
|
|
90
|
+
},
|
|
91
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196",
|
|
92
|
+
"derived_from": "ingress-snapshot"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"path": "replay-lookup",
|
|
96
|
+
"snapshot": {
|
|
97
|
+
"method": "POST",
|
|
98
|
+
"path": "/api/paid/verify-ownership",
|
|
99
|
+
"query": "",
|
|
100
|
+
"body": {
|
|
101
|
+
"endpoint": "https://example.com/api/health",
|
|
102
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
103
|
+
"method": "dns-txt"
|
|
104
|
+
},
|
|
105
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
106
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
107
|
+
"network": "eip155:8453",
|
|
108
|
+
"amount": "1000",
|
|
109
|
+
"scheme": "exact"
|
|
110
|
+
},
|
|
111
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196",
|
|
112
|
+
"derived_from": "ingress-snapshot"
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"identity_field": {
|
|
116
|
+
"name": "attempt_id",
|
|
117
|
+
"bound_to": "ingress-snapshot"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"expected": {
|
|
121
|
+
"outcome": "PASS"
|
|
122
|
+
},
|
|
123
|
+
"limits": [
|
|
124
|
+
"This says the four paths agree. It says nothing about whether the identity was computed over the right fields."
|
|
125
|
+
]
|
|
126
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "3.0.0",
|
|
3
|
+
"verifies": "attempt-identity",
|
|
4
|
+
"requires_chain_read": false,
|
|
5
|
+
"provenance": [
|
|
6
|
+
"x402 specification v2 section 5.5, Settlement and Refusal Identity Invariant, as proposed in x402-foundation/x402 pull request 3776 on branch spec/settlement-refusal-identity-invariant and not on main",
|
|
7
|
+
"the gap these cover was named by ruiruii in erc-8004/erc-8004-contracts#99 on 18 October 2026: each of the four evidence layers depends on an attempt identity that none of them defines",
|
|
8
|
+
"section 5.5 closes by referring to \"the normative conformance tests in the specification repository for the required negative cases\"; at the time of writing those did not exist"
|
|
9
|
+
],
|
|
10
|
+
"id": "attempt-identity-changed-amount-announced",
|
|
11
|
+
"clause": "changed-request-different-artifact",
|
|
12
|
+
"encodes": "The positive control for the changed-request half: new identity, and an event that names it.",
|
|
13
|
+
"description": "The amount changed, the identity changed, and an announcement event carries the new identity. This is what the clause asks for, and it is here so the two refusals above cannot be mistaken for \"any change fails\".",
|
|
14
|
+
"input": {
|
|
15
|
+
"ingress": {
|
|
16
|
+
"snapshot": {
|
|
17
|
+
"method": "POST",
|
|
18
|
+
"path": "/api/paid/verify-ownership",
|
|
19
|
+
"query": "",
|
|
20
|
+
"body": {
|
|
21
|
+
"endpoint": "https://example.com/api/health",
|
|
22
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
23
|
+
"method": "dns-txt"
|
|
24
|
+
},
|
|
25
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
26
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
27
|
+
"network": "eip155:8453",
|
|
28
|
+
"amount": "1000",
|
|
29
|
+
"scheme": "exact"
|
|
30
|
+
},
|
|
31
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196"
|
|
32
|
+
},
|
|
33
|
+
"paths": [
|
|
34
|
+
{
|
|
35
|
+
"path": "challenge-quote",
|
|
36
|
+
"snapshot": {
|
|
37
|
+
"method": "POST",
|
|
38
|
+
"path": "/api/paid/verify-ownership",
|
|
39
|
+
"query": "",
|
|
40
|
+
"body": {
|
|
41
|
+
"endpoint": "https://example.com/api/health",
|
|
42
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
43
|
+
"method": "dns-txt"
|
|
44
|
+
},
|
|
45
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
46
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
47
|
+
"network": "eip155:8453",
|
|
48
|
+
"amount": "1000",
|
|
49
|
+
"scheme": "exact"
|
|
50
|
+
},
|
|
51
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "settle",
|
|
55
|
+
"snapshot": {
|
|
56
|
+
"method": "POST",
|
|
57
|
+
"path": "/api/paid/verify-ownership",
|
|
58
|
+
"query": "",
|
|
59
|
+
"body": {
|
|
60
|
+
"endpoint": "https://example.com/api/health",
|
|
61
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
62
|
+
"method": "dns-txt"
|
|
63
|
+
},
|
|
64
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
65
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
66
|
+
"network": "eip155:8453",
|
|
67
|
+
"amount": "2000",
|
|
68
|
+
"scheme": "exact"
|
|
69
|
+
},
|
|
70
|
+
"identity": "b1b9009e0c9e7d66fc4e47a66303e8ccdddce8cc3462bc9de007abf524855bcb"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"events": [
|
|
74
|
+
{
|
|
75
|
+
"type": "announcement",
|
|
76
|
+
"identity": "b1b9009e0c9e7d66fc4e47a66303e8ccdddce8cc3462bc9de007abf524855bcb"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"expected": {
|
|
81
|
+
"outcome": "PASS"
|
|
82
|
+
},
|
|
83
|
+
"limits": [
|
|
84
|
+
"The event is taken as recorded. Whether it was actually emitted to anyone is outside this layer."
|
|
85
|
+
]
|
|
86
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "3.0.0",
|
|
3
|
+
"verifies": "attempt-identity",
|
|
4
|
+
"requires_chain_read": false,
|
|
5
|
+
"provenance": [
|
|
6
|
+
"x402 specification v2 section 5.5, Settlement and Refusal Identity Invariant, as proposed in x402-foundation/x402 pull request 3776 on branch spec/settlement-refusal-identity-invariant and not on main",
|
|
7
|
+
"the gap these cover was named by ruiruii in erc-8004/erc-8004-contracts#99 on 18 October 2026: each of the four evidence layers depends on an attempt identity that none of them defines",
|
|
8
|
+
"section 5.5 closes by referring to \"the normative conformance tests in the specification repository for the required negative cases\"; at the time of writing those did not exist"
|
|
9
|
+
],
|
|
10
|
+
"id": "attempt-identity-changed-amount-no-event",
|
|
11
|
+
"clause": "changed-request-different-artifact",
|
|
12
|
+
"encodes": "A changed request that correctly gets a new identity but surfaces nowhere, which the clause calls a silent divergence.",
|
|
13
|
+
"description": "The amount changed, the identity changed with it, and no settlement or announcement event names the new identity. Half the clause is satisfied and the half that makes it observable is not, so a reader sees two unrelated attempts instead of one attempt that changed.",
|
|
14
|
+
"input": {
|
|
15
|
+
"ingress": {
|
|
16
|
+
"snapshot": {
|
|
17
|
+
"method": "POST",
|
|
18
|
+
"path": "/api/paid/verify-ownership",
|
|
19
|
+
"query": "",
|
|
20
|
+
"body": {
|
|
21
|
+
"endpoint": "https://example.com/api/health",
|
|
22
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
23
|
+
"method": "dns-txt"
|
|
24
|
+
},
|
|
25
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
26
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
27
|
+
"network": "eip155:8453",
|
|
28
|
+
"amount": "1000",
|
|
29
|
+
"scheme": "exact"
|
|
30
|
+
},
|
|
31
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196"
|
|
32
|
+
},
|
|
33
|
+
"paths": [
|
|
34
|
+
{
|
|
35
|
+
"path": "challenge-quote",
|
|
36
|
+
"snapshot": {
|
|
37
|
+
"method": "POST",
|
|
38
|
+
"path": "/api/paid/verify-ownership",
|
|
39
|
+
"query": "",
|
|
40
|
+
"body": {
|
|
41
|
+
"endpoint": "https://example.com/api/health",
|
|
42
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
43
|
+
"method": "dns-txt"
|
|
44
|
+
},
|
|
45
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
46
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
47
|
+
"network": "eip155:8453",
|
|
48
|
+
"amount": "1000",
|
|
49
|
+
"scheme": "exact"
|
|
50
|
+
},
|
|
51
|
+
"identity": "2ed52a521f9762c3adc24c8048a4ee04a3796202a34c335585fc953e15c97196"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "settle",
|
|
55
|
+
"snapshot": {
|
|
56
|
+
"method": "POST",
|
|
57
|
+
"path": "/api/paid/verify-ownership",
|
|
58
|
+
"query": "",
|
|
59
|
+
"body": {
|
|
60
|
+
"endpoint": "https://example.com/api/health",
|
|
61
|
+
"ownerAddress": "0x1111111111111111111111111111111111111111",
|
|
62
|
+
"method": "dns-txt"
|
|
63
|
+
},
|
|
64
|
+
"payTo": "0x2888f36548a900b9b08d584524e1f73bc563ec26",
|
|
65
|
+
"asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
66
|
+
"network": "eip155:8453",
|
|
67
|
+
"amount": "2000",
|
|
68
|
+
"scheme": "exact"
|
|
69
|
+
},
|
|
70
|
+
"identity": "b1b9009e0c9e7d66fc4e47a66303e8ccdddce8cc3462bc9de007abf524855bcb"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"events": []
|
|
74
|
+
},
|
|
75
|
+
"expected": {
|
|
76
|
+
"outcome": "FAIL",
|
|
77
|
+
"reason_code": "CHANGED_REQUEST_NO_EVENT"
|
|
78
|
+
},
|
|
79
|
+
"limits": [
|
|
80
|
+
"An empty event list is taken at face value. A checker cannot tell a missing event from an event it was not shown."
|
|
81
|
+
]
|
|
82
|
+
}
|