Skip to content

Commit 59bc7a8

Browse files
authored
feat: add erc7739 signatures to passkey client sdk (#155)
1 parent a3c4618 commit 59bc7a8

File tree

13 files changed

+746
-90
lines changed

13 files changed

+746
-90
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,7 @@ command.
221221
To execute the end-to-end tests for the `demo-app` (or similarly for
222222
`nft-quest`), you'll need to do some setup:
223223

224-
1. Start `era_test_node` (In a separate terminal, run
225-
`npx zksync-cli dev start`)
224+
1. Start `anvil-zksync`
226225
2. Deploy the smart contracts, `pnpm --dir packages/contracts run deploy`
227226

228227
Once the local node is configured with the smart contracts deployed, you can run

examples/demo-app/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ logs
2323
.env.*
2424
!.env.example
2525
forge-output.json
26+
forge-output-paymaster.json
27+
forge-output-erc1271.json

examples/demo-app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"private": true,
44
"type": "module",
55
"scripts": {
6+
"dev": "nuxt dev",
67
"preview": "nuxt preview",
78
"postinstall": "nuxt prepare"
89
},
910
"dependencies": {
1011
"@matterlabs/zksync-contracts": "^0.6.1",
1112
"@nuxtjs/google-fonts": "^3.2.0",
13+
"@openzeppelin/contracts": "^5.4.0",
1214
"@pinia/nuxt": "^0.5.5",
1315
"@simplewebauthn/browser": "^13.1.0",
1416
"@simplewebauthn/server": "^13.1.1",

examples/demo-app/pages/index.vue

Lines changed: 169 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,55 @@
4545
Send 0.1 ETH with Paymaster
4646
</button>
4747

48+
<div
49+
v-if="address"
50+
class="mt-8 border-t pt-4"
51+
>
52+
<h2 class="text-xl font-bold mb-4">
53+
Typed Data Signature Verification
54+
</h2>
55+
<div class="mb-4">
56+
<pre class="bg-gray-100 p-3 rounded text-xs overflow-x-auto max-w-2xl max-h-60">{{ JSON.stringify(typedData, null, 2) }}</pre>
57+
</div>
58+
<div
59+
v-if="ERC1271CallerContract.deployedTo"
60+
class="mb-4 text-xs text-gray-600"
61+
>
62+
<p>ERC1271 Caller address: {{ ERC1271CallerContract.deployedTo }}</p>
63+
</div>
64+
<button
65+
class="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded disabled:bg-slate-300"
66+
:disabled="isSigningTypedData"
67+
@click="signTypedDataHandler"
68+
>
69+
{{ isSigningTypedData ? 'Signing...' : 'Sign Typed Data' }}
70+
</button>
71+
<div
72+
v-if="typedDataSignature"
73+
class="mt-4"
74+
>
75+
<p class="break-all">
76+
<strong>Signature:</strong> <span class="text-xs line-clamp-2">{{ typedDataSignature }}</span>
77+
</p>
78+
</div>
79+
<div
80+
v-if="isVerifyingTypedDataSignature"
81+
class="mt-4"
82+
>
83+
<p class="text-gray-600">
84+
Verifying typed data signature...
85+
</p>
86+
</div>
87+
<div
88+
v-else-if="isValidTypedDataSignature !== null"
89+
class="mt-4"
90+
>
91+
<p :class="isValidTypedDataSignature ? 'text-green-600' : 'text-red-600'">
92+
<strong>Typed Data Verification Result:</strong> {{ isValidTypedDataSignature ? 'Valid ✓' : 'Invalid ✗' }}
93+
</p>
94+
</div>
95+
</div>
96+
4897
<div
4998
v-if="errorMessage"
5099
class="p-4 mt-4 mb-4 max-w-96 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400"
@@ -55,16 +104,22 @@
55104
</template>
56105

57106
<script lang="ts" setup>
58-
import { disconnect, getBalance, watchAccount, sendTransaction, createConfig, connect, reconnect, waitForTransactionReceipt, type GetBalanceReturnType } from "@wagmi/core";
59-
import { zksyncSsoConnector, eraTestNode } from "zksync-sso-wagmi-connector";
60-
import { createWalletClient, http, parseEther, type Address } from "viem";
107+
import { disconnect, getBalance, watchAccount, sendTransaction, createConfig, connect, reconnect, waitForTransactionReceipt, type GetBalanceReturnType, signTypedData, readContract } from "@wagmi/core";
108+
import { createWalletClient, createPublicClient, http, parseEther, type Address, type Hash } from "viem";
109+
import { zksyncSsoConnector } from "zksync-sso-wagmi-connector";
61110
import { privateKeyToAccount } from "viem/accounts";
62-
import { getGeneralPaymasterInput } from "viem/zksync";
63-
import PaymasterContract from "../forge-output.json";
111+
import { getGeneralPaymasterInput, zksyncInMemoryNode } from "viem/zksync";
112+
import PaymasterContract from "../forge-output-paymaster.json";
113+
import ERC1271CallerContract from "../forge-output-erc1271.json";
64114
65-
const chain = eraTestNode; // Now using the exported eraTestNode instead of zksyncInMemoryNode
115+
const chain = zksyncInMemoryNode;
66116
67117
const testTransferTarget = "0x55bE1B079b53962746B2e86d12f158a41DF294A6";
118+
119+
const publicClient = createPublicClient({
120+
chain: chain,
121+
transport: http(),
122+
});
68123
const zksyncConnectorWithSession = zksyncSsoConnector({
69124
authServerUrl: "http://localhost:3002/confirm",
70125
session: {
@@ -92,7 +147,6 @@ reconnect(wagmiConfig);
92147
const address = ref<Address | null>(null);
93148
const balance = ref<GetBalanceReturnType | null>(null);
94149
const errorMessage = ref<string | null>(null);
95-
const isSendingEth = ref<boolean>(false);
96150
97151
const fundAccount = async () => {
98152
if (!address.value) throw new Error("Not connected");
@@ -166,6 +220,9 @@ const disconnectWallet = async () => {
166220
await disconnect(wagmiConfig);
167221
};
168222
223+
/* Send ETH */
224+
const isSendingEth = ref<boolean>(false);
225+
169226
const sendTokens = async (usePaymaster: boolean) => {
170227
if (!address.value) return;
171228
@@ -178,7 +235,7 @@ const sendTokens = async (usePaymaster: boolean) => {
178235
transactionHash = await sendTransaction(wagmiConfig, {
179236
to: testTransferTarget,
180237
value: parseEther("0.1"),
181-
paymaster: PaymasterContract.deployedTo as `0x${string}`,
238+
paymaster: PaymasterContract.deployedTo as Address,
182239
paymasterInput: getGeneralPaymasterInput({ innerInput: "0x" }),
183240
});
184241
} else {
@@ -225,4 +282,108 @@ const sendTokens = async (usePaymaster: boolean) => {
225282
isSendingEth.value = false;
226283
}
227284
};
285+
286+
/* Typed data */
287+
const typedDataSignature = ref<Hash | null>(null);
288+
const isValidTypedDataSignature = ref<boolean | null>(null);
289+
const isSigningTypedData = ref<boolean>(false);
290+
const isVerifyingTypedDataSignature = ref<boolean>(false);
291+
292+
const typedData = {
293+
types: {
294+
TestStruct: [
295+
{ name: "message", type: "string" },
296+
{ name: "value", type: "uint256" },
297+
],
298+
},
299+
primaryType: "TestStruct",
300+
message: {
301+
message: "test",
302+
value: 42n,
303+
},
304+
} as const;
305+
306+
const signTypedDataHandler = async () => {
307+
if (!address.value) return;
308+
309+
errorMessage.value = "";
310+
isSigningTypedData.value = true;
311+
isValidTypedDataSignature.value = null;
312+
try {
313+
const erc1271CallerAddress = ERC1271CallerContract.deployedTo as Address;
314+
const { domain: callerDomain } = await publicClient.getEip712Domain({
315+
address: erc1271CallerAddress,
316+
});
317+
318+
const signature = await signTypedData(wagmiConfig, {
319+
domain: {
320+
...callerDomain,
321+
salt: undefined, // Otherwise the signature verification fails (todo: figure out why)
322+
},
323+
...typedData,
324+
});
325+
typedDataSignature.value = signature;
326+
} catch (error) {
327+
// eslint-disable-next-line no-console
328+
console.error("Typed data signing failed:", error);
329+
errorMessage.value = "Typed data signing failed, see console for more info.";
330+
} finally {
331+
isSigningTypedData.value = false;
332+
}
333+
};
334+
335+
const verifyTypedDataSignatureAutomatically = async () => {
336+
if (!address.value || !typedDataSignature.value) {
337+
isValidTypedDataSignature.value = null;
338+
return;
339+
}
340+
341+
isVerifyingTypedDataSignature.value = true;
342+
try {
343+
const contractAddress = ERC1271CallerContract.deployedTo as Address;
344+
345+
const isValid = await readContract(wagmiConfig, {
346+
address: contractAddress,
347+
abi: [{
348+
type: "function",
349+
name: "validateStruct",
350+
stateMutability: "view",
351+
inputs: [
352+
{
353+
name: "testStruct", type: "tuple", internalType: "struct ERC1271Caller.TestStruct",
354+
components: [
355+
{ name: "message", type: "string", internalType: "string" },
356+
{ name: "value", type: "uint256", internalType: "uint256" },
357+
],
358+
},
359+
{ name: "signer", type: "address", internalType: "address" },
360+
{ name: "encodedSignature", type: "bytes", internalType: "bytes" },
361+
],
362+
outputs: [{ name: "", type: "bool", internalType: "bool" }],
363+
}] as const,
364+
functionName: "validateStruct",
365+
args: [
366+
typedData.message,
367+
address.value,
368+
typedDataSignature.value,
369+
],
370+
});
371+
372+
isValidTypedDataSignature.value = isValid;
373+
} catch (error) {
374+
// eslint-disable-next-line no-console
375+
console.error("Typed data signature verification failed:", error);
376+
isValidTypedDataSignature.value = false;
377+
} finally {
378+
isVerifyingTypedDataSignature.value = false;
379+
}
380+
};
381+
382+
watch(address, () => typedDataSignature.value = null);
383+
watch(typedDataSignature, verifyTypedDataSignatureAutomatically);
384+
385+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
386+
(window.BigInt as any).prototype.toJSON = function () {
387+
return this.toString();
388+
};
228389
</script>

examples/demo-app/project.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@
3636
"executor": "nx:run-commands",
3737
"options": {
3838
"cwd": "examples/demo-app",
39-
"command": "forge build smart-contracts/DemoPaymaster.sol --root . --zksync"
39+
"command": "FOUNDRY_LOG=trace forge build smart-contracts/ --root . --zksync"
4040
}
4141
},
4242
"deploy-contracts": {
4343
"executor": "nx:run-commands",
4444
"options": {
4545
"cwd": "examples/demo-app",
46-
"command": "forge create smart-contracts/DemoPaymaster.sol:DemoPaymaster --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 --rpc-url http://localhost:8011 --root . --chain 260 --zksync --json 2>&1 | sed -n 's/.*\\({.*}\\).*/\\1/p' > forge-output.json && ADDRESS=$(sed -n 's/.*\"deployedTo\":\"\\([^\"]*\\)\".*/\\1/p' forge-output.json) && echo $ADDRESS && cast send --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 $ADDRESS --rpc-url http://localhost:8011 --value 0.1ether"
46+
"command": "forge create smart-contracts/DemoPaymaster.sol:DemoPaymaster --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 --rpc-url http://localhost:8011 --root . --chain 260 --zksync --json 2>&1 | sed -n 's/.*\\({.*}\\).*/\\1/p' > forge-output-paymaster.json && ADDRESS=$(sed -n 's/.*\"deployedTo\":\"\\([^\"]*\\)\".*/\\1/p' forge-output-paymaster.json) && echo \"DemoPaymaster deployed to: $ADDRESS\" && cast send --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 $ADDRESS --rpc-url http://localhost:8011 --value 0.1ether && forge create smart-contracts/ERC1271Caller.sol:ERC1271Caller --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 --rpc-url http://localhost:8011 --root . --chain 260 --zksync --json 2>&1 | sed -n 's/.*\\({.*}\\).*/\\1/p' > forge-output-erc1271.json && ADDRESS=$(sed -n 's/.*\"deployedTo\":\"\\([^\"]*\\)\".*/\\1/p' forge-output-erc1271.json) && echo \"ERC1271Caller deployed to: $ADDRESS\""
4747
},
4848
"dependsOn": ["build-contracts"]
4949
},
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
5+
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
6+
import "@openzeppelin/contracts/interfaces/IERC1271.sol";
7+
import "@openzeppelin/contracts/utils/Address.sol";
8+
9+
contract ERC1271Caller is EIP712 {
10+
struct TestStruct {
11+
string message;
12+
uint256 value;
13+
}
14+
15+
constructor() EIP712("ERC1271Caller", "1.0.0") {}
16+
17+
function validateStruct(
18+
TestStruct calldata testStruct,
19+
address signer,
20+
bytes calldata signature
21+
) external view returns (bool) {
22+
require(signer != address(0), "Invalid signer address");
23+
24+
bytes32 structHash = keccak256(
25+
abi.encode(
26+
keccak256("TestStruct(string message,uint256 value)"),
27+
keccak256(bytes(testStruct.message)),
28+
testStruct.value
29+
)
30+
);
31+
32+
bytes32 digest = _hashTypedDataV4(structHash);
33+
34+
bytes4 magic = IERC1271(signer).isValidSignature(digest, signature);
35+
return magic == IERC1271.isValidSignature.selector;
36+
}
37+
}

0 commit comments

Comments
 (0)