Skip to content

Commit

Permalink
Add Gas Metering Tests for WebAuthn Signers (#201)
Browse files Browse the repository at this point in the history
Fixes #173 

This PR adds tests that execute user operations on Safes with WebAuthn
signers over the reference entrypoint and prints out the gas usage. It
includes both Safe deployment as well as regular user operation usage.

The results are in:

```
  Safe4337Module - WebAuthn Owner
    executeUserOp - new account
           Used 2293926 gas for >WebAuthn signer Safe deployment<
      ✔ should execute user operation (357ms)
    executeUserOp - existing account
           Used 374142 gas for >WebAuthn signer Safe operation<
      ✔ should execute user operation (229ms)
```
  • Loading branch information
nlordell authored Jan 12, 2024
1 parent b37e394 commit 77858d5
Show file tree
Hide file tree
Showing 9 changed files with 411 additions and 142 deletions.
2 changes: 1 addition & 1 deletion 4337/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ version: '3.8'

services:
geth:
image: docker.io/ethereum/client-go:latest
image: docker.io/ethereum/client-go:stable
restart: always
environment:
GETH_DEV: 'true'
Expand Down
3 changes: 1 addition & 2 deletions 4337/docker/bundler/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ FROM docker.io/library/node:18

RUN git clone https://github.com/eth-infinitism/bundler /src/bundler
WORKDIR /src/bundler
# v0.6.1
RUN git checkout 30dc20da10214415df60a5ee15a6bec0975c9af1
RUN git checkout v0.6.1

RUN yarn && yarn preprocess
ENTRYPOINT ["yarn", "bundler"]
Expand Down
24 changes: 23 additions & 1 deletion 4337/src/utils/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,33 @@ export const signHash = async (signer: Signer, hash: string): Promise<SafeSignat
}
}

export const buildSignatureBytes = (signatures: SafeSignature[]): string => {
const sortSignatures = (signatures: SafeSignature[]) => {
signatures.sort((left, right) => left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()))
}

export const buildSignatureBytes = (signatures: SafeSignature[]): string => {
sortSignatures(signatures)
return ethers.concat(signatures.map((signature) => signature.data))
}

export const buildContractSignatureBytes = (signatures: SafeSignature[]): string => {
sortSignatures(signatures)
const start = 65 * signatures.length
const { segments } = signatures.reduce(
({ segments, offset }, { signer, data }) => {
return {
segments: [...segments, ethers.solidityPacked(['uint256', 'uint256', 'uint8'], [signer, start + offset, 0])],
offset: offset + 32 + ethers.dataLength(data),
}
},
{ segments: [] as string[], offset: 0 },
)
return ethers.concat([
...segments,
...signatures.map(({ data }) => ethers.solidityPacked(['uint256', 'bytes'], [ethers.dataLength(data), data])),
])
}

