@integraledger/lcp-binding-evm-mpp 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +198 -0
- package/dist/adapter.d.ts +59 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +76 -0
- package/dist/adapter.js.map +1 -0
- package/dist/credential-type.d.ts +42 -0
- package/dist/credential-type.d.ts.map +1 -0
- package/dist/credential-type.js +17 -0
- package/dist/credential-type.js.map +1 -0
- package/dist/id-reuse.d.ts +130 -0
- package/dist/id-reuse.d.ts.map +1 -0
- package/dist/id-reuse.js +166 -0
- package/dist/id-reuse.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +83 -0
- package/dist/manifest.js.map +1 -0
- package/package.json +63 -0
- package/src/adapter.ts +192 -0
- package/src/credential-type.ts +49 -0
- package/src/id-reuse.ts +210 -0
- package/src/index.ts +18 -0
- package/src/manifest.ts +84 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MPP-EVM Id-Reuse WeldAdapter — the one member of the ten that differs at the weld.
|
|
3
|
+
*
|
|
4
|
+
* - `propose` — `challenge.id = atrHash`, and the EIP-3009 nonce MPP's derivation then requires.
|
|
5
|
+
* - `verifyCandidate` — THE PRIMARY VERIFY SURFACE: confirm a KNOWN atrHash against one settlement's
|
|
6
|
+
* on-chain nonce. You bring the hash; the chain confirms it.
|
|
7
|
+
* - `observe` — a settlement's `AuthorizationUsed` events → `settled` lifecycle transitions.
|
|
8
|
+
*
|
|
9
|
+
* **Both read paths are scoped to MPP's `authorization` credential type, and both say so when they are out of
|
|
10
|
+
* scope.** Only §5.3 puts the derived `challengeHash` on-chain; the RECOMMENDED `permit2` type signs the same
|
|
11
|
+
* value into an off-chain EIP-712 witness and `transaction`/`hash` bind no challenge at all. A settlement in
|
|
12
|
+
* any of those three moves the token and emits no `AuthorizationUsed`, so answering "no transitions" about it
|
|
13
|
+
* would be a wrong answer, not an absence — see `credential-type.ts` for the specification reading and the
|
|
14
|
+
* refusal both members return instead.
|
|
15
|
+
* - `recover` — REFUSES, always, `mpp-evm/not-recoverable-by-construction`. Present because a
|
|
16
|
+
* generic `WeldAdapter` consumer will reach for it and must be told, loudly, that this
|
|
17
|
+
* rail has no recovery; typed `Promise<Refusal>` so the impossibility is visible before
|
|
18
|
+
* the call. It takes no arguments: there is no input a recovery could read.
|
|
19
|
+
* - `enumerate` — ABSENT. The port makes it optional and the manifest declares
|
|
20
|
+
* `forwardIndexable: false`; absence is the declaration.
|
|
21
|
+
*
|
|
22
|
+
* **Why `verifyCandidate` and not a `recover` that re-derives.** A stored challenge would let this package
|
|
23
|
+
* look up an atrHash by settlement and call the answer recovery. That is the service's own records
|
|
24
|
+
* re-labelled, it would falsify `zeroPartyRecoverable: false`, and it is the single edit this package must
|
|
25
|
+
* never accept. Verification needs no such store: the auditor supplies the candidate.
|
|
26
|
+
*
|
|
27
|
+
* viem appears here only through the `Log[]` the injected `ChainReader` hands back and the shared
|
|
28
|
+
* `AuthorizationUsed` decoder in `binding-evm-common` — the same seam `binding-evm-x402` uses, so the event
|
|
29
|
+
* ABI is known in exactly one place.
|
|
30
|
+
*/
|
|
31
|
+
import type {
|
|
32
|
+
BindingManifest,
|
|
33
|
+
LifecycleTransition,
|
|
34
|
+
Outcome,
|
|
35
|
+
Refusal,
|
|
36
|
+
SettlementRef,
|
|
37
|
+
VerifierPorts,
|
|
38
|
+
WeldAdapter,
|
|
39
|
+
} from "@integraledger/lcp-binding-core";
|
|
40
|
+
import {
|
|
41
|
+
assetWasTransferred,
|
|
42
|
+
readAuthorizationUsed,
|
|
43
|
+
refOf,
|
|
44
|
+
} from "@integraledger/lcp-binding-evm-common";
|
|
45
|
+
import type { Log } from "viem";
|
|
46
|
+
import { notAuthorizationCredentialType } from "./credential-type.js";
|
|
47
|
+
import {
|
|
48
|
+
bindAtrHash,
|
|
49
|
+
checkCandidate,
|
|
50
|
+
type MppEvmCandidateConfirmation,
|
|
51
|
+
type MppEvmChallengeBinding,
|
|
52
|
+
notRecoverableByConstruction,
|
|
53
|
+
} from "./id-reuse.js";
|
|
54
|
+
import { MPP_EVM_MANIFEST } from "./manifest.js";
|
|
55
|
+
|
|
56
|
+
/** One chain + token + protection space this adapter binds. */
|
|
57
|
+
export interface MppEvmAdapterConfig {
|
|
58
|
+
chainId: number;
|
|
59
|
+
/** The payment token — the `AuthorizationUsed` emitter whose nonce carries the derivation. */
|
|
60
|
+
asset: `0x${string}`;
|
|
61
|
+
/**
|
|
62
|
+
* `challenge.realm`, MPP's protection space. Not optional and not inferable: it is half the nonce
|
|
63
|
+
* preimage, so an adapter without it cannot derive or confirm anything.
|
|
64
|
+
*/
|
|
65
|
+
realm: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The MPP-EVM surface. It IS a `WeldAdapter` on the EVM-shaped port — the rail is EVM and the types fit —
|
|
70
|
+
* with two members narrowed to say what this rail can and cannot do, and `enumerate` left off.
|
|
71
|
+
*/
|
|
72
|
+
export interface MppEvmAdapter extends WeldAdapter {
|
|
73
|
+
readonly manifest: BindingManifest;
|
|
74
|
+
/** `ctx` is accepted for port compatibility and unused: the realm lives in the adapter config. */
|
|
75
|
+
propose(
|
|
76
|
+
atrHash: `0x${string}`,
|
|
77
|
+
ctx?: unknown,
|
|
78
|
+
): Promise<Outcome<MppEvmChallengeBinding>>;
|
|
79
|
+
/** Confirm a candidate atrHash against ONE settlement's on-chain nonce. */
|
|
80
|
+
verifyCandidate(
|
|
81
|
+
atrHash: string,
|
|
82
|
+
ref: SettlementRef,
|
|
83
|
+
ports: VerifierPorts,
|
|
84
|
+
): Promise<Outcome<MppEvmCandidateConfirmation>>;
|
|
85
|
+
/** Always a refusal — see the module docblock. */
|
|
86
|
+
recover(): Promise<Refusal>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Construct the MPP-EVM adapter for one chain, token and realm. */
|
|
90
|
+
export function createMppEvmAdapter(
|
|
91
|
+
config: MppEvmAdapterConfig,
|
|
92
|
+
): MppEvmAdapter {
|
|
93
|
+
if (config.realm === "")
|
|
94
|
+
throw new Error(
|
|
95
|
+
"MppEvmAdapterConfig.realm must be non-empty — challenge.realm is half the EIP-3009 nonce preimage (draft-evm-charge-00 §5.3.1)",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One settlement's logs, and the `AuthorizationUsed` events the configured token emitted among them.
|
|
100
|
+
*
|
|
101
|
+
* The raw logs travel alongside the decoded events because an EMPTY event set is ambiguous on this rail:
|
|
102
|
+
* only the logs distinguish "this transaction moved none of the asset" from "it settled under a credential
|
|
103
|
+
* type whose challengeHash is not on-chain". Both read members need that distinction, so both read it here.
|
|
104
|
+
*/
|
|
105
|
+
async function settlement(
|
|
106
|
+
ref: SettlementRef,
|
|
107
|
+
ports: VerifierPorts,
|
|
108
|
+
): Promise<{
|
|
109
|
+
logs: Log[];
|
|
110
|
+
events: { nonce: `0x${string}`; logIndex: number | null }[];
|
|
111
|
+
}> {
|
|
112
|
+
const logs = (await ports.chain.getTransactionLogs(ref)) as Log[];
|
|
113
|
+
return { logs, events: readAuthorizationUsed(logs, config.asset) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
manifest: MPP_EVM_MANIFEST,
|
|
118
|
+
|
|
119
|
+
async propose(
|
|
120
|
+
atrHash: `0x${string}`,
|
|
121
|
+
): Promise<Outcome<MppEvmChallengeBinding>> {
|
|
122
|
+
// bindAtrHash throws (fail-fast) on a malformed atrHash — a seller-side wiring defect, not a policy
|
|
123
|
+
// outcome. There is no value-level refusal on this path: MPP constrains nothing we are choosing here.
|
|
124
|
+
return { ok: true, value: bindAtrHash(atrHash, config.realm) };
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async verifyCandidate(
|
|
128
|
+
atrHash: string,
|
|
129
|
+
ref: SettlementRef,
|
|
130
|
+
ports: VerifierPorts,
|
|
131
|
+
): Promise<Outcome<MppEvmCandidateConfirmation>> {
|
|
132
|
+
const { logs, events } = await settlement(ref, ports);
|
|
133
|
+
// Out of scope BEFORE out of luck: a settlement that moved the token under any of MPP's other three
|
|
134
|
+
// credential types is answered by naming that type, never by the pinned-index or absence refusals below.
|
|
135
|
+
if (events.length === 0 && assetWasTransferred(logs, config.asset))
|
|
136
|
+
return notAuthorizationCredentialType(config.asset);
|
|
137
|
+
// An empty set reaches checkCandidate, which owns the `no-settlement-event` refusal — one place, so a
|
|
138
|
+
// caller holding nonces from its own indexer gets the identical answer.
|
|
139
|
+
if (ref.logIndex === undefined || events.length === 0)
|
|
140
|
+
return checkCandidate(
|
|
141
|
+
atrHash,
|
|
142
|
+
config.realm,
|
|
143
|
+
events.map((e) => e.nonce),
|
|
144
|
+
);
|
|
145
|
+
// A pinned logIndex that matches NO event is a failure, never a silent fall back to another event in
|
|
146
|
+
// the same transaction — that could confirm a candidate against a different settlement leg.
|
|
147
|
+
const pinned = events.filter((e) => e.logIndex === ref.logIndex);
|
|
148
|
+
if (pinned.length === 0)
|
|
149
|
+
return {
|
|
150
|
+
refused: true,
|
|
151
|
+
haltClass: "verification-failure",
|
|
152
|
+
code: "mpp-evm/log-index-not-found",
|
|
153
|
+
detail: `no AuthorizationUsed at logIndex ${ref.logIndex} for asset ${config.asset}`,
|
|
154
|
+
};
|
|
155
|
+
return checkCandidate(
|
|
156
|
+
atrHash,
|
|
157
|
+
config.realm,
|
|
158
|
+
pinned.map((e) => e.nonce),
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async observe(
|
|
163
|
+
ref: SettlementRef,
|
|
164
|
+
ports: VerifierPorts,
|
|
165
|
+
): Promise<Outcome<LifecycleTransition[]>> {
|
|
166
|
+
const { logs, events } = await settlement(ref, ports);
|
|
167
|
+
// Settlement is the only on-chain state MPP-EVM exposes; `proposed` is off-chain and pre-settlement.
|
|
168
|
+
if (events.length === 0) {
|
|
169
|
+
// The token moved and no authorization accompanied it: the transaction DID settle, under a credential
|
|
170
|
+
// type this binding cannot read. An empty transition list would assert the opposite.
|
|
171
|
+
if (assetWasTransferred(logs, config.asset))
|
|
172
|
+
return notAuthorizationCredentialType(config.asset);
|
|
173
|
+
// The token did not move either, so there is no settlement of this asset to report — an answer, not
|
|
174
|
+
// a refusal, and one reached without reading a block.
|
|
175
|
+
return { ok: true, value: [] };
|
|
176
|
+
}
|
|
177
|
+
const at = await ports.chain.blockTime(ref);
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
value: events.map((e) => ({
|
|
181
|
+
state: "settled",
|
|
182
|
+
at,
|
|
183
|
+
ref: refOf(ref.chainId, ref.txHash, e.logIndex),
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async recover(): Promise<Refusal> {
|
|
189
|
+
return notRecoverableByConstruction();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPP's EVM method offers FOUR credential types, and this binding can read exactly one of them off a chain.
|
|
3
|
+
* That asymmetry is what this module exists to say out loud, because the alternative is a wrong answer.
|
|
4
|
+
*
|
|
5
|
+
* `draft-evm-charge-00` (paymentauth.org, read 2026-07-30), §5:
|
|
6
|
+
*
|
|
7
|
+
* - **§5.3 `authorization`** — the client signs an EIP-3009 `transferWithAuthorization` message whose `nonce`
|
|
8
|
+
* §5.3.1 REQUIRES to be the `challengeHash`. The derived value is therefore *on-chain*: the token emits
|
|
9
|
+
* `AuthorizationUsed(authorizer, indexed nonce)` and §8 consumes it ("The nonce is consumed on-chain by the
|
|
10
|
+
* token contract itself"). **This is the only credential type this binding can confirm against.**
|
|
11
|
+
* - **§5.2 `permit2`** — the RECOMMENDED type (§5.2: "The RECOMMENDED credential type"). It carries the SAME
|
|
12
|
+
* `challengeHash`, but inside the EIP-712 `PaymentWitness` struct the client signs; §10.4 places it "in the
|
|
13
|
+
* EIP-712 witness data (Permit2)". It never reaches calldata or a log, so a verifier holding only the
|
|
14
|
+
* transaction cannot read it. Settlement goes through Permit2's `permitWitnessTransferFrom`, which moves the
|
|
15
|
+
* token — an ERC-20 `Transfer` and no `AuthorizationUsed`.
|
|
16
|
+
* - **§5.4 `transaction`** and **§5.5 `hash`** — plain ERC-20 transfers with no challenge binding at all;
|
|
17
|
+
* §10.4 says they "provide weaker challenge binding than Permit2 credentials" and that the server "cannot
|
|
18
|
+
* prove the payment was created for a specific challenge instance". Again a `Transfer`, no `AuthorizationUsed`.
|
|
19
|
+
*
|
|
20
|
+
* **So the absence of `AuthorizationUsed` has two possible meanings, and they are not the same answer.** If
|
|
21
|
+
* the configured token did not move either, the transaction settled none of this asset — a true and useful
|
|
22
|
+
* report. If the token DID move, the transaction settled and this binding simply cannot read its credential
|
|
23
|
+
* binding; reporting that as an absence would be a silent wrong answer at a verification boundary. Hence the
|
|
24
|
+
* refusal below, which names the credential type rather than the missing event.
|
|
25
|
+
*
|
|
26
|
+
* The `did the asset move` predicate itself is NOT here — it is `assetWasTransferred` in
|
|
27
|
+
* `binding-evm-common`, because binding-evm-x402 asks the identical question about its own unreadable path
|
|
28
|
+
* (the Permit2 fallback) and two copies of one predicate is how the two rails drift apart. What stays here
|
|
29
|
+
* is the part that is genuinely MPP's: which of MPP's four credential types the caller is looking at, and
|
|
30
|
+
* the refusal that says so.
|
|
31
|
+
*/
|
|
32
|
+
import type { Refusal } from "@integraledger/lcp-binding-core";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The refusal for a settlement this binding cannot read: the token moved, and no EIP-3009 authorization
|
|
36
|
+
* accompanied it.
|
|
37
|
+
*
|
|
38
|
+
* Stated as a refusal rather than a throw because it is a fact about data under audit, not a wiring defect —
|
|
39
|
+
* a seller choosing MPP's RECOMMENDED credential type has done nothing wrong, and the verifier needs to be
|
|
40
|
+
* told which of MPP's four modes it is looking at, not handed an exception.
|
|
41
|
+
*/
|
|
42
|
+
export function notAuthorizationCredentialType(asset: string): Refusal {
|
|
43
|
+
return {
|
|
44
|
+
refused: true,
|
|
45
|
+
haltClass: "verification-failure",
|
|
46
|
+
code: "mpp-evm/not-authorization-credential-type",
|
|
47
|
+
detail: `token ${asset} was transferred by this settlement but it emitted no EIP-3009 AuthorizationUsed — MPP's permit2 (§5.2, the RECOMMENDED type), transaction (§5.4) and hash (§5.5) credential types keep the challengeHash off-chain or bind no challenge at all, and only the opt-in authorization type (§5.3) puts it in the on-chain nonce, so this settlement carries no on-chain value a candidate atrHash could be confirmed against`,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/id-reuse.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPP-EVM Id-Reuse (LCP §8.3.5): the host's derivation rule, the weld it produces, and the *only* kind of
|
|
3
|
+
* check the resulting on-chain value permits.
|
|
4
|
+
*
|
|
5
|
+
* **THE HOST RULE, quoted.** `draft-evm-charge-00` §5.3.1 (paymentauth.org, read 2026-07-30):
|
|
6
|
+
*
|
|
7
|
+
* nonce = keccak256(abi.encodePacked(challenge.id, challenge.realm))
|
|
8
|
+
*
|
|
9
|
+
* — with the accompanying sentence "This specification requires the nonce to be set to the `challengeHash`".
|
|
10
|
+
* The nonce is therefore a *derivation*, not a slot: there is nothing here for LCP to occupy. What LCP does
|
|
11
|
+
* instead is supply an input — the seller sets `challenge.id = atrHash` — so the host's own required
|
|
12
|
+
* derivation carries the record into the settlement transaction.
|
|
13
|
+
*
|
|
14
|
+
* **WHAT MAKES THAT SOUND IS NOT UNIQUENESS, AND AN EARLIER DOCBLOCK SAID IT WAS.** It argued that MPP's
|
|
15
|
+
* "Unique challenge identifier" requirement (`draft-httpauth-payment` §5.1.1) was satisfied by making each
|
|
16
|
+
* ATR unique per transaction. Every citation in it was exact; the sufficiency was not. LCP v1.38 §C.1
|
|
17
|
+
* (:1282-1289):
|
|
18
|
+
*
|
|
19
|
+
* > **It is not simply available, and §8.3.5's uniqueness advice is not sufficient for it.** MPP requires
|
|
20
|
+
* > the server to bind the challenge `id` *to the challenge parameters* … expressly to prevent a client
|
|
21
|
+
* > altering the challenge it presents. **Making each ATR unique per transaction satisfies uniqueness but
|
|
22
|
+
* > not that binding.** A per-transaction ATR that itself states the transaction parameters (Section 6.1)
|
|
23
|
+
* > satisfies both, because the parameters are then inside the hashed document; a terms document made
|
|
24
|
+
* > unique by a timestamp alone does not.
|
|
25
|
+
*
|
|
26
|
+
* **THE REQUIREMENT IS ON THE ATR, AND THE TREE ALREADY VERIFIES IT.** This binding holds a hash, never the
|
|
27
|
+
* document, so it cannot inspect what the ATR says — and it should not: where the ATR lives is the seller's
|
|
28
|
+
* and buyer's business, and a rail binding that needed the document would assert a custody LCP does not
|
|
29
|
+
* require. The property §C.1 asks for is **OFR**: the ATR's offer slot is bound, meaning the transaction
|
|
30
|
+
* parameters are inside the hashed document. `verify` implements it as `offerBoundStep`
|
|
31
|
+
* (`verify/src/composition.ts`) and requires it at **TC-4** (`verify/src/required.ts`). So a record whose
|
|
32
|
+
* class is TC-4 has had this checked by the layer that holds the document; a record below TC-4 has not, and
|
|
33
|
+
* the §8.3.5 discharge on this rail is only as good as that rung.
|
|
34
|
+
*
|
|
35
|
+
* The host is internally inconsistent here and the split is kept visible deliberately: the core draft
|
|
36
|
+
* states the challenge-parameter binding as a **MUST** in the `id` field description and as a **SHOULD** in
|
|
37
|
+
* its challenge-binding section. §C.1 names that inconsistency rather than resolving it, and so does this.
|
|
38
|
+
*
|
|
39
|
+
* **Zero-party-recoverable on-chain binding on this rail still requires an Overlay Contract per §8.3.2**
|
|
40
|
+
* (§C.1:1289). Nothing here recovers an atrHash, and nothing may be added that does — see the closing note.
|
|
41
|
+
*
|
|
42
|
+
* **`abi.encodePacked` over two strings is raw UTF-8 concatenation** — no length prefix, no padding. That was
|
|
43
|
+
* confirmed against Foundry's own encoder rather than assumed, and every oracle in
|
|
44
|
+
* `vectors/binding/mpp-evm-id-reuse.json` was produced by two independent keccak-256 implementations,
|
|
45
|
+
* neither of them the one used here.
|
|
46
|
+
*
|
|
47
|
+
* **The atrHash's SPELLING is part of the preimage.** The derivation hashes the id *as a string*, so
|
|
48
|
+
* `0x7f83…` and `7f83…` are different challenge ids with different nonces. This module therefore canonicalizes
|
|
49
|
+
* to LCP's own spelling — lowercase, `0x`-prefixed, 32 bytes — before deriving OR comparing, on both the
|
|
50
|
+
* candidate and the observed nonce. An uppercase-hex spelling of the same bytes is a legal input that reaches
|
|
51
|
+
* the same nonce, not a second wire convention: it is normalized, and the vectors pin that both ways round.
|
|
52
|
+
* What is REJECTED is a value that is not 32 hex bytes at all — a missing `0x`, a wrong length, a non-hex
|
|
53
|
+
* digit. Those are different challenge ids rather than spellings of this one, and there is no canonical form
|
|
54
|
+
* to map them to.
|
|
55
|
+
*
|
|
56
|
+
* **Packed concatenation is undelimited, and the fixed length is what makes that safe.** `('ab','c')` and
|
|
57
|
+
* `('a','bc')` pack identically; the vectors pin that as an observed property of the host rule. It is
|
|
58
|
+
* unreachable for this binding because `challenge.id` is always exactly 66 characters, so no other split of
|
|
59
|
+
* the preimage produces a legal id. The length check below is therefore load-bearing, not defensive noise.
|
|
60
|
+
*
|
|
61
|
+
* **NOTHING HERE RECOVERS AN atrHash, and no future edit may add such a path.** keccak-256 has no inverse:
|
|
62
|
+
* the honest surface is confirmation of a candidate the auditor already holds. Re-deriving from a stored
|
|
63
|
+
* challenge would be service-record recovery wearing the name of zero-party recovery, and would falsify
|
|
64
|
+
* `zeroPartyRecoverable: false` in the manifest.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { Outcome, Refusal } from "@integraledger/lcp-binding-core";
|
|
68
|
+
import { canonicalAtrHash } from "@integraledger/lcp-kernel";
|
|
69
|
+
import { concat, type Hex, keccak256, stringToBytes } from "viem";
|
|
70
|
+
|
|
71
|
+
/** A 32-byte value in the canonical lowercase-`0x` spelling this binding hashes and compares. */
|
|
72
|
+
const BYTES32 = /^0x[0-9a-fA-F]{64}$/;
|
|
73
|
+
|
|
74
|
+
/** The challenge the seller emits, and the nonce the buyer's authorization will therefore carry. */
|
|
75
|
+
export interface MppEvmChallengeBinding {
|
|
76
|
+
/** `challenge.id` — the atrHash itself, in LCP's canonical lowercase `0x` spelling. */
|
|
77
|
+
readonly challengeId: Hex;
|
|
78
|
+
/** `challenge.realm` — MPP's protection space, the second half of the preimage. */
|
|
79
|
+
readonly realm: string;
|
|
80
|
+
/** The EIP-3009 nonce `keccak256(abi.encodePacked(id, realm))` requires. DERIVED, never chosen. */
|
|
81
|
+
readonly nonce: Hex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A candidate atrHash confirmed against a settlement's on-chain nonce. Confirmation, never recovery. */
|
|
85
|
+
export interface MppEvmCandidateConfirmation {
|
|
86
|
+
readonly confirmed: true;
|
|
87
|
+
/** The candidate that reproduced the on-chain nonce, canonicalized. */
|
|
88
|
+
readonly atrHash: Hex;
|
|
89
|
+
/** The protection space the derivation was performed under — the confirmation is scoped to it. */
|
|
90
|
+
readonly realm: string;
|
|
91
|
+
/** The on-chain nonce the candidate reproduced. */
|
|
92
|
+
readonly nonce: Hex;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* MPP's derivation, exactly as its EVM method specifies it: `keccak256(abi.encodePacked(id, realm))`.
|
|
97
|
+
*
|
|
98
|
+
* Total over any two non-empty strings, because it implements MPP's rule and not an LCP-specific one — an
|
|
99
|
+
* ordinary opaque challenge id derives here too, which is what makes this the host's function rather than
|
|
100
|
+
* ours. THROWS on an empty id or realm: MPP requires both on every challenge (`realm` is a MUST per
|
|
101
|
+
* §5.1.1), so an empty one is a wiring defect in the caller, not a value to hash.
|
|
102
|
+
*/
|
|
103
|
+
export function deriveChallengeHash(challengeId: string, realm: string): Hex {
|
|
104
|
+
if (challengeId === "")
|
|
105
|
+
throw new Error(
|
|
106
|
+
"MPP challenge id must be non-empty — it is half the nonce preimage",
|
|
107
|
+
);
|
|
108
|
+
if (realm === "")
|
|
109
|
+
throw new Error(
|
|
110
|
+
"MPP realm must be non-empty — the core scheme makes realm a MUST on every challenge, and it is half the nonce preimage",
|
|
111
|
+
);
|
|
112
|
+
return keccak256(concat([stringToBytes(challengeId), stringToBytes(realm)]));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The Id-Reuse weld at PROPOSAL time: `challenge.id = atrHash`, and the nonce that follows from it.
|
|
117
|
+
*
|
|
118
|
+
* THROWS on a malformed atrHash, via `canonicalAtrHash` — a seller welding a hash that is not 32 bytes is a
|
|
119
|
+
* wiring defect, and the guard is also what keeps the fixed-length argument above true of the code.
|
|
120
|
+
*
|
|
121
|
+
* **Do not reach for `binding-evm-common`'s `assertBytes32` here.** It enforces the same 32 bytes but
|
|
122
|
+
* explains them as the value that will "ride as the EIP-3009 nonce" — true on x402, false on this rail, and
|
|
123
|
+
* precisely the cross-rail misreading `binding-evm-x402`'s KNOWN-BAD note exists to stop. On MPP-EVM the
|
|
124
|
+
* atrHash rides `challenge.id` and the nonce is DERIVED from it, never occupied by it. `canonicalAtrHash`
|
|
125
|
+
* states the shape requirement and nothing about where the value rides, which is why it is the right guard:
|
|
126
|
+
* the rail-specific reasoning belongs in this docblock and in the manifest, not in an error string.
|
|
127
|
+
*/
|
|
128
|
+
export function bindAtrHash(
|
|
129
|
+
atrHash: string,
|
|
130
|
+
realm: string,
|
|
131
|
+
): MppEvmChallengeBinding {
|
|
132
|
+
const challengeId = canonicalAtrHash(
|
|
133
|
+
atrHash,
|
|
134
|
+
"bindAtrHash",
|
|
135
|
+
"on this rail it rides challenge.id and the EIP-3009 nonce is DERIVED from it (draft-evm-charge-00 §5.3.1), never occupied by it",
|
|
136
|
+
) as Hex;
|
|
137
|
+
return { challengeId, realm, nonce: deriveChallengeHash(challengeId, realm) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Confirm a candidate atrHash against the `AuthorizationUsed` nonces ONE settlement emitted.
|
|
142
|
+
*
|
|
143
|
+
* The auditor brings the atrHash (from the record, or from `legal-context.json`); the chain confirms it.
|
|
144
|
+
* Refusals rather than throws, because both the candidate and the observed nonces are data under audit —
|
|
145
|
+
* the realm is not: it is the verifier's own configuration, so an empty one throws.
|
|
146
|
+
*
|
|
147
|
+
* Every observed nonce is validated BEFORE any is matched: a malformed entry refuses loudly instead of being
|
|
148
|
+
* skipped past, because silently narrowing a settlement view could turn "this transaction is not what you
|
|
149
|
+
* think" into "no match found".
|
|
150
|
+
*/
|
|
151
|
+
export function checkCandidate(
|
|
152
|
+
atrHash: string,
|
|
153
|
+
realm: string,
|
|
154
|
+
observedNonces: readonly string[],
|
|
155
|
+
): Outcome<MppEvmCandidateConfirmation> {
|
|
156
|
+
if (!BYTES32.test(atrHash))
|
|
157
|
+
return {
|
|
158
|
+
refused: true,
|
|
159
|
+
haltClass: "verification-failure",
|
|
160
|
+
code: "mpp-evm/candidate-malformed",
|
|
161
|
+
detail: `a candidate atrHash must be a 0x-prefixed 32-byte value to be a legal MPP challenge id, got "${atrHash}"`,
|
|
162
|
+
};
|
|
163
|
+
for (const observed of observedNonces)
|
|
164
|
+
if (!BYTES32.test(observed))
|
|
165
|
+
return {
|
|
166
|
+
refused: true,
|
|
167
|
+
haltClass: "verification-failure",
|
|
168
|
+
code: "mpp-evm/nonce-malformed",
|
|
169
|
+
detail: `an observed EIP-3009 nonce must be a 0x-prefixed 32-byte value, got "${observed}"`,
|
|
170
|
+
};
|
|
171
|
+
if (observedNonces.length === 0)
|
|
172
|
+
return {
|
|
173
|
+
refused: true,
|
|
174
|
+
haltClass: "verification-failure",
|
|
175
|
+
code: "mpp-evm/no-settlement-event",
|
|
176
|
+
detail:
|
|
177
|
+
"no EIP-3009 AuthorizationUsed nonce was observed for this settlement — there is nothing to verify against",
|
|
178
|
+
};
|
|
179
|
+
const candidate = canonicalAtrHash(atrHash, "checkCandidate") as Hex;
|
|
180
|
+
const nonce = deriveChallengeHash(candidate, realm);
|
|
181
|
+
const matched = observedNonces.find((o) => o.toLowerCase() === nonce);
|
|
182
|
+
if (matched === undefined)
|
|
183
|
+
return {
|
|
184
|
+
refused: true,
|
|
185
|
+
haltClass: "verification-failure",
|
|
186
|
+
code: "mpp-evm/candidate-mismatch",
|
|
187
|
+
detail: `no observed nonce equals keccak256(packed("${candidate}", "${realm}")) = ${nonce}`,
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
value: { confirmed: true, atrHash: candidate, realm, nonce },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The refusal recovery always returns, stated in one place so the reason travels with the code.
|
|
197
|
+
*
|
|
198
|
+
* Not a "not implemented yet" and not a gap to be filled: the on-chain value is a hash over the atrHash, so
|
|
199
|
+
* there is no function from settlement to atrHash to write. A caller wanting the record must bring the
|
|
200
|
+
* candidate and use `verifyCandidate`.
|
|
201
|
+
*/
|
|
202
|
+
export function notRecoverableByConstruction(): Refusal {
|
|
203
|
+
return {
|
|
204
|
+
refused: true,
|
|
205
|
+
haltClass: "verification-failure",
|
|
206
|
+
code: "mpp-evm/not-recoverable-by-construction",
|
|
207
|
+
detail:
|
|
208
|
+
"MPP-EVM is an Id-Reuse binding (LCP §8.3.5): the on-chain EIP-3009 nonce is keccak256 over the atrHash and the realm, so no atrHash can be recovered from a settlement — bring the candidate atrHash and use verifyCandidate",
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createMppEvmAdapter,
|
|
3
|
+
type MppEvmAdapter,
|
|
4
|
+
type MppEvmAdapterConfig,
|
|
5
|
+
} from "./adapter.js";
|
|
6
|
+
// `assetWasTransferred` and `ERC20_TRANSFER_TOPIC0` moved to `@integraledger/lcp-binding-evm-common` when
|
|
7
|
+
// binding-evm-x402 needed the same predicate. They are NOT re-exported from here: a symbol with two homes
|
|
8
|
+
// is how the two copies drift, which is the thing the move was for.
|
|
9
|
+
export { notAuthorizationCredentialType } from "./credential-type.js";
|
|
10
|
+
export {
|
|
11
|
+
bindAtrHash,
|
|
12
|
+
checkCandidate,
|
|
13
|
+
deriveChallengeHash,
|
|
14
|
+
type MppEvmCandidateConfirmation,
|
|
15
|
+
type MppEvmChallengeBinding,
|
|
16
|
+
notRecoverableByConstruction,
|
|
17
|
+
} from "./id-reuse.js";
|
|
18
|
+
export { MPP_EVM_MANIFEST } from "./manifest.js";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { BindingManifest } from "@integraledger/lcp-binding-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The MPP-EVM Id-Reuse binding manifest (published as `mpp-evm-derived-v1`).
|
|
5
|
+
*
|
|
6
|
+
* **pattern = "id-reuse"** (LCP §8.3.5), and this is the one member of the family that differs *at the
|
|
7
|
+
* weld*. On MPP's EVM charge method the EIP-3009 nonce is not ours to occupy: `draft-evm-charge-00` §5.3.1
|
|
8
|
+
* REQUIRES `nonce = keccak256(abi.encodePacked(challenge.id, challenge.realm))`, and §5.2.3 fixes the
|
|
9
|
+
* Permit2 witness type string with a MUST ("Implementations MUST use the exact type string above"). There is
|
|
10
|
+
* no free field on this path. What remains available is Id-Reuse: the seller sets `challenge.id = atrHash`,
|
|
11
|
+
* so the host's own derivation carries the record into the settlement transaction.
|
|
12
|
+
*
|
|
13
|
+
* **Do NOT reconcile this manifest against `binding-evm-x402`'s.** That package's docblock records an
|
|
14
|
+
* archived declaration marked KNOWN-BAD for reading MPP-EVM's derivation-MUST onto the x402 path, where no
|
|
15
|
+
* derivation exists. This is where that reading is correct, and the two manifests legitimately differ: x402
|
|
16
|
+
* is off-canonical Native Field on the same nonce field, MPP-EVM is Id-Reuse over a derivation. "Fixing" one
|
|
17
|
+
* to look like the other reintroduces the bug the archive comment exists to stop.
|
|
18
|
+
*
|
|
19
|
+
* **`nativeField` is absent, and its absence is substantive** — not merely the schema's `iff`. Nothing of
|
|
20
|
+
* ours occupies a protocol field here; the atrHash rides an identifier the host protocol already requires.
|
|
21
|
+
*
|
|
22
|
+
* **SCOPE — this manifest declares ONE of MPP's four credential types, and the scope is load-bearing.**
|
|
23
|
+
* `draft-evm-charge-00` defines four ways a client may present payment, and the derived `challengeHash`
|
|
24
|
+
* reaches the chain in exactly one of them:
|
|
25
|
+
* - **§5.3 `authorization`** — the derived value IS the EIP-3009 nonce, so it is committed on-chain. Opt-in
|
|
26
|
+
* and token-conditional: §4.2.2 "Servers MUST only include `"authorization"` when the `currency` token is
|
|
27
|
+
* known to implement EIP-3009", and §5.3.2 the same rule as a prohibition ("Servers MUST NOT advertise
|
|
28
|
+
* `"authorization"` in `credentialTypes` unless the `currency` token is known to implement EIP-3009").
|
|
29
|
+
* **This is the credential type this binding covers, and the only one it can.**
|
|
30
|
+
* - **§5.2 `permit2`** — "The RECOMMENDED credential type", and it carries the SAME derived `challengeHash`
|
|
31
|
+
* inside the EIP-712 `PaymentWitness` struct, which the client signs but which never appears in calldata or
|
|
32
|
+
* an event log; §10.4 places it "in the EIP-712 witness data (Permit2)". Signature-committed, off-chain,
|
|
33
|
+
* unreadable from a settlement.
|
|
34
|
+
* - **§5.4 `transaction`** and **§5.5 `hash`** — plain ERC-20 transfers. No challengeHash anywhere; §10.4
|
|
35
|
+
* says so outright ("weaker challenge binding than Permit2 credentials").
|
|
36
|
+
*
|
|
37
|
+
* So `weldGrades` is keyed by MPP's own credential-type name rather than by a coinage like `derived`: the key
|
|
38
|
+
* IS the scope, machine-readably, and `finality.note` repeats it in prose for whoever reads only the published
|
|
39
|
+
* profile JSON. A grade for `permit2` would be declared from the specification rather than from this binding —
|
|
40
|
+
* the package constructs no witness — and `transaction`/`hash` have no weld to grade. Generalizing the
|
|
41
|
+
* strongest leg to the whole rail is the failure this scoping exists to prevent, and the adapter enforces the
|
|
42
|
+
* same scope at runtime: a settlement that moved the token without an `AuthorizationUsed` beside it is refused
|
|
43
|
+
* by credential type, never reported as an absence of settlement.
|
|
44
|
+
*
|
|
45
|
+
* **The WLD-3 triple, under that scope:**
|
|
46
|
+
* - `onChain: true` — the derived value is the EIP-3009 nonce, emitted as an indexed topic of
|
|
47
|
+
* `AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)` and consumed on-chain by the
|
|
48
|
+
* token contract (§8: "The nonce is consumed on-chain by the token contract itself"). **Read from the host
|
|
49
|
+
* specification and from the adapter's own behaviour on synthetic logs; NOT yet from a live MPP-EVM
|
|
50
|
+
* settlement** — the repository's opt-in on-chain integration suite discharges that against a real transaction, and until
|
|
51
|
+
* it runs, this member is the one still owed a live proof.
|
|
52
|
+
* - `zeroPartyRecoverable: false` — keccak-256 has no inverse. An auditor holding only the transaction hash
|
|
53
|
+
* reads the nonce and cannot obtain the atrHash from it. Verification of a *candidate* is the whole of
|
|
54
|
+
* what this rail offers, which is why the adapter exposes `verifyCandidate` and why `recover` refuses.
|
|
55
|
+
* Observed of the code: no member maps a settlement to an atrHash, and `recover` takes no arguments.
|
|
56
|
+
* - `forwardIndexable: false` — §8.3's criterion is enumeration "bound to a given `atrHash`", and §8.3.5
|
|
57
|
+
* states it directly: "Not forward-indexable by `atrHash` alone — the on-chain value is a hash over
|
|
58
|
+
* `atrHash` and other inputs." Knowing the realm as well, one *could* derive the nonce and topic-filter it,
|
|
59
|
+
* but that is realm-scoped, and it is degenerate besides: §8.3.5's uniqueness satisfaction makes each ATR
|
|
60
|
+
* unique per transaction, so there is at most one settlement per atrHash to enumerate. Hence `indexing:
|
|
61
|
+
* "none"` and no `enumerate` member. Observed of the code: no `enumerate` exists to call.
|
|
62
|
+
*
|
|
63
|
+
* `weldGrades.authorization = "signature"`: the payer's EIP-3009 typed-data message covers the `nonce` field,
|
|
64
|
+
* so the buyer's signature — not merely an inclusion in a transaction — commits the derived value.
|
|
65
|
+
*/
|
|
66
|
+
export const MPP_EVM_MANIFEST: BindingManifest = {
|
|
67
|
+
rail: "evm:mpp",
|
|
68
|
+
protocol: "mpp",
|
|
69
|
+
pattern: "id-reuse",
|
|
70
|
+
recovery: {
|
|
71
|
+
onChain: true,
|
|
72
|
+
zeroPartyRecoverable: false,
|
|
73
|
+
forwardIndexable: false,
|
|
74
|
+
},
|
|
75
|
+
assetBinding: "filtered", // candidate verification filters the configured token's own log (AuthorizationUsed / assetWasTransferred)
|
|
76
|
+
successGate: "structural", // a reverted tx emits no logs, so the weld event cannot exist
|
|
77
|
+
indexing: "none",
|
|
78
|
+
finality: {
|
|
79
|
+
reversible: false,
|
|
80
|
+
note: "scoped to MPP's `authorization` credential type (§5.3), the only one of the four whose challengeHash reaches the chain — under the RECOMMENDED `permit2` type (§5.2) the same derived value is signature-committed in an off-chain EIP-712 witness, and `transaction`/`hash` (§5.4/§5.5) bind no challenge at all. Within that scope: final on settlement — the EIP-3009 authorization is consumed on-chain and there is no on-rail reversal; the weld is derivation-bound (nonce = keccak256(abi.encodePacked(challenge.id, challenge.realm)), draft-evm-charge-00 §5.3.1), so a candidate atrHash is VERIFIED against the on-chain nonce and never recovered from it; recourse is the record's elected forum (PAY-3/RCS-5), never dispute resolution. The §8.3.5 discharge rests on the ATR STATING the transaction parameters (LCP §6.1), not on ATR uniqueness: MPP binds the challenge id to the challenge parameters, and uniqueness alone does not satisfy that (§C.1). The tree checks it as OFR — `offerBoundStep`, required at TC-4 — so this weld is only as strong as the record's class. Zero-party-recoverable on-chain binding on this rail still requires an Overlay Contract per §8.3.2",
|
|
81
|
+
},
|
|
82
|
+
weldGrades: { authorization: "signature" },
|
|
83
|
+
lifecycleStates: ["proposed", "settled"],
|
|
84
|
+
};
|