Skip to content

Commit 63e8ddb

Browse files
FEAT: DVR-328 | Link External Wallet feature page (#2616)
Co-authored-by: Craig M. <[email protected]>
1 parent 5fd5b7c commit 63e8ddb

File tree

10 files changed

+490
-55
lines changed

10 files changed

+490
-55
lines changed

examples/passport/logged-in-user-with-nextjs/features.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
"manually-edited": true,
1818
"silent-login": true,
1919
"silent-logout": true
20+
},
21+
{
22+
"feature": "link-external-wallet",
23+
"manually-edited": true,
24+
"silent-login": true,
25+
"silent-logout": true
2026
}
2127
],
2228
"order": 3
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2-
"title": "User Information after Logging In with NextJS",
3-
"description": "Example of retrieving and displaying user data after authentication with Immutable Passport in a NextJS application",
4-
"keywords": ["Immutable", "SDK", "Passport", "NextJS", "User Info", "Linked Addresses", "Tokens"],
5-
"tech_stack": ["NextJS", "TypeScript", "React"],
2+
"title": "Logged-in User with NextJS",
3+
"description": "Example app demonstrating how to access user information and manage linked addresses after a user has logged in with Immutable Passport in a NextJS application",
4+
"keywords": ["Immutable", "SDK", "Passport", "NextJS", "Authentication", "User Info", "Linked Addresses", "Token Verification", "Wallet Linking"],
5+
"tech_stack": ["NextJS", "React", "TypeScript", "Ethereum", "MetaMask"],
66
"product": "Passport",
77
"programming_language": "TypeScript"
88
}

