@integraledger/lcp-binding-evm-common 0.12.2 → 0.13.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 CHANGED
@@ -1,5 +1,85 @@
1
1
  # @integraledger/lcp-binding-evm-common
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @integraledger/lcp-authority@0.13.0
8
+ - @integraledger/lcp-binding-core@0.13.0
9
+ - @integraledger/lcp-kernel@0.13.0
10
+
11
+ ## 0.12.3
12
+
13
+ ### Patch Changes
14
+
15
+ - 82444ad: `verifyEip3009Signature` does the token's whole acceptance test, and says what it does not do.
16
+
17
+ ⛔⛔ **IT ANSWERED `true` FOR SIGNATURES `FiatTokenV2` REVERTS ON.** For any `(r, s, v)` the pair
18
+ `(r, n − s, v ^ 1)` recovers the same address, so `recoverTypedDataAddress` alone accepts both encodings —
19
+ **measured: the malleated form of an honest payer's own signature verified.** Circle's token routes EOA
20
+ signatures through `ECRecover.sol`, which reverts before recovery on a high-s value and on any `v` outside
21
+ `{27, 28}`. A payer could present the malleated form, be served the resource, and the transfer would revert
22
+ — free goods, which is the exact failure the function exists to prevent. `isCanonicalSignature` is those two
23
+ gates, exported so its boundary is reachable: `s === n/2` is ACCEPTED, because the token's guard is
24
+ `s > n/2`, and no signing run will ever produce that value.
25
+
26
+ ⛔ **The components are read out of the regex MATCH, not sliced at fixed offsets.** With `.slice(66, 130)`,
27
+ dropping the leading anchor shifted every offset and the malformed input failed the `v` gate by accident —
28
+ so the anchor's own mutant survived. The shape check and the component read are now one statement. (Fourth
29
+ instance of an anchor mutant surviving in one day; the other three were killed by adding a `junk0x…` case.)
30
+
31
+ ⛔⛔ **AND THE ERC-1271 CLAIM WAS FALSE.** The first version of this docblock said a contract wallet _"cannot
32
+ sign an EIP-3009 authorization at all — the token has no `isValidSignature` call in that path"_. That is
33
+ wrong for **`FiatTokenV2_2`**, the 2023 implementation deployed as USDC on Base, Arbitrum and Polygon among
34
+ others, which routes `transferWithAuthorization` through `SignatureChecker.isValidSignatureNow` and
35
+ therefore **does** dispatch to ERC-1271 for a contract account. Deciding that needs a chain read and this
36
+ function takes no ports, so its contract is stated narrowly instead: **a `false` means "not signed by that
37
+ EOA", never "the chain will reject it".** A caller that must accept smart-account payers has to make the
38
+ ERC-1271 call itself; one that refuses on this answer alone is choosing to accept EOA payers only, and
39
+ should say so at the call site.
40
+
41
+ ⚠️ **The `try` came off.** It caught every failure and answered `false`, including a `typedData` this
42
+ deployment could not encode — so an operator with a mis-copied `tokenName` would have been told that every
43
+ honest payer is a forger, which is the live mistake the docblock itself names. The two untrusted inputs
44
+ still answer `false`; a wiring error throws.
45
+
46
+ Mutation: `binding-evm-common` 98.56, floor 98. The two remaining `eip3009.ts` survivors are pre-existing,
47
+ in `eip155ChainId`.
48
+
49
+ - 48d1346: `verifyEip3009Signature` — the check the token makes, available before the money moves.
50
+
51
+ `buildEip3009TypedData` has been here since the canonical EVM binding shipped, and nothing beside it could
52
+ answer whether a given authorization was actually signed by the account it names. So a seller surface
53
+ holding a payer's credential had three bad options: take the signature on trust until settlement, grow a
54
+ viem dependency of its own, or reimplement recovery. `seller-mpp` needs exactly this for the 2026-08-24
55
+ audit's C-28, and `seller-x402` already reaches here for `makeEvmAcceptanceVerifier` — the commons owns EVM
56
+ crypto, and this is the piece that was missing.
57
+
58
+ ⭐ **`ecrecover`, because that is what the TOKEN does.** `FiatTokenV2.transferWithAuthorization` recovers the
59
+ signer from the EIP-712 digest and compares it to `from`; this recovers and compares the same way.
60
+
61
+ ⛔ **It deliberately does NOT fall back to ERC-1271.** A contract wallet cannot sign an EIP-3009
62
+ authorization at all — the token has no `isValidSignature` call on that path — so accepting one here would
63
+ accept a payment the chain rejects, which is the failure this check exists to prevent rather than to cause.
64
+
65
+ ⛔ **Answers `false` rather than throwing,** for the reason `atrHashEquals` states about itself: a predicate
66
+ that throws is a worse contract than one that answers. A malformed signature, a signature of the wrong
67
+ length and an `expectedSigner` that is not an address are all _"no, this is not signed by them"_ — and the
68
+ caller is holding an untrusted credential and needs a value it can put on the wire. Addresses compare as
69
+ decoded 20-byte values, so a payer's own lowercase spelling is the same payer.
70
+
71
+ ⚠️ The domain stays the caller's to get right, and it is where this goes wrong in practice: `tokenName` and
72
+ `tokenVersion` are the token's own EIP-712 domain and differ between USDC deployments, so a signature
73
+ verified against a domain copied from another chain fails here exactly as it would on-chain.
74
+
75
+ Mutation: `binding-evm-common` 98.40, floor 98 holds. ⛔ Both regex anchors needed their own case —
76
+ `junk0x…` and `0x…ff` — because every other malformed input was refused by both mutants and the anchors
77
+ could otherwise be deleted with the suite green.
78
+
79
+ - @integraledger/lcp-authority@0.12.3
80
+ - @integraledger/lcp-binding-core@0.12.3
81
+ - @integraledger/lcp-kernel@0.12.3
82
+
3
83
  ## 0.12.2
