Skip to content

Commit b116b6b

Browse files
authored
Merge pull request #6 from Outblock/feat/erc4337
feat: ERC-4337 smart wallet support
2 parents 25ecc08 + 3fceef3 commit b116b6b

21 files changed

Lines changed: 1463 additions & 120 deletions

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/Connect.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { encode } from "@onflow/rlp"
1616
import { signWithKey } from "../utils/sign";
1717

1818
interface ConnectProps {
19-
address: string;
19+
address?: string;
2020
}
2121

2222
interface AuthnInfo {

components/WalletCard.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const WalletCard = ({ address }: WalletCardProps) => {
3131
const [chainTab, setChainTab] = useState("flow");
3232

3333
const evmAddress = store.keyInfo?.evmAddress;
34+
const smartWalletAddress = store.keyInfo?.smartWalletAddress;
3435
const network = store.network || "testnet";
3536

3637
const copyAddr = (addr: string) => {
@@ -88,6 +89,15 @@ const WalletCard = ({ address }: WalletCardProps) => {
8889
</button>
8990
</div>
9091
)}
92+
{smartWalletAddress && (
93+
<div className="flex items-center gap-2">
94+
<Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-400 border-amber-500/20 font-mono px-1.5">4337</Badge>
95+
<code className="font-mono text-[11px] text-zinc-400 truncate flex-1">{smartWalletAddress}</code>
96+
<button onClick={() => copyAddr(smartWalletAddress)} className="text-zinc-600 hover:text-zinc-300 transition-colors">
97+
<LuCopy className="h-3 w-3" />
98+
</button>
99+
</div>
100+
)}
91101
</CardContent>
92102
</Card>
93103

components/activity/ActivityList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ const ActivityList = () => {
128128
const { data: flowTxs, loading: flowLoading, error: flowError } = useFlowTransactions(store.address);
129129
const { data: evmTxs, loading: evmLoading, error: evmError } = useEvmTransactions(evmAddress);
130130

131-
const flowList: FlowTx[] = flowTxs?.data || flowTxs?.transactions || (Array.isArray(flowTxs) ? flowTxs : []);
132-
const evmList: EvmTx[] = evmTxs?.data || evmTxs?.items || (Array.isArray(evmTxs) ? evmTxs : []);
131+
const flowList: FlowTx[] = (flowTxs as any)?.data || (flowTxs as any)?.transactions || (Array.isArray(flowTxs) ? flowTxs : []);
132+
const evmList: EvmTx[] = (evmTxs as any)?.data || (evmTxs as any)?.items || (Array.isArray(evmTxs) ? evmTxs : []);
133133

134134
return (
135135
<div className="flex flex-col gap-2">

components/setting/Setting.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const Setting = () => {
5959
onCheckedChange={(checked: boolean) => {
6060
console.log("onCheckedChange ==>", enableBiometric);
6161
setEnableBiometric(checked);
62-
set(KEYS.BIOMETRIC, checked);
62+
set(KEYS.BIOMETRIC, String(checked));
6363
handleKeyInfo(checked);
6464
}}
6565
/>

components/sign/SignCard.tsx

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,31 @@ function removeKeyFromList(address: string): void {
9595
type Mode = null | 'create' | 'passkey-signin';
9696
type CreateType = null | 'passkey' | 'privateKey';
9797

98+
const COLORS = ["Red", "Blue", "Green", "Purple", "Orange", "Pink", "Cyan", "Gold", "Silver", "Coral", "Teal", "Lime", "Mint", "Amber", "Jade"];
99+
const ANIMALS = ["Fox", "Wolf", "Bear", "Eagle", "Hawk", "Tiger", "Lion", "Panda", "Otter", "Raven", "Falcon", "Lynx", "Cobra", "Dolphin", "Owl"];
100+
101+
function generateRandomName(): string {
102+
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
103+
const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
104+
return `${color} ${animal}`;
105+
}
106+
107+
const MotionWrap = ({ children }: { children: React.ReactNode }) => (
108+
<motion.div
109+
initial={{ opacity: 0, y: 12 }}
110+
animate={{ opacity: 1, y: 0 }}
111+
exit={{ opacity: 0, y: -12 }}
112+
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
113+
className="flex flex-col gap-3 w-full"
114+
>
115+
{children}
116+
</motion.div>
117+
);
118+
98119
const SignCard = () => {
99120
const { store, setStore } = useContext(StoreContext);
100121
const [mode, setMode] = useState<Mode>(null);
101-
const [username, setUsername] = useState("");
122+
const [username, setUsername] = useState(() => generateRandomName());
102123
const [createType, setCreateType] = useState<CreateType>(null);
103124
const [generatedKey, setGeneratedKey] = useState<{ pk: string; pubK: string } | null>(null);
104125
const [loading, setLoading] = useState(false);
@@ -130,8 +151,22 @@ const SignCard = () => {
130151
useEffect(() => {
131152
if (!registerInfo || !registerInfo.credentialId) return;
132153
const result = getPKfromRegister(registerInfo);
133-
setStore((s: any) => ({ ...s, keyInfo: result, id: registerInfo.credentialId, username, isCreating: true }));
134-
doCreateAccount(result.pubK);
154+
155+
// Derive smart wallet address for passkey (async)
156+
(async () => {
157+
try {
158+
const { getSmartWalletAddress } = await import("../../utils/smartWallet");
159+
const smartWalletAddress = await getSmartWalletAddress(
160+
registerInfo.publicKeySec1Hex,
161+
store.network || "testnet"
162+
);
163+
result.smartWalletAddress = smartWalletAddress;
164+
} catch (e) {
165+
console.warn("[passkey] Could not derive smart wallet address:", e);
166+
}
167+
setStore((s: any) => ({ ...s, keyInfo: result, id: registerInfo.credentialId, username, isCreating: true }));
168+
doCreateAccount(result.pubK);
169+
})();
135170
}, [registerInfo]);
136171

137172
const doCreateAccount = async (pubK: string, signatureAlgorithm = "ECDSA_P256", hashAlgorithm = "SHA2_256") => {
@@ -213,18 +248,6 @@ const SignCard = () => {
213248

214249
const modeKey = !mode ? "home" : mode === "create" && !createType ? "create" : mode === "create" && createType === "passkey" ? "passkey" : "home";
215250

216-
const MotionWrap = ({ children }: { children: React.ReactNode }) => (
217-
<motion.div
218-
initial={{ opacity: 0, y: 12 }}
219-
animate={{ opacity: 1, y: 0 }}
220-
exit={{ opacity: 0, y: -12 }}
221-
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
222-
className="flex flex-col gap-3 w-full"
223-
>
224-
{children}
225-
</motion.div>
226-
);
227-
228251
// === Main selector ===
229252
if (!mode) {
230253
return (
@@ -433,20 +456,33 @@ const SignCard = () => {
433456
<BackButton onClick={() => setCreateType(null)} />
434457
<div className="flex flex-col gap-2">
435458
<label className="text-sm text-gray-400">Username</label>
436-
<Input
437-
type="text"
438-
placeholder="Choose a name for your passkey"
439-
value={username}
440-
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
441-
className="bg-zinc-900/50 border-zinc-700"
442-
/>
459+
<div className="flex gap-2">
460+
<Input
461+
type="text"
462+
placeholder="Choose a name for your passkey"
463+
value={username}
464+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
465+
className="bg-zinc-900/50 border-zinc-700 flex-1"
466+
/>
467+
<Button
468+
variant="outline"
469+
size="icon"
470+
className="h-10 w-10 border-zinc-700 shrink-0"
471+
onClick={() => setUsername(generateRandomName())}
472+
title="Random name"
473+
>
474+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
475+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
476+
</svg>
477+
</Button>
478+
</div>
443479
</div>
444480
<Button
445481
className="bg-[#00EF8B] text-black hover:bg-[#00d67d] font-semibold"
446482
disabled={!username.trim()}
447483
onClick={async () => {
448484
try {
449-
setRegisterInfo(await createPasskey(username, username));
485+
setRegisterInfo(await createPasskey(username));
450486
} catch (e) {
451487
toast.error("Passkey registration failed");
452488
}

components/token/TokenList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const TokenList = () => {
2525
return <p className="text-sm text-red-400">Failed to load tokens</p>;
2626
}
2727

28-
const tokenList = tokens?.data || (Array.isArray(tokens) ? tokens : []);
28+
const tokenList = (tokens as any)?.data || (Array.isArray(tokens) ? tokens : []);
2929

3030
return (
3131
<div role="listbox" aria-label="Token list" className="flex flex-col">

e2e/passkey-4337.spec.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @ts-check
2+
const { test, expect } = require('@playwright/test');
3+
const { getStoredWallet } = require('./helpers');
4+
5+
/**
6+
* Passkey + ERC-4337 Smart Wallet E2E Tests
7+
*
8+
* Uses Playwright's CDP virtual authenticator to simulate WebAuthn passkey.
9+
* Tests smart wallet address derivation and 4337 integration.
10+
*/
11+
12+
test.describe('Passkey + 4337 Smart Wallet', () => {
13+
test('smart wallet address can be derived from P256 public key', async ({ page }) => {
14+
// This tests the factory contract call on Flow EVM testnet
15+
await page.goto('http://localhost:3003');
16+
17+
// Use a known P256 uncompressed public key (04 + x + y, 65 bytes)
18+
const testPubKey = '04' +
19+
'a9c60215b3d3d0bd8c34be4f8b32e1d39aa93aa702e9fb84c8d64fdadc0b29c2' +
20+
'c3f55b1e8ce19d4a7b1e7a7a8b0d6c5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9';
21+
22+
const smartWalletAddress = await page.evaluate(async (pubKey) => {
23+
const { getSmartWalletAddress } = await import('/utils/smartWallet');
24+
return getSmartWalletAddress(pubKey, 'testnet');
25+
}, testPubKey).catch(() => null);
26+
27+
// If the factory is deployed and responsive, we should get an address
28+
// If not (e.g., RPC issues), skip gracefully
29+
if (smartWalletAddress) {
30+
expect(smartWalletAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
31+
console.log('Smart wallet address for test key:', smartWalletAddress);
32+
} else {
33+
console.log('Skipping: could not derive smart wallet address (RPC may be unavailable)');
34+
}
35+
});
36+
37+
test('private key wallet does NOT have smart wallet address', async ({ page }) => {
38+
await page.goto('http://localhost:3003');
39+
await page.evaluate(() => {
40+
localStorage.removeItem('store');
41+
localStorage.removeItem('settings_config');
42+
localStorage.removeItem('enableBiometric');
43+
});
44+
await page.goto('http://localhost:3003');
45+
await page.waitForLoadState('networkidle');
46+
47+
await page.getByText('Create Wallet', { exact: true }).waitFor({ state: 'visible', timeout: 15000 });
48+
await page.getByText('Create Wallet', { exact: true }).click();
49+
50+
await page.getByText('Private Key', { exact: true }).waitFor({ state: 'visible', timeout: 5000 });
51+
await page.getByText('Private Key', { exact: true }).click();
52+
53+
await page.waitForFunction(
54+
() => {
55+
const raw = localStorage.getItem('store');
56+
if (!raw) return false;
57+
const store = JSON.parse(raw);
58+
return store.keyInfo && store.keyInfo.type === 'PrivateKey';
59+
},
60+
{ timeout: 15000 }
61+
);
62+
63+
const store = await getStoredWallet(page);
64+
expect(store.keyInfo.type).toBe('PrivateKey');
65+
expect(store.keyInfo.evmAddress).toBeTruthy();
66+
// Private key wallets should NOT have smartWalletAddress (only passkey wallets)
67+
expect(store.keyInfo.smartWalletAddress).toBeUndefined();
68+
});
69+
70+
test('passkey wallet creation with virtual authenticator', async ({ page }) => {
71+
// Set up virtual WebAuthn authenticator
72+
const cdpSession = await page.context().newCDPSession(page);
73+
await cdpSession.send('WebAuthn.enable', { enableUI: false });
74+
const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
75+
options: {
76+
protocol: 'ctap2',
77+
transport: 'internal',
78+
hasResidentKey: true,
79+
hasUserVerification: true,
80+
isUserVerified: true,
81+
automaticPresenceSimulation: true,
82+
},
83+
});
84+
85+
// Clear state
86+
await page.goto('http://localhost:3003');
87+
await page.evaluate(() => {
88+
localStorage.removeItem('store');
89+
localStorage.removeItem('settings_config');
90+
localStorage.removeItem('enableBiometric');
91+
});
92+
await page.goto('http://localhost:3003');
93+
await page.waitForLoadState('networkidle');
94+
await page.waitForTimeout(1000);
95+
96+
// Capture errors
97+
const errors = [];
98+
page.on('pageerror', (err) => errors.push(err.message));
99+
page.on('console', (msg) => {
100+
if (msg.type() === 'error') errors.push(msg.text());
101+
});
102+
103+
// Navigate to passkey creation
104+
await page.getByText('Create Wallet', { exact: true }).waitFor({ state: 'visible', timeout: 15000 });
105+
await page.getByText('Create Wallet', { exact: true }).click();
106+
await page.getByText('Passkey', { exact: true }).waitFor({ state: 'visible', timeout: 5000 });
107+
await page.getByText('Passkey', { exact: true }).click();
108+
109+
// Fill username and register
110+
const input = page.getByPlaceholder('Choose a name for your passkey');
111+
await input.waitFor({ state: 'visible', timeout: 5000 });
112+
await input.fill('TestPasskey');
113+
await page.getByRole('button', { name: /register with passkey/i }).click();
114+
115+
// Wait for store to be populated with passkey keyInfo
116+
const success = await page.waitForFunction(
117+
() => {
118+
const raw = localStorage.getItem('store');
119+
if (!raw) return false;
120+
const s = JSON.parse(raw);
121+
return s.keyInfo?.type === 'Passkey' && s.keyInfo?.credentialId;
122+
},
123+
{ timeout: 20000 }
124+
).then(() => true).catch(() => false);
125+
126+
if (!success) {
127+
console.log('Passkey registration failed. Errors:', errors);
128+
// Check if Oops page is shown
129+
const body = await page.textContent('body');
130+
if (body.includes('Oops')) {
131+
console.log('Error card shown — passkey registration threw an exception');
132+
console.log('This may be due to @flowindex/flow-passkey incompatibility with virtual authenticator');
133+
}
134+
// Don't fail the test — passkey + virtual authenticator is environment-dependent
135+
test.skip();
136+
return;
137+
}
138+
139+
const store = await getStoredWallet(page);
140+
expect(store.keyInfo.type).toBe('Passkey');
141+
expect(store.keyInfo.credentialId).toBeTruthy();
142+
expect(store.keyInfo.publicKeySec1Hex).toBeTruthy();
143+
144+
// Check smart wallet address derivation
145+
const hasSmartWallet = await page.waitForFunction(
146+
() => {
147+
const raw = localStorage.getItem('store');
148+
return raw && JSON.parse(raw).keyInfo?.smartWalletAddress;
149+
},
150+
{ timeout: 15000 }
151+
).then(() => true).catch(() => false);
152+
153+
if (hasSmartWallet) {
154+
const updated = await getStoredWallet(page);
155+
expect(updated.keyInfo.smartWalletAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
156+
console.log('Smart wallet address:', updated.keyInfo.smartWalletAddress);
157+
} else {
158+
console.log('Smart wallet address not derived (factory may be unavailable)');
159+
}
160+
161+
// Cleanup
162+
await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
163+
});
164+
});

modules/settings.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@ export function readSettings(): Settings {
9494
settings = JSON.parse(s as string);
9595
} catch {
9696
}
97-
if ("rp" in settings) {
98-
delete settings.rp;
97+
if ("rp" in (settings as any)) {
98+
delete (settings as any).rp;
9999
}
100-
if ("user" in settings) {
101-
delete settings.user;
100+
if ("user" in (settings as any)) {
101+
delete (settings as any).user;
102102
}
103103
if (!("credentials" in settings)) {
104-
settings.credentials = {};
104+
(settings as any).credentials = {};
105105
}
106106
}
107107
return settings;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"lint": "bun next lint"
1111
},
1212
"dependencies": {
13+
"@flowindex/evm-wallet": "^0.1.0",
1314
"@flowindex/flow-passkey": "latest",
1415
"@flowindex/flow-signer": "latest",
1516
"@onflow/fcl": "^1.21.9",

0 commit comments

Comments
 (0)