examples/passport/logged-in-user-with-nextjs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"@imtbl/sdk": "workspace:*",
1515
"next": "14.2.25",
1616
"react": "^18",
17-
"react-dom": "^18"
17+
"react-dom": "^18",
18+
"siwe": "^3.0.0"
1819
},
1920
"devDependencies": {
2021
"@playwright/test": "^1.45.3",

examples/passport/logged-in-user-with-nextjs/src/app/globals.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ body {
1515

1616
.mb-1 {
1717
margin-bottom: 1rem;
18+
}
19+
20+
.mt-1 {
21+
margin-top: 1rem;
1822
}
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { Button, Heading, Table, Link } from '@biom3/react';
5+
import NextLink from 'next/link';
6+
import { passportInstance } from '../utils/setupDefault';
7+
import { generateNonce } from 'siwe';
8+
import { Provider } from '@imtbl/sdk/passport';
9+
10+
// Define a type for the window.ethereum object
11+
declare global {
12+
interface Window {
13+
ethereum?: any;
14+
}
15+
}
16+
17+
export default function LinkExternalWallet() {
18+
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
19+
const [accountAddress, setAccountAddress] = useState<string | null>(null);
20+
const [walletConnected, setWalletConnected] = useState<boolean>(false);
21+
const [externalWalletAddress, setExternalWalletAddress] = useState<string | null>(null);
22+
const [isLinking, setIsLinking] = useState<boolean>(false);
23+
const [linkingStatus, setLinkingStatus] = useState<string>('');
24+
const [linkingError, setLinkingError] = useState<string | null>(null);
25+
const [linkedAddresses, setLinkedAddresses] = useState<string[]>([]);
26+
const [isMetaMaskInstalled, setIsMetaMaskInstalled] = useState<boolean>(false);
27+
28+
// Check if MetaMask is installed
29+
useEffect(() => {
30+
if (typeof window !== 'undefined') {
31+
setIsMetaMaskInstalled(!!window.ethereum);
32+
}
33+
}, []);
34+
35+
// fetch the Passport provider from the Passport instance
36+
const [passportProvider, setPassportProvider] = useState<Provider>();
37+
38+
useEffect(() => {
39+
const fetchPassportProvider = async () => {
40+
const passportProvider = await passportInstance.connectEvm();
41+
setPassportProvider(passportProvider);
42+
};
43+
fetchPassportProvider();
44+
}, []);
45+
46+
const loginWithPassport = async () => {
47+
if (!passportInstance) return;
48+
setLinkingError(null);
49+
try {
50+
const provider = await passportInstance.connectEvm();
51+
const accounts = await provider.request({ method: 'eth_requestAccounts' });
52+
if (accounts) {
53+
setIsLoggedIn(true);
54+
setAccountAddress(accounts[0] || null);
55+
} else {
56+
setIsLoggedIn(false);
57+
}
58+
} catch (error) {
59+
console.error('Error connecting to Passport:', error);
60+
setIsLoggedIn(false);
61+
}
62+
};
63+
64+
const connectWallet = async () => {
65+
setLinkingError(null);
66+
if (!isLoggedIn) {
67+
setLinkingError('Please log in with Passport first');
68+
return;
69+
}
70+
71+
try {
72+
if (typeof window === 'undefined' || !window.ethereum) {
73+
setLinkingError('MetaMask not installed. Please install MetaMask to continue.');
74+
return;
75+
}
76+
77+
const accounts = await window.ethereum.request({
78+
method: 'eth_requestAccounts'
79+
});
80+
81+
if (accounts && accounts.length > 0) {
82+
setWalletConnected(true);
83+
setExternalWalletAddress(accounts[0]);
84+
} else {
85+
throw new Error('No accounts found');
86+
}
87+
} catch (error) {
88+
console.error('Error connecting to wallet:', error);
89+
setLinkingError('Failed to connect wallet');
90+
}
91+
};
92+
93+
const linkWallet = async () => {
94+
if (!passportInstance || !isLoggedIn || !walletConnected || !externalWalletAddress || !accountAddress) {
95+
setLinkingError('Please ensure you are logged in and have connected an external wallet');
96+
return;
97+
}
98+
99+
setIsLinking(true);
100+
setLinkingStatus('Generating signature...');
101+
setLinkingError(null);
102+
103+
let linkedAddresses = await passportInstance.getLinkedAddresses();
104+
if (linkedAddresses.includes(externalWalletAddress)) {
105+
setLinkedAddresses(linkedAddresses);
106+
setLinkingStatus('Wallet already linked');
107+
setIsLinking(false);
108+
return;
109+
}
110+
111+
try {
112+
// Generate a nonce for the signature
113+
const nonce = generateNonce();
114+
// Ensure addresses are in the correct format - lowercase 0x-prefixed
115+
const metamaskAddress = externalWalletAddress.toLowerCase() as `0x${string}`;
116+
const passportAddress = accountAddress.toLowerCase() as `0x${string}`;
117+
118+
const dataToSign = {
119+
types: {
120+
EIP712Domain: [
121+
{
122+
name: "chainId",
123+
type: "uint256"
124+
}
125+
],
126+
LinkWallet: [
127+
{
128+
name: "walletAddress",
129+
type: "address"
130+
},
131+
{
132+
name: "immutablePassportAddress",
133+
type: "address"
134+
},
135+
{
136+
name: "condition",
137+
type: "string"
138+
},
139+
{
140+
name: "nonce",
141+
type: "string"
142+
}
143+
]
144+
},
145+
primaryType: "LinkWallet",
146+
domain: {
147+
chainId: 1, // Must be set to 1 for Ethereum Mainnet
148+
},
149+
message: {
150+
walletAddress: metamaskAddress,
151+
immutablePassportAddress: passportAddress,
152+
condition: "I agree to link this wallet to my Immutable Passport account.",
153+
nonce
154+
}
155+
}
156+
157+
if (typeof window === 'undefined' || !window.ethereum) {
158+
throw new Error('MetaMask not installed');
159+
}
160+
161+
let signature: string;
162+
163+
try {
164+
// Metamask must be connected to Ethereum Mainnet
165+
await window.ethereum.request({
166+
method: 'wallet_switchEthereumChain',
167+
params: [{ chainId: '0x1' }], // chainId must be in hexadecimal format
168+
});
169+
170+
// Now request the signature
171+
signature = await window.ethereum.request({
172+
method: 'eth_signTypedData_v4',
173+
params: [metamaskAddress, JSON.stringify(dataToSign)]
174+
});
175+
176+
} catch (error) {
177+
console.error('Error signing message:', error);
178+
setLinkingError('Failed to sign message');
179+
setLinkingStatus('Make sure you have MetaMask installed and connected to Ethereum Mainnet');
180+
setIsLinking(false);
181+
return
182+
}
183+
184+
setLinkingStatus('Linking wallet...');
185+
186+
// Call the linkExternalWallet method to link the wallet
187+
const result = await passportInstance.linkExternalWallet({
188+
type: "External",
189+
walletAddress: metamaskAddress,
190+
signature,
191+
nonce
192+
});
193+
194+
linkedAddresses = await passportInstance.getLinkedAddresses();
195+
setLinkedAddresses(linkedAddresses);
196+
setLinkingStatus('Wallet linked successfully!');
197+
setIsLinking(false);
198+
} catch (error: any) {
199+
console.error('Error linking wallet:', error);
200+
setLinkingError(error?.message || 'Failed to link wallet');
201+
setLinkingStatus('');
202+
setIsLinking(false);
203+
}
204+
};
205+
206+
return (
207+
<>
208+
<Heading size="medium" className="mb-1">
209+
Link External Wallet
210+
</Heading>
211+
212+
<div className="mb-1">
213+
{!isLoggedIn ? (
214+
<Button
215+
size="medium"
216+
onClick={loginWithPassport}>
217+
Login with Passport
218+
</Button>
219+
) : (
220+
<>
221+
{!walletConnected && (
222+
<Button
223+
size="medium"
224+
onClick={connectWallet}
225+
disabled={walletConnected || !isMetaMaskInstalled}>
226+
Connect Metamask
227+
</Button>
228+
)}
229+
230+
{walletConnected && (
231+
<Button
232+
size="medium"
233+
onClick={linkWallet}
234+
disabled={isLinking || !walletConnected}>
235+
{isLinking ? 'Linking...' : 'Link Wallet'}
236+
</Button>
237+
)}
238+
</>
239+
)}
240+
</div>
241+
242+
243+
<Table className="mb-1">
244+
<Table.Head>
245+
<Table.Row>
246+
<Table.Cell>Attribute</Table.Cell>
247+
<Table.Cell>Value</Table.Cell>
248+
</Table.Row>
249+
</Table.Head>
250+
<Table.Body>
251+
<Table.Row>
252+
<Table.Cell><b>Is Logged In</b></Table.Cell>
253+
<Table.Cell>{isLoggedIn ? 'Yes' : 'No'}</Table.Cell>
254+
</Table.Row>
255+
<Table.Row>
256+
<Table.Cell><b>Account Address</b></Table.Cell>
257+
<Table.Cell>{accountAddress || 'N/A'}</Table.Cell>
258+
</Table.Row>
259+
<Table.Row>
260+
<Table.Cell><b>MetaMask Available</b></Table.Cell>
261+
<Table.Cell>{isMetaMaskInstalled ? 'Yes' : 'No'}</Table.Cell>
262+
</Table.Row>
263+
<Table.Row>
264+
<Table.Cell><b>External Wallet</b></Table.Cell>
265+
<Table.Cell>{externalWalletAddress || 'Not connected'}</Table.Cell>
266+
</Table.Row>
267+
268+
{linkedAddresses.length > 0 && (
269+
<Table.Row>
270+
<Table.Cell><b>Linked Addresses</b></Table.Cell>
271+
<Table.Cell>{linkedAddresses.join(', ')}</Table.Cell>
272+
</Table.Row>
273+
)}
274+
{linkingError && (
275+
<Table.Row>
276+
<Table.Cell><b>Error</b></Table.Cell>
277+
<Table.Cell>{linkingError}</Table.Cell>
278+
</Table.Row>
279+
)}
280+
{linkingStatus && (
281+
<Table.Row>
282+
<Table.Cell><b>Message</b></Table.Cell>
283+
<Table.Cell>{linkingStatus}</Table.Cell>
284+
</Table.Row>
285+
)}
286+
</Table.Body>
287+
</Table>
288+
289+
<div className="mt-1">
290+
<Link rc={<NextLink href="/" />}>Return to Examples</Link>
291+
</div>
292+
</>
293+
);
294+
}

examples/passport/logged-in-user-with-nextjs/src/app/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,11 @@ export default function Home() {
2727
rc={<NextLink href="/verify-tokens-with-nextjs" />}>
2828
Verify Tokens with NextJS
2929
</Button>
30+
<Button
31+
className="mb-1"
32+
size="medium"
33+
rc={<NextLink href="/link-external-wallet" />}>
34+
Link External Wallet
35+
</Button>
3036
</>);
3137
}
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
'use client';
22
import { BiomeCombinedProviders, Stack } from '@biom3/react';
33

4+
45
export default function AppWrapper({
56
children,
67
}: Readonly<{
78
children: React.ReactNode;
89
}>) {
910
return (
1011
<div className="flex-container">
11-
<BiomeCombinedProviders>
12+
<BiomeCombinedProviders>
1213
<Stack alignItems="center">
13-
{ children }
14-
</Stack>
15-
</BiomeCombinedProviders>
16-
</div>
14+
{ children }
15+
</Stack>
16+
</BiomeCombinedProviders>
17+
</div>
1718
);
1819
}

0 commit comments

Comments
 (0)