4
84
 
5
85
  ### Patch Changes
package/dist/eip3009.d.ts CHANGED
@@ -101,4 +101,41 @@ export interface LcpEvmSigner {
101
101
  message: Record<string, unknown>;
102
102
  }): Promise<Hex>;
103
103
  }
104
+ /**
105
+ * Whether the 65-byte signature is one `FiatTokenV2` will accept at all, before asking who signed it.
106
+ *
107
+ * Not a style check. Both rules come straight out of the token's `ECRecover.sol`, and skipping either one
108
+ * turns {@link verifyEip3009Signature} from *"the chain will accept this"* into *"some chain might"*.
109
+ *
110
+ * ⭐ Exported so the boundary is reachable by a test. `s === n/2` is ACCEPTED (the token's guard is
111
+ * `s > n/2`), and no signing run will ever produce that value — so an off-by-one there is invisible from
112
+ * the public predicate, where such a signature simply fails to recover and answers `false` either way.
113
+ */
114
+ export declare function isCanonicalSignature(signature: string): boolean;
115
+ /**
116
+ * Whether an EIP-3009 authorization was signed, canonically, by the account it says it is from.
117
+ *
118
+ * ⭐ **The token's OWN acceptance test, not merely `ecrecover`.** `FiatTokenV2.transferWithAuthorization`
119
+ * routes an EOA signature through `ECRecover.sol`, which refuses a high-s signature and any `v` outside
120
+ * `{27, 28}` before recovering. A check that only recovered would answer `true` to the malleated form of an
121
+ * honest payer's own signature — measured — and the seller would serve the resource against a transfer that
122
+ * reverts. {@link isCanonicalSignature} is those two gates.
123
+ *
124
+ * ⛔⛔ **EOA ONLY, AND ON `FiatTokenV2_2` THAT IS NARROWER THAN THE TOKEN.** An earlier draft of this
125
+ * docblock said a contract wallet *"cannot sign an EIP-3009 authorization at all"*. That is false for
126
+ * `FiatTokenV2_2` — the 2023 implementation deployed as USDC on Base, Arbitrum and Polygon among others —
127
+ * which routes `transferWithAuthorization` through `SignatureChecker.isValidSignatureNow` and therefore
128
+ * DOES dispatch to ERC-1271 for a contract account. Deciding that needs a chain read, and this function
129
+ * takes no ports: **a `false` here means "not signed by that EOA", never "the chain will reject it"**. A
130
+ * caller that must accept smart-account payers has to make the ERC-1271 call itself, and one that refuses
131
+ * on this answer alone is choosing to accept EOA payers only. Say which, at the call site.
132
+ *
133
+ * ⛔ **Answers `false` rather than throwing for the two UNTRUSTED inputs,** for the reason `atrHashEquals`
134
+ * states about itself: a predicate that throws is a worse contract than one that answers. A malformed
135
+ * `signature` and an `expectedSigner` that is not an address are facts about the credential in hand.
136
+ * ⚠️ A `typedData` that cannot be encoded is NOT — that is this deployment's own domain being wrong, and
137
+ * returning `false` for it would tell an operator with a mis-copied `tokenName` that every honest payer is
138
+ * a forger. It throws.
139
+ */
140
+ export declare function verifyEip3009Signature(typedData: Eip3009TypedData, signature: string, expectedSigner: string): Promise<boolean>;
104
141
  //# sourceMappingURL=eip3009.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"eip3009.d.ts","sourceRoot":"","sources":["../src/eip3009.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,KAAK,OAAO,EAAc,KAAK,GAAG,EAAE,KAAK,eAAe,EAAE,MAAM,MAAM,CAAC;AAEhF,qFAAqF;AACrF,eAAO,MAAM,gCAAgC;aAC3C,yBAAyB;iBACrB,IAAI,EAAE,MAAM;iBAAE,IAAI,EAAE,SAAS;;iBAC7B,IAAI,EAAE,IAAI;iBAAE,IAAI,EAAE,SAAS;;iBAC3B,IAAI,EAAE,OAAO;iBAAE,IAAI,EAAE,SAAS;;iBAC9B,IAAI,EAAE,YAAY;iBAAE,IAAI,EAAE,SAAS;;iBACnC,IAAI,EAAE,aAAa;iBAAE,IAAI,EAAE,SAAS;;iBACpC,IAAI,EAAE,OAAO;iBAAE,IAAI,EAAE,SAAS;;CAE1B,CAAC;AAEX;kFACkF;AAClF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKrD;AAED;sGACsG;AACtG,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAMhD;AAED,gGAAgG;AAChG,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,OAAO,CAAC;IACd,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,GAAG,CAAC;CACZ;AAED;;;mGAGmG;AACnG,MAAM,WAAW,iBAAiB;IAChC,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED;;sFAEsF;AACtF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,OAAO,gCAAgC,CAAC;IAC/C,WAAW,EAAE,2BAA2B,CAAC;IACzC,OAAO,EAAE;QACP,IAAI,EAAE,OAAO,CAAC;QACd,EAAE,EAAE,OAAO,CAAC;QACZ,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,GAAG,CAAC;KACZ,CAAC;CACH,CAAC;AAEF;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,iBAAiB,GAAG;IAC3D,aAAa,EAAE,oBAAoB,CAAC;IACpC,SAAS,EAAE,gBAAgB,CAAC;CAC7B,CA+BA;AAED,qHAAqH;AACrH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,OAAO,EAAE;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/B,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;CAClB"}
