Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/account/simple/Simple7702Account.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {decodeAbiParameters, hexlify, signHash} from "src/ethereUtils";
import {decodeAbiParameters, hexlify, privateKeyToAddress, signHash} from "src/ethereUtils";
import {Bundler} from "src/Bundler";
import {BaseUserOperationDummyValues, ENTRYPOINT_V8, ENTRYPOINT_V9} from "src/constants";
import {AbstractionKitError} from "src/errors";
Expand Down Expand Up @@ -208,6 +208,17 @@ export class BaseSimple7702Account extends SmartAccount {
chainId?: bigint;
} = {},
): Promise<string> {
// Verify the private key matches this account — otherwise the raw
// transaction's sender (recovered from the signature) would be a
// different EOA and the revoke would target the signer's delegation
const signerAddress = privateKeyToAddress(eoaPrivateKey);
if (signerAddress.toLowerCase() !== this.accountAddress.toLowerCase()) {
throw new AbstractionKitError(
"BAD_DATA",
`eoaPrivateKey does not match accountAddress (${this.accountAddress})`,
);
}

// Verify delegation state before revoking
const delegatedTo = await JsonRpcNode.from(providerRpc).getDelegatedAddress(this.accountAddress);
if (delegatedTo === null) {
Expand Down
60 changes: 48 additions & 12 deletions src/utils7702.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ export function createAndSignLegacyRawTransaction(
bigintToBytes(value),
data,
bigintToBytes(BigInt(signature.yParity) + chainId * 2n + 35n),
getBytes(signature.r),
getBytes(signature.s),
// r and s must be minimal big-endian integers — nodes reject RLP
// scalars with leading zero bytes as non-canonical
bigintToBytes(BigInt(signature.r)),
bigintToBytes(BigInt(signature.s)),
];
const transactionPayload = encodeRlp(payload);
return transactionPayload;
Expand Down Expand Up @@ -438,11 +440,37 @@ function bigintToBytes(bi: bigint) {
return getBytes(toBeArray(bi));
}

const SECP256K1_N = BigInt(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
);
const SECP256K1_HALF_N = SECP256K1_N / 2n;

/**
* Parse a raw ECDSA signature into its components.
* Supports standard 65-byte (r + s + v) and EIP-2098 64-byte compact formats.
*
* High-s signatures are normalized to the complementary low-s form
* (s' = n - s, flipped yParity) rather than rejected. A high-s value is not
* a signer defect: plain ECDSA produces s uniformly across the range, so
* generic signers (AWS KMS, HSMs, WebCrypto) return high-s for ~half of all
* signatures — the low-s rule is an Ethereum canonicalization convention
* (EIP-2), not part of ECDSA. Per the EIP-2 rationale, the complementary
* signature is equally valid for the same signer and payload, so the
* conversion changes the encoding, not the authorization. Rejecting instead
* would fail nondeterministically on ~half of all signatures from such
* signers, and EIP-7702 makes the unnormalized failure mode silent: nodes
* validate s <= secp256k1n/2 per tuple and skip invalid tuples without
* error, so a high-s authorization would "succeed" without ever applying
* the delegation.
*
* @see https://eips.ethereum.org/EIPS/eip-2 - s-value bound and the
* malleability rationale (flipping s to secp256k1n - s with the v flip
* "would still be valid")
* @see https://eips.ethereum.org/EIPS/eip-7702 - Behavior steps: "Verify s
* is less than or equal to secp256k1n/2" and "If any step above fails,
* immediately stop processing the tuple and continue to the next tuple"
* @param rawSig - Hex string: 128 chars (EIP-2098 compact), or 130/132 chars (standard with 0x prefix)
* @returns An object with yParity (0 or 1), r, and s components
* @returns An object with yParity (0 or 1), r, and s components (low-s normalized)
*/
function parseRawSignature(rawSig: string): { yParity: 0 | 1; r: bigint; s: bigint } {
const sig = rawSig.startsWith("0x") ? rawSig.slice(2) : rawSig;
Expand All @@ -452,22 +480,30 @@ function parseRawSignature(rawSig: string): { yParity: 0 | 1; r: bigint; s: bigi
);
}
const r = BigInt(`0x${sig.slice(0, 64)}`);
let yParity: 0 | 1;
let s: bigint;

if (sig.length === 128) {
// EIP-2098 compact signature (64 bytes): r (32) + yParity||s (32)
const yParityAndS = BigInt(`0x${sig.slice(64, 128)}`);
const yParity = Number((yParityAndS >> 255n) & 1n) as 0 | 1;
const s = yParityAndS & ((1n << 255n) - 1n);
return { yParity, r, s };
yParity = Number((yParityAndS >> 255n) & 1n) as 0 | 1;
s = yParityAndS & ((1n << 255n) - 1n);
} else {
// Standard 65-byte signature: r (32) + s (32) + v (1)
s = BigInt(`0x${sig.slice(64, 128)}`);
const v = parseInt(sig.slice(128, 130), 16);
if (v !== 0 && v !== 1 && v !== 27 && v !== 28) {
throw new RangeError(`invalid signature v value: ${v}`);
}
yParity = (v >= 27 ? v - 27 : v) as 0 | 1;
}

// Standard 65-byte signature: r (32) + s (32) + v (1)
const s = BigInt(`0x${sig.slice(64, 128)}`);
const v = parseInt(sig.slice(128, 130), 16);
if (v !== 0 && v !== 1 && v !== 27 && v !== 28) {
throw new RangeError(`invalid signature v value: ${v}`);
// EIP-7702 requires s <= n/2; nodes silently skip high-s authorization
// tuples. Normalize to the complementary low-s signature.
if (s > SECP256K1_HALF_N) {
s = SECP256K1_N - s;
yParity = (1 - yParity) as 0 | 1;
}
const yParity = (v >= 27 ? v - 27 : v) as 0 | 1;
return { yParity, r, s };
}

Expand Down
66 changes: 66 additions & 0 deletions test/utils7702.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,69 @@ describe("createAndSignLegacyRawTransaction v computation (#128)", () => {
expect(tx.from.toLowerCase()).toBe(wallet.address.toLowerCase());
});
});

