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
103 changes: 75 additions & 28 deletions src/account/Safe/SafeAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,26 @@ export class SafeAccount extends SmartAccount {
const validAfter = 0xffffffffffffn;
const validUntil = 0xffffffffffffn;

// Derived from the operation's own init fields; the signature encoder
// needs the init flag and verifier config whenever a dummy pair
// carries a raw WebauthnPublicKey signer.
let initCode: string | null;
if ("initCode" in userOperation) {
initCode = userOperation.initCode;
} else {
initCode = userOperation.factory;
}
const isInit = initCode != null && initCode !== "0x";
const webAuthnSignatureOverrides = {
isInit,
webAuthnSharedSigner: overrides.webAuthnSharedSigner,
eip7212WebAuthnPrecompileVerifier: overrides.eip7212WebAuthnPrecompileVerifier,
eip7212WebAuthnContractVerifier: overrides.eip7212WebAuthnContractVerifier,
webAuthnSignerFactory: overrides.webAuthnSignerFactory,
webAuthnSignerSingleton: overrides.webAuthnSignerSingleton,
webAuthnSignerProxyCreationCode: overrides.webAuthnSignerProxyCreationCode,
};

// Number of dummy signatures this method placed on the estimated
// operation. Zero when the caller supplied a ready-made signature,
// in which case the signer count is unknown here and per-signer gas
Expand All @@ -1419,35 +1439,23 @@ export class SafeAccount extends SmartAccount {
validAfter,
validUntil,
isMultiChainSignature: overrides.isMultiChainSignature,
...webAuthnSignatureOverrides,
},
);
} else if (overrides.expectedSigners != null) {
let initCode: string | null;

if ("initCode" in userOperation) {
initCode = userOperation.initCode;
} else {
initCode = userOperation.factory;
}
const isInit = initCode != null && initCode !== "0x";

const dummySignerSignaturePairs =
SafeAccount.createDummySignerSignaturePairForExpectedSigners(overrides.expectedSigners, {
isInit,
webAuthnSharedSigner: overrides.webAuthnSharedSigner,
eip7212WebAuthnPrecompileVerifier: overrides.eip7212WebAuthnPrecompileVerifier,
eip7212WebAuthnContractVerifier: overrides.eip7212WebAuthnContractVerifier,
webAuthnSignerFactory: overrides.webAuthnSignerFactory,
webAuthnSignerSingleton: overrides.webAuthnSignerSingleton,
webAuthnSignerProxyCreationCode: overrides.webAuthnSignerProxyCreationCode,
});
SafeAccount.createDummySignerSignaturePairForExpectedSigners(
overrides.expectedSigners,
webAuthnSignatureOverrides,
);
dummySignersCount = dummySignerSignaturePairs.length;
estimationSignature = SafeAccount.formatSignaturesToUseroperationSignature(
dummySignerSignaturePairs,
{
validAfter,
validUntil,
isMultiChainSignature: overrides.isMultiChainSignature,
...webAuthnSignatureOverrides,
},
);
} else if (userOperation.signature.length < 3) {
Expand Down Expand Up @@ -1706,6 +1714,7 @@ export class SafeAccount extends SmartAccount {
const validAfter = 0xffffffffffffn;
const validUntil = 0xffffffffffffn;

const isInit = factoryAddress != null && factoryAddress !== "0x";
let dummySignerSignaturePairs: SignerSignaturePair[];
if (overrides.dummySignerSignaturePairs != null) {
if (overrides.expectedSigners != null) {
Expand All @@ -1721,7 +1730,6 @@ export class SafeAccount extends SmartAccount {
if (overrides.expectedSigners == null) {
dummySignerSignaturePairs = [EOADummySignerSignaturePair];
} else {
const isInit = factoryAddress != null && factoryAddress !== "0x";
dummySignerSignaturePairs = SafeAccount.createDummySignerSignaturePairForExpectedSigners(
overrides.expectedSigners,
{
Expand All @@ -1742,7 +1750,17 @@ export class SafeAccount extends SmartAccount {
validAfter,
validUntil,
isMultiChainSignature: overrides.isMultiChainSignature,
// needed when user-supplied dummySignerSignaturePairs contain raw
// WebauthnPublicKey signers — the encoder requires the init flag,
// and for deployed accounts derives the per-owner verifier
// address from the verifier config
isInit,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For isInit: false, it also needs the verifier, factory, singleton, and proxy creation code values. Without them, it derives the owner using defaults rather than the caller’s custom configuration

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

webAuthnSharedSigner,
eip7212WebAuthnPrecompileVerifier,
eip7212WebAuthnContractVerifier,
webAuthnSignerFactory,
webAuthnSignerSingleton,
webAuthnSignerProxyCreationCode,
},
);

Expand Down Expand Up @@ -1791,12 +1809,18 @@ export class SafeAccount extends SmartAccount {

const parallelPaymasterInitValues = overrides.parallelPaymasterInitValues;
if (parallelPaymasterInitValues != null) {
if (!parallelPaymasterInitValues.paymasterData.endsWith("22e325a297439656")) {
// lowercase like the EIP-712 trimmer and the MultiChain
// subclass — uppercase hex is valid input
if (
!parallelPaymasterInitValues.paymasterData
.toLowerCase()
.endsWith("22e325a297439656")
) {
throw new RangeError(
"Invalid paymasterData override, it must end with the PAYMASTER_SIG_MAGIC '22e325a297439656'.",
);
}
if (this.entrypointAddress !== ENTRYPOINT_V9) {
if (this.entrypointAddress.toLowerCase() !== ENTRYPOINT_V9.toLowerCase()) {
throw new RangeError("parallelPaymasterInitValues only works with ep v0.9");
}
userOperationToEstimate.paymaster = parallelPaymasterInitValues.paymaster;
Expand Down Expand Up @@ -2105,7 +2129,7 @@ export class SafeAccount extends SmartAccount {
],
).slice(-40);

return `0x${proxyAdd}`;
return getAddress(`0x${proxyAdd}`); //to checksummed
}

/**
Expand Down Expand Up @@ -2175,17 +2199,36 @@ export class SafeAccount extends SmartAccount {
}

/**
* calculate a signer public address lowercase
* @param signer - a signer to compute address for
* @param overrides - overrides for the default values
* @returns signer address
* Resolve a {@link Signer} to the lowercase address the Safe contract
* sees as the owner — not merely a case conversion:
*
* - string signer: returned lowercased as-is.
* - WebAuthn public key with `overrides.isInit` set: resolves to the
* WebAuthn **shared signer** address, since during account init the
* shared signer is the enabled owner rather than a per-owner verifier.
* - WebAuthn public key otherwise: **derives** the deterministic CREATE2
* address of the per-owner WebAuthn verifier proxy from the key's x/y
* coordinates and the verifier/factory configuration.
*
* Used as the sort key in {@link sortSignatures}, so it must always match
* the owner address that signature encoding will emit for the same
* overrides — pass the same overrides bag to both.
* @param signer - a signer to compute the owner address for
* @param overrides - WebAuthn verifier configuration and the init flag
* @returns the owner address, lowercased
*/
public static getSignerLowerCaseAddress(
signer: Signer,
overrides: WebAuthnSignatureOverrides = {},
): string {
if (typeof signer === "string") {
return signer.toLowerCase();
} else if (overrides.isInit) {
// on init the encoded owner is the WebAuthn shared signer, not the
// per-owner verifier proxy — sort by the same address that gets encoded
const webAuthnSharedSigner =
overrides.webAuthnSharedSigner ?? SafeAccount.DEFAULT_WEB_AUTHN_SHARED_SIGNER;
return webAuthnSharedSigner.toLowerCase();
} else {
const eip7212WebAuthnPrecompileVerifier =
overrides.eip7212WebAuthnPrecompileVerifier ?? SafeAccount.DEFAULT_WEB_AUTHN_PRECOMPILE;
Expand Down Expand Up @@ -2399,7 +2442,9 @@ export class SafeAccount extends SmartAccount {
let prevOwnerT = overrides.prevOwner;
if (prevOwnerT == null) {
const owners = await this.getOwners(nodeRpcUrl);
const oldOwnerIndex = owners.indexOf(oldOwnerT);
const oldOwnerIndex = owners.findIndex(
(owner) => owner.toLowerCase() === oldOwnerT.toLowerCase(),
);
if (oldOwnerIndex === -1) {
throw new RangeError("oldOwner is not a current owner.");
} else if (oldOwnerIndex === 0) {
Expand Down Expand Up @@ -2470,7 +2515,9 @@ export class SafeAccount extends SmartAccount {
let prevOwnerT = overrides.prevOwner;
if (prevOwnerT == null) {
const owners = await this.getOwners(nodeRpcUrl);
const ownerToDeleteIndex = owners.indexOf(ownerToDeleteT);
const ownerToDeleteIndex = owners.findIndex(
(owner) => owner.toLowerCase() === ownerToDeleteT.toLowerCase(),
);
if (ownerToDeleteIndex === -1) {
throw new RangeError("ownerToDelete is not a current owner.");
} else if (ownerToDeleteIndex === 0) {
Expand Down
105 changes: 105 additions & 0 deletions test/safe/webauthnDummyPairsEstimation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Regression tests: baseEstimateUserOperationGas must accept user-supplied
// dummySignerSignaturePairs containing raw WebauthnPublicKey signers —
// deriving isInit from the operation's own init fields — and must forward
// the WebAuthn verifier-config overrides so deployed accounts with custom
// verifier setups encode the correct owner address. Offline.

const ak = require('../../dist/index.cjs');

const WEBAUTHN_KEY = {
x: 0x7a2fa39b3c61b3cbab8e44abeac8c9c7a4c1f76d42ae6f47b3b2a96d5c4f1a2bn,
y: 0x2e8c5f6d4b7a9c1e3f5a8d7b6c4e2f1a9d8c7b6a5e4f3d2c1b0a9f8e7d6c5b4an,
};
const WEBAUTHN_SIG = '0x' + 'ab'.repeat(320);
const ACCOUNT = '0x' + '42'.repeat(20);
const CUSTOM_SIGNER_FACTORY = '0x' + '77'.repeat(20);

const GAS_RESULT = {
callGasLimit: '0x10000',
verificationGasLimit: '0x10000',
preVerificationGas: '0x10000',
};

function estimateWithDummyPairs({ factory, overrides = {} }) {
const captured = [];
const bundler = new ak.Bundler({
request: async ({ method, params }) => {
captured.push({ method, params });
return GAS_RESULT;
},
});
const account = new ak.SafeAccountV0_3_0(ACCOUNT);
const userOperation = {
sender: ACCOUNT,
nonce: 0n,
factory,
factoryData: null,
callData: '0x',
callGasLimit: 0n,
verificationGasLimit: 0n,
preVerificationGas: 0n,
maxFeePerGas: 0n,
maxPriorityFeePerGas: 0n,
paymaster: null,
paymasterVerificationGasLimit: null,
paymasterPostOpGasLimit: null,
paymasterData: null,
signature: '0x',
};
const estimation = account.baseEstimateUserOperationGas(userOperation, bundler, {
dummySignerSignaturePairs: [{ signer: WEBAUTHN_KEY, signature: WEBAUTHN_SIG }],
...overrides,
});
return { estimation, captured };
}

// signature layout: 0x + validAfter (6 bytes) + validUntil (6 bytes) + static
// segments (65 bytes per signer: r = padded owner address, s = offset, v).
// The encoded owner address sits in bytes 12..32 of the first segment's r.
function encodedOwner(signature) {
return signature.slice(2 + 24).slice(24, 64);
}

describe('WebAuthn dummySignerSignaturePairs in baseEstimateUserOperationGas', () => {
test('init op: derives isInit from the factory field and encodes the shared signer', async () => {
const { estimation, captured } = estimateWithDummyPairs({
factory: '0x' + '11'.repeat(20),
});
await expect(estimation).resolves.toBeDefined();
const sentOp = captured[0].params[0];
expect(encodedOwner(sentOp.signature)).toBe(
ak.SafeAccountV0_3_0.DEFAULT_WEB_AUTHN_SHARED_SIGNER.slice(2).toLowerCase(),
);
});

test('deployed op: encodes the per-owner verifier-proxy address', async () => {
const { estimation, captured } = estimateWithDummyPairs({ factory: null });
await expect(estimation).resolves.toBeDefined();
const sentOp = captured[0].params[0];
const verifierProxy = ak.SafeAccountV0_3_0.createWebAuthnSignerVerifierAddress(
WEBAUTHN_KEY.x,
WEBAUTHN_KEY.y,
);
expect(encodedOwner(sentOp.signature)).toBe(verifierProxy.slice(2).toLowerCase());
});

test('deployed op: forwards custom verifier-config overrides to the encoder', async () => {
const { estimation, captured } = estimateWithDummyPairs({
factory: null,
overrides: { webAuthnSignerFactory: CUSTOM_SIGNER_FACTORY },
});
await expect(estimation).resolves.toBeDefined();
const sentOp = captured[0].params[0];
const customVerifierProxy = ak.SafeAccountV0_3_0.createWebAuthnSignerVerifierAddress(
WEBAUTHN_KEY.x,
WEBAUTHN_KEY.y,
{ webAuthnSignerFactory: CUSTOM_SIGNER_FACTORY },
);
const defaultVerifierProxy = ak.SafeAccountV0_3_0.createWebAuthnSignerVerifierAddress(
WEBAUTHN_KEY.x,
WEBAUTHN_KEY.y,
);
expect(customVerifierProxy).not.toBe(defaultVerifierProxy);
expect(encodedOwner(sentOp.signature)).toBe(customVerifierProxy.slice(2).toLowerCase());
});
});
60 changes: 60 additions & 0 deletions test/safe/webauthnInitSignatureSort.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Regression test: during account init the encoded owner for a WebAuthn
// signer is the shared signer, so sorting must use the shared-signer
// address too — not the per-owner verifier-proxy address. Offline.

const ak = require('../../dist/index.cjs');

const WEBAUTHN_KEY = {
x: 0x7a2fa39b3c61b3cbab8e44abeac8c9c7a4c1f76d42ae6f47b3b2a96d5c4f1a2bn,
y: 0x2e8c5f6d4b7a9c1e3f5a8d7b6c4e2f1a9d8c7b6a5e4f3d2c1b0a9f8e7d6c5b4an,
};

const SHARED_SIGNER = ak.SafeAccountV0_3_0.DEFAULT_WEB_AUTHN_SHARED_SIGNER;
// one below the shared signer, so: verifierProxy < EOA < sharedSigner
const EOA = '0x' + (BigInt(SHARED_SIGNER) - 1n).toString(16).padStart(40, '0');

const WEBAUTHN_SIG = '0x' + 'ab'.repeat(320);
const EOA_SIG = '0x' + '22'.repeat(32) + '33'.repeat(32) + '1c';

describe('WebAuthn init signature sorting', () => {
const verifierProxy = ak.SafeAccountV0_3_0.createWebAuthnSignerVerifierAddress(
WEBAUTHN_KEY.x,
WEBAUTHN_KEY.y,
);

test('fixture precondition: verifierProxy < EOA < sharedSigner', () => {
expect(verifierProxy.toLowerCase() < EOA.toLowerCase()).toBe(true);
expect(EOA.toLowerCase() < SHARED_SIGNER.toLowerCase()).toBe(true);
});

test('isInit: true sorts by the shared-signer address that gets encoded', () => {
const sig = ak.SafeAccountV0_3_0.buildSignaturesFromSingerSignaturePairs(
[
{ signer: WEBAUTHN_KEY, signature: WEBAUTHN_SIG },
{ signer: EOA, signature: EOA_SIG },
],
{ isInit: true },
);
const seg1 = sig.slice(2, 132);
const seg2 = sig.slice(132, 262);
// EOA (lower address) must come first: its segment ends with v=0x1c
expect(seg1.endsWith('1c')).toBe(true);
// contract-signature segment second: owner = shared signer, v=0
expect(seg2.slice(24, 64)).toBe(SHARED_SIGNER.slice(2).toLowerCase());
expect(seg2.slice(128, 130)).toBe('00');
});

test('isInit: false still sorts by the verifier-proxy address', () => {
const sig = ak.SafeAccountV0_3_0.buildSignaturesFromSingerSignaturePairs(
[
{ signer: EOA, signature: EOA_SIG },
{ signer: WEBAUTHN_KEY, signature: WEBAUTHN_SIG },
],
{ isInit: false },
);
const seg1 = sig.slice(2, 132);
// verifier proxy (lower address) comes first as the contract signature
expect(seg1.slice(24, 64)).toBe(verifierProxy.slice(2).toLowerCase());
expect(seg1.slice(128, 130)).toBe('00');
});
});
Loading