1
+ {"version":3,"file":"eip3009.d.ts","sourceRoot":"","sources":["../src/eip3009.ts"],"names":[],"mappings":"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"}
package/dist/eip3009.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * The x402-client scheme class is deliberately NOT here; it lives in binding-evm-x402. This module is
8
8
  * protocol-agnostic typed-data construction, viem-only.
9
9
  */
10
- import { getAddress } from "viem";
10
+ import { getAddress, recoverTypedDataAddress, } from "viem";
11
11
  /** EIP-3009 TransferWithAuthorization typed-data field layout (USDC FiatTokenV2). */
12
12
  export const TRANSFER_WITH_AUTHORIZATION_TYPE = {
13
13
  TransferWithAuthorization: [
@@ -70,4 +70,81 @@ export function buildEip3009TypedData(i) {
70
70
  };
71
71
  return { authorization, typedData };
72
72
  }
73
+ /**
74
+ * secp256k1's order halved — the low-s boundary `FiatTokenV2`'s own `ECRecover.sol` enforces.
75
+ *
76
+ * ⛔⛔ **A SIGNATURE HAS TWO VALID ENCODINGS AND THE TOKEN ACCEPTS ONE.** For any `(r, s, v)` the pair
77
+ * `(r, n − s, v ^ 1)` recovers the same address, so `ecrecover` alone says `true` to both. Circle's token
78
+ * reverts on the high-s form before `ecrecover` is reached — *"ECRecover: invalid signature 's' value"* —
79
+ * and on any `v` outside `{27, 28}`. A pre-flight check without these gates answers `true` for a
80
+ * credential the chain refuses, and the seller has already served the resource by then.
81
+ */
82
+ const SECP256K1_HALF_N = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n;
83
+ /**
84
+ * Whether the 65-byte signature is one `FiatTokenV2` will accept at all, before asking who signed it.
85
+ *
86
+ * Not a style check. Both rules come straight out of the token's `ECRecover.sol`, and skipping either one
87
+ * turns {@link verifyEip3009Signature} from *"the chain will accept this"* into *"some chain might"*.
88
+ *
89
+ * ⭐ Exported so the boundary is reachable by a test. `s === n/2` is ACCEPTED (the token's guard is
90
+ * `s > n/2`), and no signing run will ever produce that value — so an off-by-one there is invisible from
91
+ * the public predicate, where such a signature simply fails to recover and answers `false` either way.
92
+ */
93
+ export function isCanonicalSignature(signature) {
94
+ // ⛔ Captured, not sliced at fixed offsets. With `.slice(66, 130)` the components were read from
95
+ // positions that only line up when the anchors hold — so dropping `^` shifted every offset and the
96
+ // malformed input failed the `v` gate by accident, which left the anchor's mutant alive. Reading r/s/v
97
+ // out of the match makes the shape check and the component read the same statement.
98
+ const parts = /^0x([0-9a-fA-F]{64})([0-9a-fA-F]{64})([0-9a-fA-F]{2})$/.exec(signature);
99
+ if (parts === null)
100
+ return false;
101
+ if (BigInt(`0x${parts[2]}`) > SECP256K1_HALF_N)
102
+ return false;
103
+ const v = Number.parseInt(parts[3], 16);
104
+ return v === 27 || v === 28;
105
+ }
106
+ /**
107
+ * Whether an EIP-3009 authorization was signed, canonically, by the account it says it is from.
108
+ *
109
+ * ⭐ **The token's OWN acceptance test, not merely `ecrecover`.** `FiatTokenV2.transferWithAuthorization`
110
+ * routes an EOA signature through `ECRecover.sol`, which refuses a high-s signature and any `v` outside
111
+ * `{27, 28}` before recovering. A check that only recovered would answer `true` to the malleated form of an
112
+ * honest payer's own signature — measured — and the seller would serve the resource against a transfer that
113
+ * reverts. {@link isCanonicalSignature} is those two gates.
114
+ *
115
+ * ⛔⛔ **EOA ONLY, AND ON `FiatTokenV2_2` THAT IS NARROWER THAN THE TOKEN.** An earlier draft of this
116
+ * docblock said a contract wallet *"cannot sign an EIP-3009 authorization at all"*. That is false for
117
+ * `FiatTokenV2_2` — the 2023 implementation deployed as USDC on Base, Arbitrum and Polygon among others —
118
+ * which routes `transferWithAuthorization` through `SignatureChecker.isValidSignatureNow` and therefore
119
+ * DOES dispatch to ERC-1271 for a contract account. Deciding that needs a chain read, and this function
120
+ * takes no ports: **a `false` here means "not signed by that EOA", never "the chain will reject it"**. A
121
+ * caller that must accept smart-account payers has to make the ERC-1271 call itself, and one that refuses
122
+ * on this answer alone is choosing to accept EOA payers only. Say which, at the call site.
123
+ *
124
+ * ⛔ **Answers `false` rather than throwing for the two UNTRUSTED inputs,** for the reason `atrHashEquals`
125
+ * states about itself: a predicate that throws is a worse contract than one that answers. A malformed
126
+ * `signature` and an `expectedSigner` that is not an address are facts about the credential in hand.
127
+ * ⚠️ A `typedData` that cannot be encoded is NOT — that is this deployment's own domain being wrong, and
128
+ * returning `false` for it would tell an operator with a mis-copied `tokenName` that every honest payer is
129
+ * a forger. It throws.
130
+ */
131
+ export async function verifyEip3009Signature(typedData, signature, expectedSigner) {
132
+ if (!/^0x[0-9a-fA-F]{40}$/.test(expectedSigner))
133
+ return false;
134
+ if (!isCanonicalSignature(signature))
135
+ return false;
136
+ // ⛔ NOT wrapped in a try. `isCanonicalSignature` has already established that the signature parses, so
137
+ // the only way this throws now is an unencodable `typedData` — a wiring error, and one a `false` would
138
+ // report as the counterparty's fault. See the head note.
139
+ const recovered = await recoverTypedDataAddress({
140
+ domain: typedData.domain,
141
+ types: typedData.types,
142
+ primaryType: typedData.primaryType,
143
+ message: typedData.message,
144
+ signature: signature,
145
+ });
146
+ // Compared as decoded 20-byte values, not as strings: `getAddress` checksums both sides, so a payer
147
+ // spelling their own address in lowercase is the same payer.
148
+ return getAddress(recovered) === getAddress(expectedSigner);
149
+ }
73
150
  //# sourceMappingURL=eip3009.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"eip3009.js","sourceRoot":"","sources":["../src/eip3009.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAgB,UAAU,EAAkC,MAAM,MAAM,CAAC;AAEhF,qFAAqF;AACrF,MAAM,CAAC,MAAM,gCAAgC,GAAG;IAC9C,yBAAyB,EAAE;QACzB,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE;QACjC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE;QAC/B,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;QAClC,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE;QACvC,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,EAAE;QACxC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;KACnC;CACO,CAAC;AAEX;kFACkF;AAClF,MAAM,UAAU,aAAa,CAAC,OAAe;IAC3C,MAAM,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzC,IAAI,CAAC,CAAC;QACJ,MAAM,IAAI,KAAK,CAAC,+CAA+C,OAAO,GAAG,CAAC,CAAC;IAC7E,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtB,CAAC;AAED;sGACsG;AACtG,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,KAAK,CAAC;QACpC,MAAM,IAAI,KAAK,CACb,4FAA4F,KAAK,GAAG,CACrG,CAAC;IACJ,OAAO,KAAK,CAAC,WAAW,EAAS,CAAC;AACpC,CAAC;AAoDD;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAoB;IAIxD,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC5B,MAAM,aAAa,GAAyB;QAC1C,IAAI;QACJ,EAAE;QACF,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,UAAU,EAAE,CAAC,CAAC,UAAU;QACxB,WAAW,EAAE,CAAC,CAAC,WAAW;QAC1B,KAAK;KACN,CAAC;IACF,MAAM,SAAS,GAAqB;QAClC,MAAM,EAAE;YACN,IAAI,EAAE,CAAC,CAAC,SAAS;YACjB,OAAO,EAAE,CAAC,CAAC,YAAY;YACvB,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,iBAAiB,EAAE,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC;SACnD;QACD,KAAK,EAAE,gCAAgC;QACvC,WAAW,EAAE,2BAA2B;QACxC,OAAO,EAAE;YACP,IAAI;YACJ,EAAE;YACF,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;YACtB,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC;YAChC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC;YAClC,KAAK;SACN;KACF,CAAC;IACF,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC;AACtC,CAAC"}
1
+ {"version":3,"file":"eip3009.js","sourceRoot":"","sources":["../src/eip3009.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAEL,UAAU,EAEV,uBAAuB,GAExB,MAAM,MAAM,CAAC;AAEd,qFAAqF;AACrF,MAAM,CAAC,MAAM,gCAAgC,GAAG;IAC9C,yBAAyB,EAAE;QACzB,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE;QACjC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE;QAC/B,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;QAClC,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE;QACvC,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,EAAE;QACxC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;KACnC;CACO,CAAC;AAEX;kFACkF;AAClF,MAAM,UAAU,aAAa,CAAC,OAAe;IAC3C,MAAM,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzC,IAAI,CAAC,CAAC;QACJ,MAAM,IAAI,KAAK,CAAC,+CAA+C,OAAO,GAAG,CAAC,CAAC;IAC7E,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtB,CAAC;AAED;sGACsG;AACtG,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,KAAK,CAAC;QACpC,MAAM,IAAI,KAAK,CACb,4FAA4F,KAAK,GAAG,CACrG,CAAC;IACJ,OAAO,KAAK,CAAC,WAAW,EAAS,CAAC;AACpC,CAAC;AAoDD;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAoB;IAIxD,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC5B,MAAM,aAAa,GAAyB;QAC1C,IAAI;QACJ,EAAE;QACF,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,UAAU,EAAE,CAAC,CAAC,UAAU;QACxB,WAAW,EAAE,CAAC,CAAC,WAAW;QAC1B,KAAK;KACN,CAAC;IACF,MAAM,SAAS,GAAqB;QAClC,MAAM,EAAE;YACN,IAAI,EAAE,CAAC,CAAC,SAAS;YACjB,OAAO,EAAE,CAAC,CAAC,YAAY;YACvB,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,iBAAiB,EAAE,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC;SACnD;QACD,KAAK,EAAE,gCAAgC;QACvC,WAAW,EAAE,2BAA2B;QACxC,OAAO,EAAE;YACP,IAAI;YACJ,EAAE;YACF,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;YACtB,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC;YAChC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC;YAClC,KAAK;SACN;KACF,CAAC;IACF,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC;AACtC,CAAC;AAaD;;;;;;;;GAQG;AACH,MAAM,gBAAgB,GACpB,mEAAmE,CAAC;AAEtE;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAiB;IACpD,gGAAgG;IAChG,mGAAmG;IACnG,uGAAuG;IACvG,oFAAoF;IACpF,MAAM,KAAK,GAAG,wDAAwD,CAAC,IAAI,CACzE,SAAS,CACV,CAAC;IACF,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACjC,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAW,EAAE,CAAC,GAAG,gBAAgB;QAAE,OAAO,KAAK,CAAC;IACvE,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,CAAC;IAClD,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;AAC9B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAC1C,SAA2B,EAC3B,SAAiB,EACjB,cAAsB;IAEtB,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,cAAc,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,CAAC,oBAAoB,CAAC,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IACnD,uGAAuG;IACvG,uGAAuG;IACvG,yDAAyD;IACzD,MAAM,SAAS,GAAY,MAAM,uBAAuB,CAAC;QACvD,MAAM,EAAE,SAAS,CAAC,MAAM;QACxB,KAAK,EAAE,SAAS,CAAC,KAAK;QACtB,WAAW,EAAE,SAAS,CAAC,WAAW;QAClC,OAAO,EAAE,SAAS,CAAC,OAAO;QAC1B,SAAS,EAAE,SAAgB;KAC5B,CAAC,CAAC;IACH,oGAAoG;IACpG,6DAA6D;IAC7D,OAAO,UAAU,CAAC,SAAS,CAAC,KAAK,UAAU,CAAC,cAAc,CAAC,CAAC;AAC9D,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { makeChainReader } from "./client.js";
2
2
  export { decodeEasAttestation, EAS_GET_ATTESTATION_ABI, type EasAttestation, isEasValidAsOf, type RawEasAttestation, readEasAttestation, } from "./eas.js";
3
3
  export { hashEip712 } from "./eip712.js";
4
- export { assertBytes32, type BuildEip3009Input, buildEip3009TypedData, type Eip3009Authorization, type Eip3009TypedData, eip155ChainId, type LcpEvmSigner, TRANSFER_WITH_AUTHORIZATION_TYPE, } from "./eip3009.js";
4
+ export { assertBytes32, type BuildEip3009Input, buildEip3009TypedData, type Eip3009Authorization, type Eip3009TypedData, eip155ChainId, isCanonicalSignature, type LcpEvmSigner, TRANSFER_WITH_AUTHORIZATION_TYPE, verifyEip3009Signature, } from "./eip3009.js";
5
5
  export { assetWasTransferred, ERC20_TRANSFER_TOPIC0 } from "./erc20.js";
6
6
  export { ACCEPTANCE_DOMAIN_NAME, ACCEPTANCE_DOMAIN_VERSION, ACCEPTANCE_ENVELOPE_TYPE, type AcceptanceSignatureInput, type AcceptanceTypedData, type AcceptanceVerifyOpts, buildAcceptanceTypedData, EVM_ACCEPTANCE_SCHEMES, EVM_PAYLOAD_TYPES, type EvmAcceptanceScheme, type EvmPayloadType, isEvmAcceptanceScheme, isEvmPayloadType, makeEvmAcceptanceVerifier, makeLocalEvmSigner, rfc3339ToUnixSeconds, verifyAcceptanceSignature, } from "./erc1271.js";
7
7
  export { AUTHORIZATION_USED_ABI, type AuthorizationUsedEvent, type OnChainAtrHashProof, readAuthorizationUsed, refOf, verifyAtrHashOnChain, } from "./events.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,KAAK,cAAc,EACnB,cAAc,EACd,KAAK,iBAAiB,EACtB,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,aAAa,EACb,KAAK,iBAAiB,EACtB,qBAAqB,EACrB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,aAAa,EACb,KAAK,YAAY,EACjB,gCAAgC,GACjC,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EACxB,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,sBAAsB,EACtB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,qBAAqB,EACrB,KAAK,EACL,oBAAoB,GACrB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,KAAK,cAAc,EACnB,cAAc,EACd,KAAK,iBAAiB,EACtB,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,aAAa,EACb,KAAK,iBAAiB,EACtB,qBAAqB,EACrB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,aAAa,EACb,oBAAoB,EACpB,KAAK,YAAY,EACjB,gCAAgC,EAChC,sBAAsB,GACvB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EACxB,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,sBAAsB,EACtB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,qBAAqB,EACrB,KAAK,EACL,oBAAoB,GACrB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { makeChainReader } from "./client.js";
2
2
  export { decodeEasAttestation, EAS_GET_ATTESTATION_ABI, isEasValidAsOf, readEasAttestation, } from "./eas.js";
3
3
  export { hashEip712 } from "./eip712.js";
4
- export { assertBytes32, buildEip3009TypedData, eip155ChainId, TRANSFER_WITH_AUTHORIZATION_TYPE, } from "./eip3009.js";
4
+ export { assertBytes32, buildEip3009TypedData, eip155ChainId, isCanonicalSignature, TRANSFER_WITH_AUTHORIZATION_TYPE, verifyEip3009Signature, } from "./eip3009.js";
5
5
  export { assetWasTransferred, ERC20_TRANSFER_TOPIC0 } from "./erc20.js";
6
6
  export { ACCEPTANCE_DOMAIN_NAME, ACCEPTANCE_DOMAIN_VERSION, ACCEPTANCE_ENVELOPE_TYPE, buildAcceptanceTypedData, EVM_ACCEPTANCE_SCHEMES, EVM_PAYLOAD_TYPES, isEvmAcceptanceScheme, isEvmPayloadType, makeEvmAcceptanceVerifier, makeLocalEvmSigner, rfc3339ToUnixSeconds, verifyAcceptanceSignature, } from "./erc1271.js";
7
7
  export { AUTHORIZATION_USED_ABI, readAuthorizationUsed, refOf, verifyAtrHashOnChain, } from "./events.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EAEvB,cAAc,EAEd,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,aAAa,EAEb,qBAAqB,EAGrB,aAAa,EAEb,gCAAgC,GACjC,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EAIxB,wBAAwB,EACxB,sBAAsB,EACtB,iBAAiB,EAGjB,qBAAqB,EACrB,gBAAgB,EAChB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,sBAAsB,EAGtB,qBAAqB,EACrB,KAAK,EACL,oBAAoB,GACrB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EAEvB,cAAc,EAEd,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,aAAa,EAEb,qBAAqB,EAGrB,aAAa,EACb,oBAAoB,EAEpB,gCAAgC,EAChC,sBAAsB,GACvB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EAIxB,wBAAwB,EACxB,sBAAsB,EACtB,iBAAiB,EAGjB,qBAAqB,EACrB,gBAAgB,EAChB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,sBAAsB,EAGtB,qBAAqB,EACrB,KAAK,EACL,oBAAoB,GACrB,MAAM,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@integraledger/lcp-binding-evm-common",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Shared EVM machinery for LCP rail bindings: typed-data construction and signature verification.",
5
5
  "keywords": [
6
6
  "lcp",
@@ -44,9 +44,9 @@
44
44
  "homepage": "https://github.com/IntegraLedger/integra-protocol/tree/main/packages/binding-evm-common#readme",
45
45
  "dependencies": {
46
46
  "viem": "2.55.11",
47
- "@integraledger/lcp-binding-core": "0.12.2",
48
- "@integraledger/lcp-authority": "0.12.2",
49
- "@integraledger/lcp-kernel": "0.12.2"
47
+ "@integraledger/lcp-authority": "0.13.0",
48
+ "@integraledger/lcp-binding-core": "0.13.0",
49
+ "@integraledger/lcp-kernel": "0.13.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "24.13.3",
package/src/eip3009.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  * The x402-client scheme class is deliberately NOT here; it lives in binding-evm-x402. This module is
8
8
  * protocol-agnostic typed-data construction, viem-only.
9
9
  */
10
- import { type Address, getAddress, type Hex, type TypedDataDomain } from "viem";
10
+ import {
11
+ type Address,
12
+ getAddress,
13
+ type Hex,
14
+ recoverTypedDataAddress,
15
+ type TypedDataDomain,
16
+ } from "viem";
11
17
 
12
18
  /** EIP-3009 TransferWithAuthorization typed-data field layout (USDC FiatTokenV2). */
13
19
  export const TRANSFER_WITH_AUTHORIZATION_TYPE = {
@@ -140,3 +146,86 @@ export interface LcpEvmSigner {
140
146
  message: Record<string, unknown>;
141
147
  }): Promise<Hex>;
142
148
  }
149
+
150
+ /**
151
+ * secp256k1's order halved — the low-s boundary `FiatTokenV2`'s own `ECRecover.sol` enforces.
152
+ *
153
+ * ⛔⛔ **A SIGNATURE HAS TWO VALID ENCODINGS AND THE TOKEN ACCEPTS ONE.** For any `(r, s, v)` the pair
154
+ * `(r, n − s, v ^ 1)` recovers the same address, so `ecrecover` alone says `true` to both. Circle's token
155
+ * reverts on the high-s form before `ecrecover` is reached — *"ECRecover: invalid signature 's' value"* —
156
+ * and on any `v` outside `{27, 28}`. A pre-flight check without these gates answers `true` for a
157
+ * credential the chain refuses, and the seller has already served the resource by then.
158
+ */
159
+ const SECP256K1_HALF_N =
160
+ 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n;
161
+
162
+ /**
163
+ * Whether the 65-byte signature is one `FiatTokenV2` will accept at all, before asking who signed it.
164
+ *
165
+ * Not a style check. Both rules come straight out of the token's `ECRecover.sol`, and skipping either one
166
+ * turns {@link verifyEip3009Signature} from *"the chain will accept this"* into *"some chain might"*.
167
+ *
168
+ * ⭐ Exported so the boundary is reachable by a test. `s === n/2` is ACCEPTED (the token's guard is
169
+ * `s > n/2`), and no signing run will ever produce that value — so an off-by-one there is invisible from
170
+ * the public predicate, where such a signature simply fails to recover and answers `false` either way.
171
+ */
172
+ export function isCanonicalSignature(signature: string): boolean {
173
+ // ⛔ Captured, not sliced at fixed offsets. With `.slice(66, 130)` the components were read from
174
+ // positions that only line up when the anchors hold — so dropping `^` shifted every offset and the
175
+ // malformed input failed the `v` gate by accident, which left the anchor's mutant alive. Reading r/s/v
176
+ // out of the match makes the shape check and the component read the same statement.
177
+ const parts = /^0x([0-9a-fA-F]{64})([0-9a-fA-F]{64})([0-9a-fA-F]{2})$/.exec(
178
+ signature,
179
+ );
180
+ if (parts === null) return false;
181
+ if (BigInt(`0x${parts[2] as string}`) > SECP256K1_HALF_N) return false;
182
+ const v = Number.parseInt(parts[3] as string, 16);
183
+ return v === 27 || v === 28;
184
+ }
185
+
186
+ /**
187
+ * Whether an EIP-3009 authorization was signed, canonically, by the account it says it is from.
188
+ *
189
+ * ⭐ **The token's OWN acceptance test, not merely `ecrecover`.** `FiatTokenV2.transferWithAuthorization`
190
+ * routes an EOA signature through `ECRecover.sol`, which refuses a high-s signature and any `v` outside
191
+ * `{27, 28}` before recovering. A check that only recovered would answer `true` to the malleated form of an
192
+ * honest payer's own signature — measured — and the seller would serve the resource against a transfer that
193
+ * reverts. {@link isCanonicalSignature} is those two gates.
194
+ *
195
+ * ⛔⛔ **EOA ONLY, AND ON `FiatTokenV2_2` THAT IS NARROWER THAN THE TOKEN.** An earlier draft of this
196
+ * docblock said a contract wallet *"cannot sign an EIP-3009 authorization at all"*. That is false for
197
+ * `FiatTokenV2_2` — the 2023 implementation deployed as USDC on Base, Arbitrum and Polygon among others —
198
+ * which routes `transferWithAuthorization` through `SignatureChecker.isValidSignatureNow` and therefore
199
+ * DOES dispatch to ERC-1271 for a contract account. Deciding that needs a chain read, and this function
200
+ * takes no ports: **a `false` here means "not signed by that EOA", never "the chain will reject it"**. A
201
+ * caller that must accept smart-account payers has to make the ERC-1271 call itself, and one that refuses
202
+ * on this answer alone is choosing to accept EOA payers only. Say which, at the call site.
203
+ *
204
+ * ⛔ **Answers `false` rather than throwing for the two UNTRUSTED inputs,** for the reason `atrHashEquals`
205
+ * states about itself: a predicate that throws is a worse contract than one that answers. A malformed
206
+ * `signature` and an `expectedSigner` that is not an address are facts about the credential in hand.
207
+ * ⚠️ A `typedData` that cannot be encoded is NOT — that is this deployment's own domain being wrong, and
208
+ * returning `false` for it would tell an operator with a mis-copied `tokenName` that every honest payer is
209
+ * a forger. It throws.
210
+ */
211
+ export async function verifyEip3009Signature(
212
+ typedData: Eip3009TypedData,
213
+ signature: string,
214
+ expectedSigner: string,
215
+ ): Promise<boolean> {
216
+ if (!/^0x[0-9a-fA-F]{40}$/.test(expectedSigner)) return false;
217
+ if (!isCanonicalSignature(signature)) return false;
218
+ // ⛔ NOT wrapped in a try. `isCanonicalSignature` has already established that the signature parses, so
219
+ // the only way this throws now is an unencodable `typedData` — a wiring error, and one a `false` would
220
+ // report as the counterparty's fault. See the head note.
221
+ const recovered: Address = await recoverTypedDataAddress({
222
+ domain: typedData.domain,
223
+ types: typedData.types,
224
+ primaryType: typedData.primaryType,
225
+ message: typedData.message,
226
+ signature: signature as Hex,
227
+ });
228
+ // Compared as decoded 20-byte values, not as strings: `getAddress` checksums both sides, so a payer
229
+ // spelling their own address in lowercase is the same payer.
230
+ return getAddress(recovered) === getAddress(expectedSigner);
231
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,10 @@ export {
15
15
  type Eip3009Authorization,
16
16
  type Eip3009TypedData,
17
17
  eip155ChainId,
18
+ isCanonicalSignature,
18
19
  type LcpEvmSigner,
19
20
  TRANSFER_WITH_AUTHORIZATION_TYPE,
21
+ verifyEip3009Signature,
20
22
  } from "./eip3009.js";
21
23
  export { assetWasTransferred, ERC20_TRANSFER_TOPIC0 } from "./erc20.js";
22
24
  export {