describe("createAndSignLegacyRawTransaction canonical RLP r/s", () => {
// These keys deterministically (RFC 6979) produce a signature whose r or s
// has a leading zero byte for this payload; the raw tx must still encode
// r/s as minimal integers or nodes reject it as non-canonical RLP.
test.each([38, 63])(
"key %i with a leading-zero r/s component encodes minimally and recovers",
(i) => {
const { decodeRlp, getBytes } = require("ethers");
const pk = "0x" + i.toString(16).padStart(64, "0");
const raw = ak.createAndSignLegacyRawTransaction(
1n,
0n,
1000000000n,
21000n,
"0x" + "aa".repeat(20),
0n,
"0x",
pk,
);
const fields = decodeRlp(raw);
const r = getBytes(fields[7]);
const s = getBytes(fields[8]);
expect(r.length === 0 || r[0] !== 0).toBe(true);
expect(s.length === 0 || s[0] !== 0).toBe(true);
const tx = Transaction.from(raw);
expect(tx.from.toLowerCase()).toBe(new Wallet(pk).address.toLowerCase());
},
);
});

describe("createAndSignEip7702DelegationAuthorization low-s normalization", () => {
const N = BigInt(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
);

test("normalizes a high-s callback signature to the low-s complement", async () => {
const { SigningKey } = require("ethers");
const wallet = new Wallet("0x" + "11".repeat(32));
const chainId = 1n;
const delegatee = "0x" + "aa".repeat(20);
const nonce = 0n;

const authHash = ak.createEip7702DelegationAuthorizationHash(chainId, delegatee, nonce);
const lowSig = wallet.signingKey.sign(authHash); // ethers always low-s
// construct the complementary high-s signature with flipped parity
const highS = N - BigInt(lowSig.s);
const highV = lowSig.yParity === 0 ? 28 : 27;
const highSig =
"0x" +
lowSig.r.slice(2) +
highS.toString(16).padStart(64, "0") +
highV.toString(16).padStart(2, "0");

const auth = await ak.createAndSignEip7702DelegationAuthorization(
chainId,
delegatee,
nonce,
async () => highSig,
);
// must come back as the original low-s signature
expect(BigInt(auth.s)).toBe(BigInt(lowSig.s));
expect(Number(BigInt(auth.yParity))).toBe(lowSig.yParity);
expect(BigInt(auth.s) <= N / 2n).toBe(true);
});
});
Loading