From 9d7a760cabae1a7ee2585223edf3432d80e3a5c5 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 19 Sep 2026 13:39:29 +0530 Subject: [PATCH 1/4] Test the full app and backend flow from signup to eSIM identifier on a fork --- tests/consumer/userFlow.fork.test.ts | 200 +++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/consumer/userFlow.fork.test.ts diff --git a/tests/consumer/userFlow.fork.test.ts b/tests/consumer/userFlow.fork.test.ts new file mode 100644 index 0000000..d857249 --- /dev/null +++ b/tests/consumer/userFlow.fork.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { createWalletClient, erc20Abi, http, parseEther, stringToHex, type Address, type Hex } from "viem"; +import { createBundlerClient } from "viem/account-abstraction"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { ContractRevertError, Kokio } from "kokio-sdk"; +import { KokioAdmin } from "kokio-sdk/admin"; +import { ESIMWallet, ESIMWalletFactory } from "kokio-sdk/abis"; +import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; + +import { impersonateRegistryOwner } from "../utils/forkChain.js"; +import { createSoftSigner } from "../utils/softP256Signer.js"; +import { asPasskey } from "./fixtures/passkeyAuthenticator.js"; +import { expectSponsored } from "./fixtures/sponsorship.js"; +import { startForkStack, type ForkStack } from "./fixtures/forkStack.js"; +import { setTokenBalance } from "./fixtures/tokens.js"; +import { CREDENTIAL_ID, FORK_POLICY_ID, RP_ID, TEST_TAG, testBytes32, testDeviceId } from "./fixtures/testLabels.js"; + +const USDC = stringToHex("USDC", { size: 32 }); +const BUNDLE = { id: testBytes32("flow-bundle"), priceUSDCents: 500n, settlement: Settlement.DeviceWallet }; +const E_SIM_SALT = 1n; + +// Signup to an eSIM identifier onchain, in the order an app and its backend run +// it, with the backend's side done through KokioAdmin. Later steps build on earlier ones. +describe("documented user flow on a Base Sepolia fork", () => { + let stack: ForkStack; + let admin: KokioAdmin; + + // What the backend stores against the user at signup. + const db = { + uid: testDeviceId(), + salt: BigInt(Date.now()), + ownerKey: undefined as unknown as readonly [Hex, Hex], + deviceWallet: undefined as unknown as Address, + eSIMWallet: undefined as unknown as Address, + }; + const signer = createSoftSigner(RP_ID); + db.ownerKey = signer.ownerKey; + + let session: Kokio; + let client: KokioSmartAccountClient; + + beforeAll(async () => { + stack = await startForkStack(); + passkeyGet.mockImplementation(asPasskey(signer)); + + // The backend signs with its own private key, as it would against a hosted RPC. + const account = privateKeyToAccount(generatePrivateKey()); + await stack.fork.testClient.setBalance({ address: account.address, value: parseEther("1") }); + admin = new KokioAdmin(createWalletClient({ account, chain: baseSepolia, transport: http(stack.fork.rpcUrl) })); + + const { client: owner } = await impersonateRegistryOwner(stack.fork); + await waitFor(await new KokioAdmin(owner).registry.requestAdminUpdate(account.address)); + await waitFor(await admin.registry.acceptAdminUpdate()); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it("0. the backend works out the device wallet address at signup", async () => { + db.deviceWallet = await admin.deviceWalletFactory.getCounterFactualAddress(db.ownerKey, db.uid, db.salt); + + expect(await stack.fork.publicClient.getCode({ address: db.deviceWallet })).toBeUndefined(); + }, 60_000); + + it("1. the app resolves the same smart account from the passkey", async () => { + const walletClient = createWalletClient({ chain: baseSepolia, transport: http(stack.fork.rpcUrl) }); + const kokio = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, "unused-on-fork", FORK_POLICY_ID); + + const account = await kokio.smartAccount.getSmartWallet(db.uid, db.ownerKey, db.salt); + expect(account.address).toBe(db.deviceWallet); + + client = await kokio.smartAccount.getSmartWalletClient(account, { bundlerUrl: stack.bundlerUrl }); + session = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, "unused-on-fork", FORK_POLICY_ID, client, account.address); + }, 60_000); + + it("2. the app deploys the device wallet with a sponsored user operation", async () => { + await expectSponsored(client, stack.fork.publicClient, () => session.deviceWallet!.sendUserOperation([])); + + expect(await stack.fork.publicClient.getCode({ address: db.deviceWallet })).toMatch(/^0x[0-9a-f]+$/i); + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(false); + }, 120_000); + + it("3. the backend registers the device wallet from its own records", async () => { + await waitFor(await admin.deviceWalletFactory.postCreateAccount(db.deviceWallet, db.uid, db.ownerKey, db.salt)); + + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(true); + }, 60_000); + + let bindUserOp: Hex; + + it("4. the app deploys and binds an eSIM wallet without granting fund access", async () => { + await expectSponsored(client, stack.fork.publicClient, async () => { + const result = await session.deviceWallet!.deployAndBindESIMWallet(E_SIM_SALT, { grantAccessToFunds: false }); + db.eSIMWallet = result.eSIMWalletAddress; + return (bindUserOp = result.userOpHash); + }); + session.setESIMWalletAddress(db.eSIMWallet); + + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, 120_000); + + it("5. the backend verifies the eSIM wallet belongs to this user's device wallet", async () => { + // The app sends only { eSIMWallet, eSIMSalt, userOpHash }. The device wallet + // comes from the backend's own records. + const bundler = createBundlerClient({ transport: http(stack.bundlerUrl) }); + const receipt = await bundler.waitForUserOperationReceipt({ hash: bindUserOp }); + expect(receipt.success).toBe(true); + expect(receipt.sender).toBe(db.deviceWallet); + + expect(await admin.registry.isESIMWalletValid(db.eSIMWallet)).toBe(db.deviceWallet); + + admin.setDeviceWalletAddress(db.deviceWallet).setESIMWalletAddress(db.eSIMWallet); + expect(await admin.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(true); + expect(await admin.eSIMWallet!.owner()).toBe(db.deviceWallet); + + const { factoryAddresses } = await admin.constants; + const expected = await stack.fork.publicClient.readContract({ + address: factoryAddresses.ESIM_WALLET_FACTORY as Address, + abi: ESIMWalletFactory, + functionName: "getCounterFactualAddress", + args: [db.deviceWallet, E_SIM_SALT], + }); + expect(expected).toBe(db.eSIMWallet); + }, 120_000); + + const REF_1 = testBytes32("flow-order-1"); + let usdc: Address; + let quote: bigint; + let purchaseBlock: bigint; + + it("6. the app buys the bundle, the device wallet sending the tokens in the same operation", async () => { + usdc = (await session.paymentAdapter!.resolveAsset(USDC)).token; + quote = await session.paymentAdapter!.quote(USDC, BUNDLE.priceUSDCents); + await setTokenBalance(stack.fork, usdc, db.deviceWallet, quote * 2n); + + const receipt = await expectSponsored(client, stack.fork.publicClient, () => + session.eSIMWallet!.buyDataBundleWithTransfer(BUNDLE, USDC, quote, REF_1)); + purchaseBlock = receipt.receipt.blockNumber; + + expect(await balanceOf(usdc, db.deviceWallet)).toBe(quote); + expect(await balanceOf(usdc, db.eSIMWallet)).toBe(0n); + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, 120_000); + + it("7. the backend finds the payment by its reference", async () => { + const [event] = await stack.fork.publicClient.getContractEvents({ + address: db.eSIMWallet, + abi: ESIMWallet, + eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: REF_1 }, + // A real backend starts from its last scanned block; hosted RPCs cap the range. + fromBlock: purchaseBlock, + }); + + expect(event.args).toMatchObject({ + _dataBundleID: BUNDLE.id, + _priceUSDCents: BUNDLE.priceUSDCents, + _asset: USDC, + _token: usdc, + _amountSpent: quote, + }); + }, 60_000); + + const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; + + it("8. the backend records the eSIM's identifier, once", async () => { + await waitFor(await admin.registry.assignESIMIdentifier(db.eSIMWallet, E_SIM_ID)); + + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + + const again = await admin.registry.assignESIMIdentifier(db.eSIMWallet, `${E_SIM_ID}-2`).catch((e: unknown) => e); + expect(again).toBeInstanceOf(ContractRevertError); + expect((again as ContractRevertError).decoded?.errorName).toBe("ESIMIdentifierAlreadySet"); + }, 60_000); + + it("a top-up repeats steps 6 and 7 on the same eSIM wallet", async () => { + const REF_2 = testBytes32("flow-order-2"); + + await expectSponsored(client, stack.fork.publicClient, () => + session.eSIMWallet!.buyDataBundleWithTransfer(BUNDLE, USDC, quote, REF_2)); + + expect(await balanceOf(usdc, db.deviceWallet)).toBe(0n); + expect((await session.eSIMWallet!.transactionHistory(1n)).id).toBe(BUNDLE.id); + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + }, 120_000); + + const waitFor = async (hash: Hex) => { + const { status } = await stack.fork.publicClient.waitForTransactionReceipt({ hash }); + expect(status).toBe("success"); + }; + + const balanceOf = (token: Address, holder: Address) => + stack.fork.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); +}); From d81e7c4eeb46dc421fbc5a18edfa9997b0caabdb Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 19 Sep 2026 13:55:53 +0530 Subject: [PATCH 2/4] Run the user flow on Base Sepolia too, and log each step's hashes The steps live in one shared definition so the fork and live runs cannot drift apart. --- tests/consumer/flows/userFlow.ts | 231 +++++++++++++++++++++++++++ tests/consumer/userFlow.fork.test.ts | 223 ++++---------------------- tests/consumer/userFlow.live.test.ts | 31 ++++ 3 files changed, 296 insertions(+), 189 deletions(-) create mode 100644 tests/consumer/flows/userFlow.ts create mode 100644 tests/consumer/userFlow.live.test.ts diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts new file mode 100644 index 0000000..aaf6dda --- /dev/null +++ b/tests/consumer/flows/userFlow.ts @@ -0,0 +1,231 @@ +import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; +import { createWalletClient, erc20Abi, http, stringToHex, type Address, type Hex, type PublicClient } from "viem"; +import { createBundlerClient } from "viem/account-abstraction"; +import { baseSepolia } from "viem/chains"; +import { ContractRevertError, Kokio } from "kokio-sdk"; +import type { KokioAdmin } from "kokio-sdk/admin"; +import { ESIMWallet, ESIMWalletFactory } from "kokio-sdk/abis"; +import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; + +import { createSoftSigner } from "../../utils/softP256Signer.js"; +import { asPasskey } from "../fixtures/passkeyAuthenticator.js"; +import { expectSponsored } from "../fixtures/sponsorship.js"; +import { CREDENTIAL_ID, RP_ID, TEST_TAG, testBytes32, testDeviceId } from "../fixtures/testLabels.js"; + +/** Where the flow runs, and the few things that differ between a fork and Base Sepolia. */ +export interface FlowTarget { + rpcUrl: string; + publicClient: PublicClient; + pimlicoAPIKey: string; + policyId: string; + /** Left out to send user operations to Pimlico, as an app does. */ + bundlerUrl?: string; + /** Where the backend looks up a user operation receipt. */ + receiptUrl: string; + /** The backend, signing with its own private key as the registry's eSIM wallet admin. */ + admin: KokioAdmin; + /** Puts `amount` of `token` in the user's device wallet. */ + fund: (token: Address, to: Address, amount: bigint) => Promise; + /** Asset symbol to pay with. */ + asset: string; + priceUSDCents: bigint; + /** Blocks to wait after each write before reading its result. */ + confirmations: number; + /** Block explorer transaction URL prefix, so logged hashes can be opened. */ + explorerTx?: string; + stop?: () => Promise; +} + +const E_SIM_SALT = 1n; + +// Signup to an eSIM identifier onchain, in the order an app and its backend run +// it. One test per step, and later steps build on earlier ones. +export const describeUserFlow = ( + name: string, + setup: () => Promise, + passkeyGet: Mock, + { timeout }: { timeout: number }, +) => describe(name, () => { + let target: FlowTarget; + let admin: KokioAdmin; + let asset: Hex; + let bundle: { id: Hex; priceUSDCents: bigint; settlement: Settlement }; + + // What the backend stores against the user at signup. + const signer = createSoftSigner(RP_ID); + const db = { + uid: testDeviceId(), + salt: BigInt(Date.now()), + ownerKey: signer.ownerKey, + deviceWallet: undefined as unknown as Address, + eSIMWallet: undefined as unknown as Address, + }; + + let session: Kokio; + let client: KokioSmartAccountClient; + + beforeAll(async () => { + target = await setup(); + admin = target.admin; + asset = stringToHex(target.asset, { size: 32 }); + bundle = { id: testBytes32("flow-bundle"), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; + passkeyGet.mockImplementation(asPasskey(signer)); + }, 180_000); + + afterAll(async () => { + // Recorded so a live run can be looked up on a block explorer. + console.log(`device wallet ${db.deviceWallet}, eSIM wallet ${db.eSIMWallet}`); + await target?.stop?.(); + }); + + it("0. the backend works out the device wallet address at signup", async () => { + db.deviceWallet = await admin.deviceWalletFactory.getCounterFactualAddress(db.ownerKey, db.uid, db.salt); + log("0", `device wallet ${db.deviceWallet}, salt ${db.salt}, device ${db.uid}`); + + expect(await target.publicClient.getCode({ address: db.deviceWallet })).toBeUndefined(); + }, timeout); + + it("1. the app resolves the same smart account from the passkey", async () => { + const walletClient = createWalletClient({ chain: baseSepolia, transport: http(target.rpcUrl) }); + const kokio = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, target.pimlicoAPIKey, target.policyId); + + const account = await kokio.smartAccount.getSmartWallet(db.uid, db.ownerKey, db.salt); + log("1", `app resolved ${account.address}, no transaction`); + expect(account.address).toBe(db.deviceWallet); + + client = await kokio.smartAccount.getSmartWalletClient(account, { bundlerUrl: target.bundlerUrl }); + session = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, target.pimlicoAPIKey, target.policyId, client, account.address); + }, timeout); + + it("2. the app deploys the device wallet with a sponsored user operation", async () => { + await sponsored("2", () => session.deviceWallet!.sendUserOperation([])); + + expect(await target.publicClient.getCode({ address: db.deviceWallet })).toMatch(/^0x[0-9a-f]+$/i); + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(false); + }, timeout); + + it("3. the backend registers the device wallet from its own records", async () => { + await waitFor("3", await admin.deviceWalletFactory.postCreateAccount(db.deviceWallet, db.uid, db.ownerKey, db.salt)); + + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(true); + }, timeout); + + let bindUserOp: Hex; + + it("4. the app deploys and binds an eSIM wallet without granting fund access", async () => { + await sponsored("4", async () => { + const result = await session.deviceWallet!.deployAndBindESIMWallet(E_SIM_SALT, { grantAccessToFunds: false }); + db.eSIMWallet = result.eSIMWalletAddress; + return (bindUserOp = result.userOpHash); + }); + session.setESIMWalletAddress(db.eSIMWallet); + log("4", `eSIM wallet ${db.eSIMWallet}`); + + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, timeout); + + it("5. the backend verifies the eSIM wallet belongs to this user's device wallet", async () => { + // The app sends only { eSIMWallet, eSIMSalt, userOpHash }. The device wallet + // comes from the backend's own records. + const bundler = createBundlerClient({ transport: http(target.receiptUrl) }); + const receipt = await bundler.waitForUserOperationReceipt({ hash: bindUserOp }); + expect(receipt.success).toBe(true); + expect(receipt.sender).toBe(db.deviceWallet); + + expect(await admin.registry.isESIMWalletValid(db.eSIMWallet)).toBe(db.deviceWallet); + + admin.setDeviceWalletAddress(db.deviceWallet).setESIMWalletAddress(db.eSIMWallet); + expect(await admin.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(true); + expect(await admin.eSIMWallet!.owner()).toBe(db.deviceWallet); + + const { factoryAddresses } = await admin.constants; + const expected = await target.publicClient.readContract({ + address: factoryAddresses.ESIM_WALLET_FACTORY as Address, + abi: ESIMWalletFactory, + functionName: "getCounterFactualAddress", + args: [db.deviceWallet, E_SIM_SALT], + }); + expect(expected).toBe(db.eSIMWallet); + log("5", `verified from step 4's transaction ${link(receipt.receipt.transactionHash)}, no transaction`); + }, timeout); + + // Unique per run, so a live rerun never collides with an earlier purchase. + const REF_1 = testBytes32(`fo1-${Date.now()}`); + let token: Address; + let quote: bigint; + let purchaseBlock: bigint; + + it("6. the app buys the bundle, the device wallet sending the tokens in the same operation", async () => { + token = (await session.paymentAdapter!.resolveAsset(asset)).token; + quote = await session.paymentAdapter!.quote(asset, bundle.priceUSDCents); + await target.fund(token, db.deviceWallet, quote * 2n); + + const receipt = await sponsored("6", () => session.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, REF_1)); + purchaseBlock = receipt.receipt.blockNumber; + + expect(await balanceOf(token, db.deviceWallet)).toBe(quote); + expect(await balanceOf(token, db.eSIMWallet)).toBe(0n); + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, timeout); + + it("7. the backend finds the payment by its reference", async () => { + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, + abi: ESIMWallet, + eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: REF_1 }, + // A real backend starts from its last scanned block; hosted RPCs cap the range. + fromBlock: purchaseBlock, + }); + log("7", `payment found in ${link(event.transactionHash)}, no transaction`); + + expect(event.args).toMatchObject({ + _dataBundleID: bundle.id, + _priceUSDCents: bundle.priceUSDCents, + _asset: asset, + _token: token, + _amountSpent: quote, + }); + }, timeout); + + const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; + + it("8. the backend records the eSIM's identifier, once", async () => { + await waitFor("8", await admin.registry.assignESIMIdentifier(db.eSIMWallet, E_SIM_ID)); + + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + + // Refused before sending, so this costs nothing even on a live chain. + const again = await admin.registry.assignESIMIdentifier(db.eSIMWallet, `${E_SIM_ID}-2`).catch((e: unknown) => e); + expect(again).toBeInstanceOf(ContractRevertError); + expect((again as ContractRevertError).decoded?.errorName).toBe("ESIMIdentifierAlreadySet"); + }, timeout); + + it("a top-up repeats steps 6 and 7 on the same eSIM wallet", async () => { + const REF_2 = testBytes32(`fo2-${Date.now()}`); + + await sponsored("top-up", () => session.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, REF_2)); + + expect(await balanceOf(token, db.deviceWallet)).toBe(0n); + expect((await session.eSIMWallet!.transactionHistory(1n)).id).toBe(bundle.id); + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + }, timeout); + + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); + const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); + + const sponsored = async (step: string, send: () => Promise) => { + const receipt = await expectSponsored(client, target.publicClient, send, { confirmations: target.confirmations }); + log(step, `user operation ${receipt.userOpHash}, transaction ${link(receipt.receipt.transactionHash)}`); + return receipt; + }; + + const waitFor = async (step: string, hash: Hex) => { + const { status } = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); + log(step, `transaction ${link(hash)}`); + expect(status).toBe("success"); + }; + + const balanceOf = (tokenAddress: Address, holder: Address) => + target.publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); +}); diff --git a/tests/consumer/userFlow.fork.test.ts b/tests/consumer/userFlow.fork.test.ts index d857249..54154f4 100644 --- a/tests/consumer/userFlow.fork.test.ts +++ b/tests/consumer/userFlow.fork.test.ts @@ -1,200 +1,45 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { createWalletClient, erc20Abi, http, parseEther, stringToHex, type Address, type Hex } from "viem"; -import { createBundlerClient } from "viem/account-abstraction"; +import { vi } from "vitest"; +import { createWalletClient, http, parseEther, type Hex } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { baseSepolia } from "viem/chains"; const passkeyGet = vi.hoisted(() => vi.fn()); vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); -import { ContractRevertError, Kokio } from "kokio-sdk"; import { KokioAdmin } from "kokio-sdk/admin"; -import { ESIMWallet, ESIMWalletFactory } from "kokio-sdk/abis"; -import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; import { impersonateRegistryOwner } from "../utils/forkChain.js"; -import { createSoftSigner } from "../utils/softP256Signer.js"; -import { asPasskey } from "./fixtures/passkeyAuthenticator.js"; -import { expectSponsored } from "./fixtures/sponsorship.js"; -import { startForkStack, type ForkStack } from "./fixtures/forkStack.js"; +import { describeUserFlow } from "./flows/userFlow.js"; +import { startForkStack } from "./fixtures/forkStack.js"; import { setTokenBalance } from "./fixtures/tokens.js"; -import { CREDENTIAL_ID, FORK_POLICY_ID, RP_ID, TEST_TAG, testBytes32, testDeviceId } from "./fixtures/testLabels.js"; - -const USDC = stringToHex("USDC", { size: 32 }); -const BUNDLE = { id: testBytes32("flow-bundle"), priceUSDCents: 500n, settlement: Settlement.DeviceWallet }; -const E_SIM_SALT = 1n; - -// Signup to an eSIM identifier onchain, in the order an app and its backend run -// it, with the backend's side done through KokioAdmin. Later steps build on earlier ones. -describe("documented user flow on a Base Sepolia fork", () => { - let stack: ForkStack; - let admin: KokioAdmin; - - // What the backend stores against the user at signup. - const db = { - uid: testDeviceId(), - salt: BigInt(Date.now()), - ownerKey: undefined as unknown as readonly [Hex, Hex], - deviceWallet: undefined as unknown as Address, - eSIMWallet: undefined as unknown as Address, - }; - const signer = createSoftSigner(RP_ID); - db.ownerKey = signer.ownerKey; - - let session: Kokio; - let client: KokioSmartAccountClient; - - beforeAll(async () => { - stack = await startForkStack(); - passkeyGet.mockImplementation(asPasskey(signer)); - - // The backend signs with its own private key, as it would against a hosted RPC. - const account = privateKeyToAccount(generatePrivateKey()); - await stack.fork.testClient.setBalance({ address: account.address, value: parseEther("1") }); - admin = new KokioAdmin(createWalletClient({ account, chain: baseSepolia, transport: http(stack.fork.rpcUrl) })); - - const { client: owner } = await impersonateRegistryOwner(stack.fork); - await waitFor(await new KokioAdmin(owner).registry.requestAdminUpdate(account.address)); - await waitFor(await admin.registry.acceptAdminUpdate()); - }, 180_000); - - afterAll(async () => { - await stack?.stop(); - }); - - it("0. the backend works out the device wallet address at signup", async () => { - db.deviceWallet = await admin.deviceWalletFactory.getCounterFactualAddress(db.ownerKey, db.uid, db.salt); - - expect(await stack.fork.publicClient.getCode({ address: db.deviceWallet })).toBeUndefined(); - }, 60_000); - - it("1. the app resolves the same smart account from the passkey", async () => { - const walletClient = createWalletClient({ chain: baseSepolia, transport: http(stack.fork.rpcUrl) }); - const kokio = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, "unused-on-fork", FORK_POLICY_ID); - - const account = await kokio.smartAccount.getSmartWallet(db.uid, db.ownerKey, db.salt); - expect(account.address).toBe(db.deviceWallet); - - client = await kokio.smartAccount.getSmartWalletClient(account, { bundlerUrl: stack.bundlerUrl }); - session = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, "unused-on-fork", FORK_POLICY_ID, client, account.address); - }, 60_000); - - it("2. the app deploys the device wallet with a sponsored user operation", async () => { - await expectSponsored(client, stack.fork.publicClient, () => session.deviceWallet!.sendUserOperation([])); - - expect(await stack.fork.publicClient.getCode({ address: db.deviceWallet })).toMatch(/^0x[0-9a-f]+$/i); - expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(false); - }, 120_000); - - it("3. the backend registers the device wallet from its own records", async () => { - await waitFor(await admin.deviceWalletFactory.postCreateAccount(db.deviceWallet, db.uid, db.ownerKey, db.salt)); - - expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(true); - }, 60_000); - - let bindUserOp: Hex; - - it("4. the app deploys and binds an eSIM wallet without granting fund access", async () => { - await expectSponsored(client, stack.fork.publicClient, async () => { - const result = await session.deviceWallet!.deployAndBindESIMWallet(E_SIM_SALT, { grantAccessToFunds: false }); - db.eSIMWallet = result.eSIMWalletAddress; - return (bindUserOp = result.userOpHash); - }); - session.setESIMWalletAddress(db.eSIMWallet); - - expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); - }, 120_000); - - it("5. the backend verifies the eSIM wallet belongs to this user's device wallet", async () => { - // The app sends only { eSIMWallet, eSIMSalt, userOpHash }. The device wallet - // comes from the backend's own records. - const bundler = createBundlerClient({ transport: http(stack.bundlerUrl) }); - const receipt = await bundler.waitForUserOperationReceipt({ hash: bindUserOp }); - expect(receipt.success).toBe(true); - expect(receipt.sender).toBe(db.deviceWallet); - - expect(await admin.registry.isESIMWalletValid(db.eSIMWallet)).toBe(db.deviceWallet); - - admin.setDeviceWalletAddress(db.deviceWallet).setESIMWalletAddress(db.eSIMWallet); - expect(await admin.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(true); - expect(await admin.eSIMWallet!.owner()).toBe(db.deviceWallet); - - const { factoryAddresses } = await admin.constants; - const expected = await stack.fork.publicClient.readContract({ - address: factoryAddresses.ESIM_WALLET_FACTORY as Address, - abi: ESIMWalletFactory, - functionName: "getCounterFactualAddress", - args: [db.deviceWallet, E_SIM_SALT], - }); - expect(expected).toBe(db.eSIMWallet); - }, 120_000); - - const REF_1 = testBytes32("flow-order-1"); - let usdc: Address; - let quote: bigint; - let purchaseBlock: bigint; - - it("6. the app buys the bundle, the device wallet sending the tokens in the same operation", async () => { - usdc = (await session.paymentAdapter!.resolveAsset(USDC)).token; - quote = await session.paymentAdapter!.quote(USDC, BUNDLE.priceUSDCents); - await setTokenBalance(stack.fork, usdc, db.deviceWallet, quote * 2n); - - const receipt = await expectSponsored(client, stack.fork.publicClient, () => - session.eSIMWallet!.buyDataBundleWithTransfer(BUNDLE, USDC, quote, REF_1)); - purchaseBlock = receipt.receipt.blockNumber; - - expect(await balanceOf(usdc, db.deviceWallet)).toBe(quote); - expect(await balanceOf(usdc, db.eSIMWallet)).toBe(0n); - expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); - }, 120_000); - - it("7. the backend finds the payment by its reference", async () => { - const [event] = await stack.fork.publicClient.getContractEvents({ - address: db.eSIMWallet, - abi: ESIMWallet, - eventName: "DataBundleBoughtWithToken", - args: { _paymentReference: REF_1 }, - // A real backend starts from its last scanned block; hosted RPCs cap the range. - fromBlock: purchaseBlock, - }); - - expect(event.args).toMatchObject({ - _dataBundleID: BUNDLE.id, - _priceUSDCents: BUNDLE.priceUSDCents, - _asset: USDC, - _token: usdc, - _amountSpent: quote, - }); - }, 60_000); - - const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; - - it("8. the backend records the eSIM's identifier, once", async () => { - await waitFor(await admin.registry.assignESIMIdentifier(db.eSIMWallet, E_SIM_ID)); - - expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); - - const again = await admin.registry.assignESIMIdentifier(db.eSIMWallet, `${E_SIM_ID}-2`).catch((e: unknown) => e); - expect(again).toBeInstanceOf(ContractRevertError); - expect((again as ContractRevertError).decoded?.errorName).toBe("ESIMIdentifierAlreadySet"); - }, 60_000); - - it("a top-up repeats steps 6 and 7 on the same eSIM wallet", async () => { - const REF_2 = testBytes32("flow-order-2"); - - await expectSponsored(client, stack.fork.publicClient, () => - session.eSIMWallet!.buyDataBundleWithTransfer(BUNDLE, USDC, quote, REF_2)); - - expect(await balanceOf(usdc, db.deviceWallet)).toBe(0n); - expect((await session.eSIMWallet!.transactionHistory(1n)).id).toBe(BUNDLE.id); - expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); - }, 120_000); - - const waitFor = async (hash: Hex) => { - const { status } = await stack.fork.publicClient.waitForTransactionReceipt({ hash }); - expect(status).toBe("success"); +import { FORK_POLICY_ID } from "./fixtures/testLabels.js"; + +describeUserFlow("user flow on a Base Sepolia fork", async () => { + const stack = await startForkStack(); + const waitFor = async (hash: Hex) => { await stack.fork.publicClient.waitForTransactionReceipt({ hash }); }; + + // The backend signs with its own private key, as it would against a hosted RPC. + // The registry owner hands it the admin role first. + const account = privateKeyToAccount(generatePrivateKey()); + await stack.fork.testClient.setBalance({ address: account.address, value: parseEther("1") }); + const admin = new KokioAdmin(createWalletClient({ account, chain: baseSepolia, transport: http(stack.fork.rpcUrl) })); + + const { client: owner } = await impersonateRegistryOwner(stack.fork); + await waitFor(await new KokioAdmin(owner).registry.requestAdminUpdate(account.address)); + await waitFor(await admin.registry.acceptAdminUpdate()); + + return { + rpcUrl: stack.fork.rpcUrl, + publicClient: stack.fork.publicClient, + pimlicoAPIKey: "unused-on-fork", + policyId: FORK_POLICY_ID, + bundlerUrl: stack.bundlerUrl, + receiptUrl: stack.bundlerUrl, + admin, + fund: (token, to, amount) => setTokenBalance(stack.fork, token, to, amount), + asset: "USDC", + priceUSDCents: 500n, + confirmations: 1, + stop: stack.stop, }; - - const balanceOf = (token: Address, holder: Address) => - stack.fork.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); -}); +}, passkeyGet, { timeout: 120_000 }); diff --git a/tests/consumer/userFlow.live.test.ts b/tests/consumer/userFlow.live.test.ts new file mode 100644 index 0000000..091f7c2 --- /dev/null +++ b/tests/consumer/userFlow.live.test.ts @@ -0,0 +1,31 @@ +import { vi } from "vitest"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { describeUserFlow } from "./flows/userFlow.js"; +import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; + +// The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and +// the registry's real eSIM wallet admin as the backend. Sends real testnet +// transactions: the admin pays gas for three and sends 2 USDCt to the new device +// wallet. Run with `npm run test:consumer:live`. +describeUserFlow("user flow on Base Sepolia with Pimlico", async () => { + const live = await startLiveStack(); + + return { + rpcUrl: live.target.rpcUrl, + publicClient: live.publicClient, + pimlicoAPIKey: live.target.pimlicoAPIKey, + policyId: live.target.policyId, + receiptUrl: `https://api.pimlico.io/v2/84532/rpc?apikey=${live.target.pimlicoAPIKey}`, + admin: live.admin, + fund: (token, to, amount) => fundFromAdmin(live, token, to, amount), + // The internal test token, so live runs never spend Circle USDC. + asset: "USDCt", + priceUSDCents: 100n, + // Hosted RPCs can answer from a node a block behind, so let each write settle. + confirmations: 3, + explorerTx: "https://sepolia.basescan.org/tx/", + }; +}, passkeyGet, { timeout: 180_000 }); From 12cef3d6f4ed81492da55d7739b248a9138bdba0 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 19 Sep 2026 14:00:48 +0530 Subject: [PATCH 3/4] Buy with each accepted asset in the user flow, Circle USDC included on live --- tests/consumer/flows/userFlow.ts | 30 ++++++++++++++++++++++++---- tests/consumer/userFlow.fork.test.ts | 3 +-- tests/consumer/userFlow.live.test.ts | 9 ++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index aaf6dda..5e3a37b 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -26,8 +26,6 @@ export interface FlowTarget { admin: KokioAdmin; /** Puts `amount` of `token` in the user's device wallet. */ fund: (token: Address, to: Address, amount: bigint) => Promise; - /** Asset symbol to pay with. */ - asset: string; priceUSDCents: bigint; /** Blocks to wait after each write before reading its result. */ confirmations: number; @@ -44,7 +42,9 @@ export const describeUserFlow = ( name: string, setup: () => Promise, passkeyGet: Mock, - { timeout }: { timeout: number }, + // The first asset pays for steps 6 and the top-up. Each other one gets its own + // purchase at the end, since the payment adapter accepts several. + { timeout, assets: [primaryAsset, ...otherAssets] }: { timeout: number; assets: [string, ...string[]] }, ) => describe(name, () => { let target: FlowTarget; let admin: KokioAdmin; @@ -67,7 +67,7 @@ export const describeUserFlow = ( beforeAll(async () => { target = await setup(); admin = target.admin; - asset = stringToHex(target.asset, { size: 32 }); + asset = stringToHex(primaryAsset, { size: 32 }); bundle = { id: testBytes32("flow-bundle"), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; passkeyGet.mockImplementation(asPasskey(signer)); }, 180_000); @@ -211,6 +211,28 @@ export const describeUserFlow = ( expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); }, timeout); + otherAssets.forEach((symbol, i) => { + it(`a purchase can also pay with ${symbol}`, async () => { + const other = stringToHex(symbol, { size: 32 }); + const otherToken = (await session.paymentAdapter!.resolveAsset(other)).token; + expect(otherToken).not.toBe(token); + const otherQuote = await session.paymentAdapter!.quote(other, bundle.priceUSDCents); + await target.fund(otherToken, db.deviceWallet, otherQuote); + const ref = testBytes32(`fa${i}-${Date.now()}`); + + const receipt = await sponsored(symbol, () => + session.eSIMWallet!.buyDataBundleWithTransfer(bundle, other, otherQuote, ref)); + + expect(await balanceOf(otherToken, db.deviceWallet)).toBe(0n); + expect((await session.eSIMWallet!.transactionHistory(2n + BigInt(i))).id).toBe(bundle.id); + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, + }); + expect(event.args).toMatchObject({ _asset: other, _token: otherToken, _amountSpent: otherQuote }); + }, timeout); + }); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); diff --git a/tests/consumer/userFlow.fork.test.ts b/tests/consumer/userFlow.fork.test.ts index 54154f4..40d5aab 100644 --- a/tests/consumer/userFlow.fork.test.ts +++ b/tests/consumer/userFlow.fork.test.ts @@ -37,9 +37,8 @@ describeUserFlow("user flow on a Base Sepolia fork", async () => { receiptUrl: stack.bundlerUrl, admin, fund: (token, to, amount) => setTokenBalance(stack.fork, token, to, amount), - asset: "USDC", priceUSDCents: 500n, confirmations: 1, stop: stack.stop, }; -}, passkeyGet, { timeout: 120_000 }); +}, passkeyGet, { timeout: 120_000, assets: ["USDC", "USDCt"] }); diff --git a/tests/consumer/userFlow.live.test.ts b/tests/consumer/userFlow.live.test.ts index 091f7c2..9e47e35 100644 --- a/tests/consumer/userFlow.live.test.ts +++ b/tests/consumer/userFlow.live.test.ts @@ -8,8 +8,8 @@ import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; // The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and // the registry's real eSIM wallet admin as the backend. Sends real testnet -// transactions: the admin pays gas for three and sends 2 USDCt to the new device -// wallet. Run with `npm run test:consumer:live`. +// transactions: the admin pays gas for four and sends 2 USDCt and 1 USDC to the +// new device wallet. Run with `npm run test:consumer:live`. describeUserFlow("user flow on Base Sepolia with Pimlico", async () => { const live = await startLiveStack(); @@ -21,11 +21,10 @@ describeUserFlow("user flow on Base Sepolia with Pimlico", async () => { receiptUrl: `https://api.pimlico.io/v2/84532/rpc?apikey=${live.target.pimlicoAPIKey}`, admin: live.admin, fund: (token, to, amount) => fundFromAdmin(live, token, to, amount), - // The internal test token, so live runs never spend Circle USDC. - asset: "USDCt", priceUSDCents: 100n, // Hosted RPCs can answer from a node a block behind, so let each write settle. confirmations: 3, explorerTx: "https://sepolia.basescan.org/tx/", }; -}, passkeyGet, { timeout: 180_000 }); + // Mostly the internal test token; one purchase in Circle's Base Sepolia USDC. +}, passkeyGet, { timeout: 180_000, assets: ["USDCt", "USDC"] }); From 3dd416c6f1ed8efba67cb4abca4bf9c7ae0fd579 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 19 Sep 2026 14:22:14 +0530 Subject: [PATCH 4/4] Record external wallet and card purchases in the user flow The backend records them with recordSettledPurchase. The steps check the event, the history entry and the spent reference, that no tokens moved, and that the contract refuses the cases it should. --- tests/consumer/flows/userFlow.ts | 83 ++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index 5e3a37b..c8d4adc 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -1,10 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; -import { createWalletClient, erc20Abi, http, stringToHex, type Address, type Hex, type PublicClient } from "viem"; +import { + createWalletClient, encodeAbiParameters, erc20Abi, http, keccak256, stringToHex, + type Address, type Hex, type PublicClient, +} from "viem"; import { createBundlerClient } from "viem/account-abstraction"; import { baseSepolia } from "viem/chains"; import { ContractRevertError, Kokio } from "kokio-sdk"; import type { KokioAdmin } from "kokio-sdk/admin"; -import { ESIMWallet, ESIMWalletFactory } from "kokio-sdk/abis"; +import { ESIMWallet, ESIMWalletFactory, Registry } from "kokio-sdk/abis"; import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; import { createSoftSigner } from "../../utils/softP256Signer.js"; @@ -233,6 +236,65 @@ export const describeUserFlow = ( }, timeout); }); + // Paid outside the protocol, from an external wallet or by card. The backend + // confirms the payment offchain, then records it. No money moves onchain. + const recorded = [ + { label: "from an external wallet", symbol: "USDC", settlement: Settlement.ExternalWallet }, + { label: "by card", symbol: "USD", settlement: Settlement.Fiat }, + ]; + // Everything bought above comes first in the eSIM wallet's history. + const historyBefore = 2n + BigInt(otherAssets.length); + + recorded.forEach(({ label, symbol, settlement }, i) => { + it(`a top-up paid ${label} is recorded by the backend`, async () => { + const recordedAsset = stringToHex(symbol, { size: 32 }); + const { token: recordedToken } = await admin.paymentAdapter.resolveAsset(recordedAsset); + const tokenAmount = await admin.paymentAdapter.quote(recordedAsset, bundle.priceUSDCents); + const ref = testBytes32(`ro${i}-${Date.now()}`); + const details = { ...bundle, settlement }; + + const vault = await admin.registry.vault(); + const balances = () => Promise.all([db.deviceWallet, db.eSIMWallet, vault].map((holder) => balanceOf(token, holder))); + const before = await balances(); + + const receipt = await waitFor(label, await admin.registry.recordSettledPurchase(db.eSIMWallet, details, recordedAsset, tokenAmount, ref)); + + const [event] = await target.publicClient.getContractEvents({ + address: (await admin.constants).factoryAddresses.REGISTRY as Address, + abi: Registry, + eventName: "DataBundleSettled", + args: { _eSIMWallet: db.eSIMWallet, _paymentReference: ref }, + fromBlock: receipt.blockNumber, + }); + expect(event.args).toMatchObject({ + _dataBundleID: bundle.id, + _priceUSDCents: bundle.priceUSDCents, + _settlement: settlement, + _asset: recordedAsset, + _token: recordedToken, + _tokenAmount: tokenAmount, + }); + expect(await session.eSIMWallet!.transactionHistory(historyBefore + BigInt(i))).toMatchObject({ id: bundle.id, settlement }); + expect(await admin.registry.usedPaymentReferences(scoped(ref))).toBe(true); + // Nothing moved: the payment happened outside the protocol. + expect(await balances()).toEqual(before); + }, timeout); + }); + + it("the backend cannot record a purchase the contract refuses", async () => { + const usd = stringToHex("USD", { size: 32 }); + const fiat = { ...bundle, settlement: Settlement.Fiat }; + const cap = await session.eSIMWallet!.priceCapUSDCents(); + const record = (details: typeof bundle, symbol: Hex, ref: Hex) => + revertOf(admin.registry.recordSettledPurchase(db.eSIMWallet, details, symbol, 0n, ref)); + + // Refused before sending, so none of these costs anything even on a live chain. + expect(await record(bundle, usd, testBytes32(`rx0-${Date.now()}`))).toBe("SettlementNotAsserted"); + expect(await record(fiat, usd, REF_1)).toBe("PaymentReferenceAlreadyUsed"); + expect(await record({ ...fiat, priceUSDCents: cap + 1n }, usd, testBytes32(`rx1-${Date.now()}`))).toBe("DataBundlePriceAboveCap"); + expect(await record(fiat, testBytes32("coin"), testBytes32(`rx2-${Date.now()}`))).toBe("AssetNotAllowed"); + }, timeout); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); @@ -243,9 +305,22 @@ export const describeUserFlow = ( }; const waitFor = async (step: string, hash: Hex) => { - const { status } = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); + const receipt = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); log(step, `transaction ${link(hash)}`); - expect(status).toBe("success"); + expect(receipt.status).toBe("success"); + return receipt; + }; + + // Registry.usedPaymentReferences is keyed per eSIM wallet. + const scoped = (ref: Hex) => + keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes32" }], [db.eSIMWallet, ref])); + + // The custom error name a refused write carries, as the SDK reports it. + const revertOf = async (pending: Promise): Promise => { + const err = await pending.then(() => undefined, (e: unknown) => e); + if (err === undefined) return "did not revert"; + if (!(err instanceof ContractRevertError)) throw err; + return err.decoded?.errorName ?? `undecoded ${err.data}`; }; const balanceOf = (tokenAddress: Address, holder: Address) =>