Skip to content

Commit 3b1cd94

Browse files
committed
fix: support paymaster via auth-server
still have stuff to fix and tests, but was able to validate manually
1 parent 13e9b99 commit 3b1cd94

File tree

19 files changed

+471
-149
lines changed

19 files changed

+471
-149
lines changed

examples/demo-app/pages/index.vue

Lines changed: 43 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@
1717
Connect with Session
1818
</button>
1919
<button
20-
v-if="address"
21-
title="Send ETH with paymaster sponsoring gas"
22-
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mr-4 disabled:bg-slate-300"
23-
:disabled="isSendingEth"
24-
@click="sendTokensWithPaymaster()"
20+
v-if="!address"
21+
title="Connect with paymaster sponsoring gas (no session)"
22+
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mr-4"
23+
@click="connectWallet('paymaster')"
2524
>
26-
Send 0.1 ETH (Paymaster)
25+
Connect (Paymaster)
2726
</button>
2827
<button
2928
v-if="!address"
@@ -45,13 +44,20 @@
4544
>
4645
<p>Balance: {{ balance ? `${balance.formatted} ${balance.symbol}` : '...' }}</p>
4746
</div>
47+
<div
48+
v-if="address"
49+
class="mt-4"
50+
>
51+
<p>Connection Mode: {{ connectionMode }} {{ isPaymasterEnabled ? '(Gas Sponsored ✨)' : '' }}</p>
52+
</div>
4853
<button
4954
v-if="address"
50-
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-3 mr-4 disabled:bg-slate-300"
55+
:class="isPaymasterEnabled ? 'bg-green-500 hover:bg-green-700' : 'bg-blue-500 hover:bg-blue-700'"
56+
class="text-white font-bold py-2 px-4 rounded mt-3 mr-4 disabled:bg-slate-300"
5157
:disabled="isSendingEth"
5258
@click="sendTokens()"
5359
>
54-
Send 0.1 ETH
60+
Send 0.1 ETH{{ isPaymasterEnabled ? ' (Paymaster)' : '' }}
5561
</button>
5662

5763
<!-- <div
@@ -113,8 +119,8 @@
113119
</template>
114120