export const logGas = async (message: string, tx: Promise<TransactionResponse>, skip?: boolean): Promise<TransactionResponse> => {
return tx.then(async (result) => {
const receipt = await result.wait()
Expand Down
19 changes: 8 additions & 11 deletions 4337/test/e2e/SingletonSigners.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { deployments, ethers, network } from 'hardhat'
import { buildSignatureBytes } from '../../src/utils/execution'
import { buildContractSignatureBytes } from '../../src/utils/execution'
import { buildUserOperationFromSafeUserOperation, buildSafeUserOpTransaction } from '../../src/utils/userOp'
import { bundlerRpc, encodeMultiSendTransactions, prepareAccounts, waitForUserOp } from '../utils/e2e'

Expand Down Expand Up @@ -107,18 +107,15 @@ describe('E2E - Singleton Signers', () => {
const opHash = await validator.getOperationHash(
buildUserOperationFromSafeUserOperation({
safeOp,
signature: buildSignatureBytes([]),
signature: '0x',
}),
)
const signature = ethers.concat([
buildSignatureBytes(
customSigners.map(({ signer }, i) => ({
signer: signer.target as string,
data: ethers.solidityPacked(['uint256', 'uint256', 'uint8'], [signer.target, 65 * customSigners.length + 64 * i, 0]),
})),
),
...customSigners.map(({ key }) => ethers.solidityPacked(['uint256', 'bytes'], [32, ethers.toBeHex(BigInt(opHash) ^ key)])),
])
const signature = buildContractSignatureBytes(
customSigners.map(({ signer, key }) => ({
signer: signer.target as string,
data: ethers.toBeHex(BigInt(opHash) ^ key),
})),
)
const userOp = buildUserOperationFromSafeUserOperation({
safeOp,
signature,
Expand Down
79 changes: 2 additions & 77 deletions 4337/test/e2e/WebAuthnSigner.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { expect } from 'chai'
import CBOR from 'cbor'
import { deployments, ethers, network } from 'hardhat'
import { bundlerRpc, prepareAccounts, waitForUserOp } from '../utils/e2e'
import { chainId } from '../utils/encoding'
import { AuthenticatorAttestationResponse, WebAuthnCredentials, base64UrlEncode } from '../utils/webauthn'
import { WebAuthnCredentials, extractChallengeOffset, extractPublicKey, extractSignature } from '../utils/webauthn'

describe('E2E - WebAuthn Signers', () => {
before(function () {
Expand All @@ -13,7 +12,7 @@ describe('E2E - WebAuthn Signers', () => {
})

const setupTests = deployments.createFixture(async ({ deployments }) => {
const { EntryPoint, Safe4337Module, SafeSignerLaunchpad, SafeProxyFactory, AddModulesLib, SafeL2, MultiSend } = await deployments.run()
const { EntryPoint, Safe4337Module, SafeSignerLaunchpad, SafeProxyFactory, AddModulesLib, SafeL2 } = await deployments.run()
const [user] = await prepareAccounts()
const bundler = bundlerRpc()

Expand All @@ -23,7 +22,6 @@ describe('E2E - WebAuthn Signers', () => {
const addModulesLib = await ethers.getContractAt('AddModulesLib', AddModulesLib.address)
const signerLaunchpad = await ethers.getContractAt('SafeSignerLaunchpad', SafeSignerLaunchpad.address)
const singleton = await ethers.getContractAt('SafeL2', SafeL2.address)
const multiSend = await ethers.getContractAt('MultiSend', MultiSend.address)

const WebAuthnSignerFactory = await ethers.getContractFactory('WebAuthnSignerFactory')
const signerFactory = await WebAuthnSignerFactory.deploy()
Expand All @@ -41,7 +39,6 @@ describe('E2E - WebAuthn Signers', () => {
entryPoint,
signerLaunchpad,
singleton,
multiSend,
signerFactory,
navigator,
}
Expand Down Expand Up @@ -200,76 +197,4 @@ describe('E2E - WebAuthn Signers', () => {
const safeInstance = await ethers.getContractAt('SafeL2', safe)
expect(await safeInstance.getOwners()).to.deep.equal([signerAddress])
})

/**
* Extract the x and y coordinates of the public key from a created public key credential.
* Inspired from <https://webauthn.guide/#registration>.
*/
function extractPublicKey(response: AuthenticatorAttestationResponse): { x: bigint; y: bigint } {
const attestationObject = CBOR.decode(response.attestationObject)
const authDataView = new DataView(attestationObject.authData.buffer)
const credentialIdLength = authDataView.getUint16(53)
const cosePublicKey = attestationObject.authData.slice(55 + credentialIdLength)
const key: Map<number, unknown> = CBOR.decode(cosePublicKey)
const bn = (bytes: Uint8Array) => BigInt(ethers.hexlify(bytes))
return {
x: bn(key.get(-2) as Uint8Array),
y: bn(key.get(-3) as Uint8Array),
}
}

/**
* Compute the challenge offset in the client data JSON. This is the offset, in bytes, of the
* value associated with the `challenge` key in the JSON blob.
*/
function extractChallengeOffset(response: AuthenticatorAssertionResponse, challenge: string): number {
const clientDataJSON = new TextDecoder('utf-8').decode(response.clientDataJSON)

const encodedChallenge = base64UrlEncode(challenge)
const offset = clientDataJSON.indexOf(encodedChallenge)
if (offset < 0) {
throw new Error('challenge not found in client data JSON')
}

return offset
}

/**
* Extracts the signature into R and S values from the authenticator response.
*
* See:
* - <https://datatracker.ietf.org/doc/html/rfc3279#section-2.2.3>
* - <https://en.wikipedia.org/wiki/X.690#BER_encoding>
*/
function extractSignature(response: AuthenticatorAssertionResponse): [bigint, bigint] {
const check = (x: boolean) => {
if (!x) {
throw new Error('invalid signature encoding')
}
}

// Decode the DER signature. Note that we assume that all lengths fit into 8-bit integers,
// which is true for the kinds of signatures we are decoding but generally false. I.e. this
// code should not be used in any serious application.
const view = new DataView(response.signature)

// check that the sequence header is valid
check(view.getUint8(0) === 0x30)
check(view.getUint8(1) === view.byteLength - 2)

// read r and s
const readInt = (offset: number) => {
check(view.getUint8(offset) === 0x02)
const len = view.getUint8(offset + 1)
const start = offset + 2
const end = start + len
const n = BigInt(ethers.hexlify(new Uint8Array(view.buffer.slice(start, end))))
check(n < ethers.MaxUint256)
return [n, end] as const
}
const [r, sOffset] = readInt(2)
const [s] = readInt(sOffset)

return [r, s]
}
})
Loading

0 comments on commit 77858d5

Please sign in to comment.