115121
<script lang="ts" setup>
116-
import { disconnect, getBalance, watchAccount, sendTransaction, createConfig, connect, waitForTransactionReceipt, type GetBalanceReturnType, signTypedData, readContract } from "@wagmi/core";
117-
import { createWalletClient, createPublicClient, http, parseEther, type Address, type Hash } from "viem";
122+
import { disconnect, getBalance, watchAccount, createConfig, connect, waitForTransactionReceipt, type GetBalanceReturnType, signTypedData, readContract, getConnectorClient } from "@wagmi/core";
123+
import { createWalletClient, createPublicClient, http, parseEther, toHex, type Address, type Hash } from "viem";
118124
import { zksyncSsoConnector } from "zksync-sso-4337/connector";
119125
import { privateKeyToAccount } from "viem/accounts";
120126
import { localhost } from "viem/chains";
@@ -190,6 +196,8 @@ const wagmiConfig = createConfig({
190196
const address = ref<Address | null>(null);
191197
const balance = ref<GetBalanceReturnType | null>(null);
192198
const errorMessage = ref<string | null>(null);
199+
const connectionMode = ref<string>("Not connected");
200+
const isPaymasterEnabled = computed(() => connectionMode.value === "paymaster" || connectionMode.value === "session-paymaster");
193201
const isInitializing = ref(true);
194202
195203
// Ensure fresh, unauthenticated state on page load so the connect buttons render
@@ -271,6 +279,9 @@ const connectWallet = async (mode: "regular" | "session" | "paymaster" | "sessio
271279
return;
272280
}
273281
282+
// Track which mode was used for connection
283+
connectionMode.value = mode;
284+
274285
connect(wagmiConfig, {
275286
connector,
276287
chainId: chain.id,
@@ -284,7 +295,16 @@ const connectWallet = async (mode: "regular" | "session" | "paymaster" | "sessio
284295
285296
const disconnectWallet = async () => {
286297
errorMessage.value = "";
287-
await disconnect(wagmiConfig);
298+
try {
299+
await disconnect(wagmiConfig);
300+
} catch (error) {
301+
// If connector doesn't have disconnect method, manually reset state
302+
// eslint-disable-next-line no-console
303+
console.warn("Disconnect failed, manually resetting state:", error);
304+
address.value = null;
305+
balance.value = null;
306+
}
307+
connectionMode.value = "Not connected";
288308
};
289309
290310
/* Send ETH */
@@ -296,19 +316,18 @@ const sendTokens = async () => {
296316
errorMessage.value = "";
297317
isSendingEth.value = true;
298318
try {
299-
let transactionHash;
300-
301-
transactionHash = await sendTransaction(wagmiConfig, {
302-
to: testTransferTarget,
303-
value: parseEther("0.1"),
304-
});
305-
306-
// FIXME: When not using sessions, sendTransaction returns a map and not a string
307-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
308-
if ((transactionHash as any).value !== undefined) {
309-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
310-
transactionHash = (transactionHash as any).value;
311-
}
319+
// Get the connector client which will have paymaster config if connected with paymaster mode
320+
const connectorClient = await getConnectorClient(wagmiConfig);
321+
322+
// Use the provider's request method which routes through our custom client
323+
const transactionHash = await connectorClient.request({
324+
method: "eth_sendTransaction",
325+
params: [{
326+
from: address.value,
327+
to: testTransferTarget,
328+
value: toHex(parseEther("0.1")),
329+
}],
330+
}) as Hash;
312331
313332
const receipt = await waitForTransactionReceipt(wagmiConfig, {
314333
hash: transactionHash,
@@ -341,68 +360,6 @@ const sendTokens = async () => {
341360
}
342361
};
343362
344-
const sendTokensWithPaymaster = async () => {
345-
if (!address.value) return;
346-
347-
errorMessage.value = "";
348-
isSendingEth.value = true;
349-
try {
350-
if (!testPaymasterAddress) {
351-
throw new Error("Paymaster address not configured");
352-
}
353-
354-
// Temporarily reconfigure with paymaster
355-
const paymasterConnector = buildConnector("paymaster");
356-
357-
// Reconnect with paymaster config
358-
await disconnect(wagmiConfig);
359-
await connect(wagmiConfig, {
360-
connector: paymasterConnector,
361-
chainId: chain.id,
362-
});
363-
364-
// Wait for reconnection
365-
await new Promise((resolve) => setTimeout(resolve, 2000));
366-
367-
let transactionHash = await sendTransaction(wagmiConfig, {
368-
to: testTransferTarget,
369-
value: parseEther("0.1"),
370-
});
371-
372-
// FIXME: When not using sessions, sendTransaction returns a map and not a string
373-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
374-
if ((transactionHash as any).value !== undefined) {
375-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
376-
transactionHash = (transactionHash as any).value;
377-
}
378-
379-
const receipt = await waitForTransactionReceipt(wagmiConfig, {
380-
hash: transactionHash,
381-
});
382-
balance.value = await getBalance(wagmiConfig, {
383-
address: address.value,
384-
});
385-
if (receipt.status === "reverted") throw new Error("Transaction reverted");
386-
} catch (error) {
387-
// eslint-disable-next-line no-console
388-
console.error("Paymaster transaction failed:", error);
389-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
390-
let transactionFailureDetails = (error as any).cause?.cause?.cause?.data?.originalError?.cause?.details;
391-
if (!transactionFailureDetails) {
392-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
393-
transactionFailureDetails = (error as any).cause?.details;
394-
}
395-
396-
if (transactionFailureDetails) {
397-
errorMessage.value = transactionFailureDetails;
398-
} else {
399-
errorMessage.value = "Paymaster transaction failed, see console for more info.";
400-
}
401-
} finally {
402-
isSendingEth.value = false;
403-
}
404-
};
405-
406363
/* Typed data */
407364
const typedDataSignature = ref<Hash | null>(null);
408365
const isValidTypedDataSignature = ref<boolean | null>(null);

examples/demo-app/project.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@
8787
},
8888
"dependsOn": ["build:local"]
8989
},
90+
"dev-all": {
91+
"executor": "nx:run-commands",
92+
"options": {
93+
"cwd": "examples/demo-app",
94+
"commands": [
95+
{
96+
"command": "pnpm nx dev:with-api auth-server",
97+
"prefix": "Auth-Server:"
98+
},
99+
{
100+
"command": "PORT=3005 pnpm run dev",
101+
"prefix": "Demo-App:"
102+
}
103+
]
104+
},
105+
"dependsOn": ["build:local"]
106+
},
90107
"e2e:setup:erc4337": {
91108
"executor": "nx:run-commands",
92109
"options": {

examples/demo-app/tests/create-account.spec.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,12 @@ test("Create passkey account and send ETH", async ({ page }) => {
241241
.toBeGreaterThan(endBalance + 0.1);
242242
});
243243

244-
test("Create passkey account and verify paymaster button", async ({ page }) => {
244+
test("Create passkey account and send ETH with paymaster", async ({ page }) => {
245245
// Create account with regular connect
246246
await page.getByRole("button", { name: "Connect", exact: true }).click();
247247

248248
await page.waitForTimeout(2000);
249-
const popup = page.context().pages()[1];
249+
let popup = page.context().pages()[1];
250250
await expect(popup.getByText("Connect to")).toBeVisible();
251251
popup.on("console", (msg) => {
252252
if (msg.type() === "error") console.log(`Auth server error console: "${msg.text()}"`);
@@ -256,8 +256,13 @@ test("Create passkey account and verify paymaster button", async ({ page }) => {
256256
});
257257

258258
// Setup WebAuthn for passkey creation
259-
const client = await popup.context().newCDPSession(popup);
259+
let client = await popup.context().newCDPSession(popup);
260260
await client.send("WebAuthn.enable");
261+
let newCredential: WebAuthnCredential | null = null;
262+
client.on("WebAuthn.credentialAdded", (credentialAdded) => {
263+
console.log("New Passkey credential added");
264+
newCredential = credentialAdded.credential;
265+
});
261266
await client.send("WebAuthn.addVirtualAuthenticator", {
262267
options: {
263268
protocol: "ctap2",
@@ -279,16 +284,129 @@ test("Create passkey account and verify paymaster button", async ({ page }) => {
279284
await expect(page.getByText("Disconnect")).toBeVisible({ timeout: 10000 });
280285
await expect(page.getByText("Balance:")).toBeVisible();
281286

282-
const balanceText = await page.getByText("Balance:").innerText();
283-
const balance = +balanceText.replace("Balance: ", "").replace(" ETH", "");
287+
const startBalanceText = await page.getByText("Balance:").innerText();
288+
const startBalance = +startBalanceText.replace("Balance: ", "").replace(" ETH", "");
289+
console.log(`Starting balance: ${startBalance} ETH`);
284290

285-
// Verify paymaster button exists and is enabled
291+
// Click "Send 0.1 ETH (Paymaster)" button
286292
await expect(page.getByRole("button", { name: "Send 0.1 ETH (Paymaster)", exact: true })).toBeVisible();
293+
await page.getByRole("button", { name: "Send 0.1 ETH (Paymaster)", exact: true }).click();
294+
295+
// Wait for Auth Server to pop back up
296+
await page.waitForTimeout(2000);
297+
298+
// Check if popup appeared
299+
const pages = page.context().pages();
300+
console.log(`Number of pages after clicking paymaster button: ${pages.length}`);
301+
if (pages.length < 2) {
302+
console.log("ERROR: Auth server popup did not appear!");
303+
console.log("This means the paymaster transaction may not require confirmation");
304+
throw new Error("Auth server popup did not appear after clicking paymaster button");
305+
}
306+
307+
popup = pages[1];
308+
309+
// Debug: Check what screen the popup is showing
310+
console.log(`Popup URL: ${popup.url()}`);
311+
const popupTitle = await popup.title();
312+
console.log(`Popup title: ${popupTitle}`);
313+
314+
// Check if this is a connection screen or transaction screen
315+
const isConnectionScreen = popup.url().includes("/connect");
316+
console.log(`Is connection screen: ${isConnectionScreen}`);
317+
318+
if (isConnectionScreen) {
319+
console.log("⚠️ Popup is showing connection approval, not transaction confirmation!");
320+
console.log("This means disconnect/reconnect triggered a new connection request");
321+
console.log("We need to approve the connection first, then wait for transaction popup");
322+
323+
// Recreate the virtual authenticator for connection approval
324+
client = await popup.context().newCDPSession(popup);
325+
await client.send("WebAuthn.enable");
326+
const connResult = await client.send("WebAuthn.addVirtualAuthenticator", {
327+
options: {
328+
protocol: "ctap2",
329+
transport: "usb",
330+
hasResidentKey: true,
331+
hasUserVerification: true,
332+
isUserVerified: true,
333+
automaticPresenceSimulation: true,
334+
},
335+
});
336+
await expect(newCredential).not.toBeNull();
337+
await client.send("WebAuthn.addCredential", {
338+
authenticatorId: connResult.authenticatorId,
339+
credential: newCredential!,
340+
});
341+
342+
// Click "Connect" to approve the connection
343+
console.log("Clicking Connect button to approve reconnection...");
344+
await popup.getByTestId("connect").click();
345+
346+
// Wait for connection popup to close and transaction popup to open
347+
await page.waitForTimeout(3000);
348+
const pagesAfterConnection = page.context().pages();
349+
console.log(`Pages after connection approval: ${pagesAfterConnection.length}`);
350+
351+
if (pagesAfterConnection.length < 2) {
352+
throw new Error("Transaction popup did not appear after connection approval");
353+
}
354+
355+
// Get the NEW popup (transaction confirmation)
356+
popup = pagesAfterConnection[pagesAfterConnection.length - 1];
357+
console.log(`New popup URL: ${popup.url()}`);
358+
}
359+
360+
// Now we should have the transaction confirmation popup
361+
// Recreate the virtual authenticator for transaction signature
362+
client = await popup.context().newCDPSession(popup);
363+
await client.send("WebAuthn.enable");
364+
const result = await client.send("WebAuthn.addVirtualAuthenticator", {
365+
options: {
366+
protocol: "ctap2",
367+
transport: "usb",
368+
hasResidentKey: true,
369+
hasUserVerification: true,
370+
isUserVerified: true,
371+
automaticPresenceSimulation: true,
372+
},
373+
});
374+
await expect(newCredential).not.toBeNull();
375+
await client.send("WebAuthn.addCredential", {
376+
authenticatorId: result.authenticatorId,
377+
credential: newCredential!,
378+
});
379+
380+
// Verify the transaction details in auth server
381+
await expect(popup.getByText("-0.1")).toBeVisible();
382+
await expect(popup.getByText("Sending to")).toBeVisible();
383+
384+
// CRITICAL: Verify that fees are shown as sponsored (paymaster covers them)
385+
await expect(popup.getByText("Fees")).toBeVisible();
386+
const sponsoredText = popup.getByText("0 ETH (Sponsored)");
387+
await expect(sponsoredText, "Paymaster should cover fees - expecting '0 ETH (Sponsored)' to be shown").toBeVisible();
388+
console.log("✓ Auth server shows fees are sponsored by paymaster");
389+
390+
// Confirm the transfer
391+
await popup.getByTestId("confirm").click();
392+
393+
// Wait for confirmation to complete and popup to close
394+
await page.waitForTimeout(2000);
395+
396+
// Verify transaction completed
287397
await expect(page.getByRole("button", { name: "Send 0.1 ETH (Paymaster)", exact: true })).toBeEnabled();
288398

289-
console.log(`Account created with balance: ${balance} ETH. Paymaster button verified.`);
290-
// Note: Actual paymaster transaction testing is covered by Test 4 (session + paymaster)
291-
// which successfully tests paymaster sponsorship via the "Connect Session (Paymaster)" flow
399+
const endBalanceText = await page.getByText("Balance:").innerText();
400+
const endBalance = +endBalanceText.replace("Balance: ", "").replace(" ETH", "");
401+
console.log(`Ending balance: ${endBalance} ETH`);
402+
403+
const balanceChange = startBalance - endBalance;
404+
console.log(`Balance change: ${balanceChange} ETH`);
405+
406+
// Balance should decrease by EXACTLY 0.1 ETH (no gas fees paid by user)
407+
// Allow small tolerance for rounding
408+
expect(Math.abs(balanceChange - 0.1), "Balance should decrease by exactly 0.1 ETH (paymaster pays gas)").toBeLessThan(0.001);
409+
console.log("✓ Paymaster successfully covered gas fees - balance decreased by exactly 0.1 ETH");
292410
});
293411

294412
test("Create account with session, create session via paymaster, and send ETH", async ({ page }) => {

0 commit comments

Comments
 (0)