From 53f8edbdea670fbeb707d16d9087c490f67020ec Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:16:34 +0530 Subject: [PATCH 01/10] Move the user flow's fork setup into a shared fixture --- tests/consumer/fixtures/forkFlowTarget.ts | 41 +++++++++++++++++++++++ tests/consumer/userFlow.fork.test.ts | 39 ++------------------- 2 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 tests/consumer/fixtures/forkFlowTarget.ts diff --git a/tests/consumer/fixtures/forkFlowTarget.ts b/tests/consumer/fixtures/forkFlowTarget.ts new file mode 100644 index 0000000..b86ac89 --- /dev/null +++ b/tests/consumer/fixtures/forkFlowTarget.ts @@ -0,0 +1,41 @@ +import { createWalletClient, http, parseEther, type Hex } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import { KokioAdmin } from "kokio-sdk/admin"; + +import { impersonateRegistryOwner } from "../../utils/forkChain.js"; +import type { FlowTarget } from "../flows/userFlow.js"; +import { startForkStack } from "./forkStack.js"; +import { setTokenBalance } from "./tokens.js"; +import { FORK_POLICY_ID } from "./testLabels.js"; + +// A Base Sepolia fork with a local bundler and paymaster, and a backend that +// signs with its own private key as the registry's eSIM wallet admin. +export const startForkFlowTarget = async (): Promise => { + 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), + priceUSDCents: 500n, + confirmations: 1, + stop: stack.stop, + }; +}; diff --git a/tests/consumer/userFlow.fork.test.ts b/tests/consumer/userFlow.fork.test.ts index 40d5aab..b8b74f4 100644 --- a/tests/consumer/userFlow.fork.test.ts +++ b/tests/consumer/userFlow.fork.test.ts @@ -1,44 +1,9 @@ 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 { KokioAdmin } from "kokio-sdk/admin"; - -import { impersonateRegistryOwner } from "../utils/forkChain.js"; import { describeUserFlow } from "./flows/userFlow.js"; -import { startForkStack } from "./fixtures/forkStack.js"; -import { setTokenBalance } from "./fixtures/tokens.js"; -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()); +import { startForkFlowTarget } from "./fixtures/forkFlowTarget.js"; - 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), - priceUSDCents: 500n, - confirmations: 1, - stop: stack.stop, - }; -}, passkeyGet, { timeout: 120_000, assets: ["USDC", "USDCt"] }); +describeUserFlow("user flow on a Base Sepolia fork", startForkFlowTarget, passkeyGet, { timeout: 120_000, assets: ["USDC", "USDCt"] }); From a8f02dfe1387b050b2690703fe237e4936a1d7f8 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:19:08 +0530 Subject: [PATCH 02/10] Test a lazy deploy with 88 recorded purchases across 20 eSIMs on a fork --- tests/consumer/flows/lazyWalletDeployFlow.ts | 200 +++++++++++++++++++ tests/consumer/lazyWalletDeploy.fork.test.ts | 9 + 2 files changed, 209 insertions(+) create mode 100644 tests/consumer/flows/lazyWalletDeployFlow.ts create mode 100644 tests/consumer/lazyWalletDeploy.fork.test.ts diff --git a/tests/consumer/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts new file mode 100644 index 0000000..b61cadd --- /dev/null +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; +import { erc20Abi, stringToHex, type Address, type Hex } from "viem"; +import { ContractRevertError } from "kokio-sdk"; +import type { KokioAdmin } from "kokio-sdk/admin"; +import { ESIMWallet } from "kokio-sdk/abis"; +import { Settlement, type DataBundleDetails } from "kokio-sdk/types"; + +import type { FlowTarget } from "./userFlow.js"; +import { expectSponsored } from "../fixtures/sponsorship.js"; +import { createTestUser, type TestUser } from "../fixtures/user.js"; +import { testBytes32 } from "../fixtures/testLabels.js"; + +// Two eSIMs with more history than one copy call takes by default (25), and 18 +// small ones, so the 20 wallets also need more than one deploy call (10 each). +const PURCHASES_PER_ESIM = [26, 26, ...Array.from({ length: 18 }, (_, i) => (i % 3) + 1)]; +const TOTAL_PURCHASES = PURCHASES_PER_ESIM.reduce((sum, n) => sum + n, 0); +const PURCHASES_PER_RECORDING = 10; + +// A user who bought every bundle by card or from an external wallet before +// installing the app, so all of it sits in the lazy wallet registry. The backend +// deploys their wallets and copies the history in when they sign up. +export const describeLazyWalletDeployFlow = ( + name: string, + setup: () => Promise, + passkeyGet: Mock, + { timeout }: { timeout: number }, +) => describe(name, () => { + let target: FlowTarget; + let admin: KokioAdmin; + let user: TestUser; + let eSIMIds: string[]; + // Each eSIM's purchases, in the order they were bought. + let history: DataBundleDetails[][]; + let eSIMWallets: readonly Address[]; + + beforeAll(async () => { + target = await setup(); + admin = target.admin; + // Only a passkey and a counterfactual address so far. Nothing is onchain. + user = await createTestUser(target, passkeyGet); + + eSIMIds = PURCHASES_PER_ESIM.map((_, e) => `${user.uid}-e${e}`); + history = PURCHASES_PER_ESIM.map((count, e) => Array.from({ length: count }, (_, n) => ({ + id: testBytes32(`lz${e}-${n}`), + priceUSDCents: BigInt(100 + ((e * 7 + n * 13) % 900)), + settlement: (e + n) % 2 === 0 ? Settlement.Fiat : Settlement.ExternalWallet, + }))); + }, 180_000); + + afterAll(async () => { + console.log(`lazy device wallet ${user?.deviceWallet}, ${eSIMWallets?.length ?? 0} eSIM wallets`); + await target?.stop?.(); + }); + + it(`the backend records ${TOTAL_PURCHASES} card and external wallet purchases as they come in`, async () => { + // Round robin over the eSIMs, so one eSIM's purchases span several recordings + // and their order has to survive across transactions. + const arrivals: { eSIMId: string; details: DataBundleDetails }[] = []; + for (let round = 0; round < Math.max(...PURCHASES_PER_ESIM); round++) { + history.forEach((purchases, e) => { + if (round < purchases.length) arrivals.push({ eSIMId: eSIMIds[e], details: purchases[round] }); + }); + } + expect(arrivals).toHaveLength(TOTAL_PURCHASES); + + for (let start = 0; start < arrivals.length; start += PURCHASES_PER_RECORDING) { + const chunk = arrivals.slice(start, start + PURCHASES_PER_RECORDING); + await waitFor(await admin.lazyWalletRegistry.batchPopulateHistory( + [user.uid], [chunk.map((a) => a.eSIMId)], [chunk.map((a) => a.details)], + )); + } + + expect(await admin.lazyWalletRegistry.isDeviceIdentifierReserved(user.uid)).toBe(true); + expect(await admin.registry.isDeviceIdentifierAlreadyUsed(user.uid)).toBe(false); + for (const [e, eSIMId] of eSIMIds.entries()) { + // Listed in the order the device first bought each eSIM. + expect(await admin.lazyWalletRegistry.eSIMIdentifiersAssociatedWithDeviceIdentifier(user.uid, BigInt(e))).toBe(eSIMId); + expect(await admin.lazyWalletRegistry.outstandingHistoryEntries(eSIMId)).toBe(BigInt(PURCHASES_PER_ESIM[e])); + } + }, timeout); + + it("the app's passkey resolves to the address the backend will deploy", async () => { + // The app resolved its account from the passkey, the device id and the salt the backend stores. + const counterfactual = await admin.deviceWalletFactory.getCounterFactualAddress(user.signer.ownerKey, user.uid, user.salt); + expect(user.deviceWallet).toBe(counterfactual); + expect(await target.publicClient.getCode({ address: counterfactual })).toBeUndefined(); + }, timeout); + + it("the backend deploys the device wallet and all 20 eSIM wallets", async () => { + const deployment = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(user.signer.ownerKey, user.uid, user.salt, 0n); + eSIMWallets = deployment.eSIMWallets; + + expect(deployment.alreadyComplete).toBe(false); + expect(deployment.batches.map((batch) => batch.remaining)).toEqual([10n, 0n]); + expect(deployment.eSIMIdentifiers).toEqual(eSIMIds); + expect(eSIMWallets).toHaveLength(eSIMIds.length); + expect(new Set(eSIMWallets).size).toBe(eSIMWallets.length); + + const deviceWallet = user.deviceWallet; + expect(deployment.deviceWallet).toBe(deviceWallet); + expect(await target.publicClient.getCode({ address: deviceWallet })).toMatch(/^0x[0-9a-f]+$/i); + expect(await admin.registry.isDeviceWalletValid(deviceWallet)).toBe(true); + expect(await admin.registry.uniqueIdentifierToDeviceWallet(user.uid)).toBe(deviceWallet); + expect(await admin.lazyWalletRegistry.eSIMWalletsDeployed(user.uid)).toBe(BigInt(eSIMIds.length)); + + admin.setDeviceWalletAddress(deviceWallet); + for (const [e, wallet] of eSIMWallets.entries()) { + expect(await target.publicClient.getCode({ address: wallet })).toMatch(/^0x[0-9a-f]+$/i); + expect(await readESIMWallet(wallet, "owner")).toBe(deviceWallet); + expect(await readESIMWallet(wallet, "eSIMUniqueIdentifier")).toBe(eSIMIds[e]); + expect(await admin.registry.isESIMWalletValid(wallet)).toBe(deviceWallet); + expect(await admin.registry.eSIMWalletForIdentifier(eSIMIds[e])).toBe(wallet); + expect(await admin.lazyWalletRegistry.lazyDeployedESIMWallet(eSIMIds[e])).toBe(wallet); + expect(await admin.deviceWallet!.isValidESIMWallet(wallet)).toBe(true); + } + }, timeout); + + it("a new purchase is refused until the eSIM's history is copied in", async () => { + const usd = stringToHex("USD", { size: 32 }); + const err = await admin.registry.recordSettledPurchase( + eSIMWallets[0], + { id: testBytes32("lz-early"), priceUSDCents: target.priceUSDCents, settlement: Settlement.Fiat }, + usd, target.priceUSDCents, testBytes32(`lze-${Date.now()}`), + ).then(() => undefined, (e: unknown) => e); + + // Refused before sending, so this costs nothing. + expect(err).toBeInstanceOf(ContractRevertError); + expect((err as ContractRevertError).decoded?.errorName).toBe("HistoryNotFullyCopied"); + }, timeout); + + it("the backend copies every eSIM's history onto its wallet", async () => { + for (const [e, eSIMId] of eSIMIds.entries()) { + const copy = await admin.lazyWalletRegistry.setHistoryForLazyWallet(eSIMId); + const count = BigInt(PURCHASES_PER_ESIM[e]); + + expect(copy.eSIMWallet).toBe(eSIMWallets[e]); + expect(copy.copied).toBe(count); + expect(copy.batches.map((batch) => batch.copied)).toEqual(count > 25n ? [25n, count - 25n] : [count]); + expect(await admin.lazyWalletRegistry.historyEntriesCopied(eSIMId)).toBe(count); + expect(await admin.lazyWalletRegistry.outstandingHistoryEntries(eSIMId)).toBe(0n); + } + }, timeout); + + it(`each eSIM wallet holds exactly its own purchases, in order, ${TOTAL_PURCHASES} in all`, async () => { + for (const [e, wallet] of eSIMWallets.entries()) { + for (const [n, expected] of history[e].entries()) { + expect(await readHistory(wallet, BigInt(n)), `eSIM ${e} entry ${n}`).toEqual(expected); + } + // Nothing past the end: the contract has no length getter, so this is how to count. + await expect(readHistory(wallet, BigInt(history[e].length))).rejects.toThrow(); + } + }, timeout); + + it("running the deploy and the copy again sends nothing", async () => { + const deployAgain = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(user.signer.ownerKey, user.uid, user.salt, 0n); + expect(deployAgain).toMatchObject({ deviceWallet: user.deviceWallet, alreadyComplete: true, batches: [] }); + + const copyAgain = await admin.lazyWalletRegistry.setHistoryForLazyWallet(eSIMIds[0]); + expect(copyAgain).toMatchObject({ eSIMWallet: eSIMWallets[0], copied: 0n, alreadyComplete: true }); + }, timeout); + + it("the user's passkey buys a new bundle on the lazily deployed wallet", async () => { + const asset = stringToHex("USDC", { size: 32 }); + const bundle = { id: testBytes32("lz-after"), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; + const ref = testBytes32(`lza-${Date.now()}`); + + user.kokio.setESIMWalletAddress(eSIMWallets[0]); + const { token } = await user.kokio.paymentAdapter!.resolveAsset(asset); + const quote = await user.kokio.paymentAdapter!.quote(asset, bundle.priceUSDCents); + await target.fund(token, user.deviceWallet, quote); + + const receipt = await expectSponsored(user.client, target.publicClient, + () => user.kokio.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, ref), { confirmations: target.confirmations }); + + const [event] = await target.publicClient.getContractEvents({ + address: eSIMWallets[0], abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, + }); + expect(event.args).toMatchObject({ _dataBundleID: bundle.id, _token: token, _amountSpent: quote }); + // Lands after the copied history, not in front of it. + expect(await readHistory(eSIMWallets[0], BigInt(PURCHASES_PER_ESIM[0]))).toEqual(bundle); + expect(await target.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [user.deviceWallet] })).toBe(0n); + }, timeout); + + const waitFor = async (hash: Hex) => { + const receipt = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); + expect(receipt.status).toBe("success"); + return receipt; + }; + + const readESIMWallet = (address: Address, functionName: "owner" | "eSIMUniqueIdentifier") => + target.publicClient.readContract({ address, abi: ESIMWallet, functionName }); + + const readHistory = async (address: Address, index: bigint): Promise => { + const [id, priceUSDCents, settlement] = await target.publicClient.readContract({ + address, abi: ESIMWallet, functionName: "transactionHistory", args: [index], + }); + return { id, priceUSDCents, settlement }; + }; +}); diff --git a/tests/consumer/lazyWalletDeploy.fork.test.ts b/tests/consumer/lazyWalletDeploy.fork.test.ts new file mode 100644 index 0000000..b5f4243 --- /dev/null +++ b/tests/consumer/lazyWalletDeploy.fork.test.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { describeLazyWalletDeployFlow } from "./flows/lazyWalletDeployFlow.js"; +import { startForkFlowTarget } from "./fixtures/forkFlowTarget.js"; + +describeLazyWalletDeployFlow("lazy wallet deploy on a Base Sepolia fork", startForkFlowTarget, passkeyGet, { timeout: 300_000 }); From 5c593e356b16bac34980c9d5918f62086ff136e4 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:22:03 +0530 Subject: [PATCH 03/10] Buy 2 more eSIMs after the lazy deploy and move two eSIMs to another device One of the moved eSIMs is the lazy one with the longest history, which must travel with it intact. --- tests/consumer/flows/lazyWalletDeployFlow.ts | 144 ++++++++++++++++--- 1 file changed, 125 insertions(+), 19 deletions(-) diff --git a/tests/consumer/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts index b61cadd..c55bf9d 100644 --- a/tests/consumer/flows/lazyWalletDeployFlow.ts +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -6,6 +6,7 @@ import { ESIMWallet } from "kokio-sdk/abis"; import { Settlement, type DataBundleDetails } from "kokio-sdk/types"; import type { FlowTarget } from "./userFlow.js"; +import { asPasskey } from "../fixtures/passkeyAuthenticator.js"; import { expectSponsored } from "../fixtures/sponsorship.js"; import { createTestUser, type TestUser } from "../fixtures/user.js"; import { testBytes32 } from "../fixtures/testLabels.js"; @@ -143,11 +144,7 @@ export const describeLazyWalletDeployFlow = ( it(`each eSIM wallet holds exactly its own purchases, in order, ${TOTAL_PURCHASES} in all`, async () => { for (const [e, wallet] of eSIMWallets.entries()) { - for (const [n, expected] of history[e].entries()) { - expect(await readHistory(wallet, BigInt(n)), `eSIM ${e} entry ${n}`).toEqual(expected); - } - // Nothing past the end: the contract has no length getter, so this is how to count. - await expect(readHistory(wallet, BigInt(history[e].length))).rejects.toThrow(); + expect(await readAllHistory(wallet), `eSIM ${e}`).toEqual(history[e]); } }, timeout); @@ -160,27 +157,136 @@ export const describeLazyWalletDeployFlow = ( }, timeout); it("the user's passkey buys a new bundle on the lazily deployed wallet", async () => { - const asset = stringToHex("USDC", { size: 32 }); - const bundle = { id: testBytes32("lz-after"), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; - const ref = testBytes32(`lza-${Date.now()}`); + const bundle = await buy(user, eSIMWallets[0], "lz-after"); - user.kokio.setESIMWalletAddress(eSIMWallets[0]); - const { token } = await user.kokio.paymentAdapter!.resolveAsset(asset); - const quote = await user.kokio.paymentAdapter!.quote(asset, bundle.priceUSDCents); - await target.fund(token, user.deviceWallet, quote); + // Lands after the copied history, not in front of it. + expect(await readAllHistory(eSIMWallets[0])).toEqual([...history[0], bundle]); + }, timeout); + + const newESIMWallets: Address[] = []; + + it("the device wallet buys 2 more eSIMs, each with a first bundle", async () => { + // Salts the lazy deploy never used: it took the device's own salt and the 19 after it. + for (const salt of [1n, 2n]) { + let eSIMWallet!: Address; + await sponsored(user, async () => { + const result = await user.kokio.deviceWallet!.deployAndBindESIMWallet(salt); + eSIMWallet = result.eSIMWalletAddress; + return result.userOpHash; + }); + expect(eSIMWallets).not.toContain(eSIMWallet); + + const bundle = await buy(user, eSIMWallet, `lz-new-${salt}`); + const eSIMId = `${user.uid}-n${salt}`; + await waitFor(await admin.registry.assignESIMIdentifier(eSIMWallet, eSIMId)); + + expect(await readESIMWallet(eSIMWallet, "eSIMUniqueIdentifier")).toBe(eSIMId); + expect(await admin.registry.isESIMWalletValid(eSIMWallet)).toBe(user.deviceWallet); + expect(await readAllHistory(eSIMWallet)).toEqual([bundle]); + newESIMWallets.push(eSIMWallet); + } + }, timeout); + + let next: TestUser; + + it("another device is set up to take eSIMs over", async () => { + next = await createTestUser(target, passkeyGet); + await sponsored(next, () => next.kokio.deviceWallet!.sendUserOperation([])); + await waitFor(await admin.deviceWalletFactory.postCreateAccount(next.deviceWallet, next.uid, next.signer.ownerKey, next.salt)); + + expect(await admin.registry.isDeviceWalletValid(next.deviceWallet)).toBe(true); + }, timeout); + + // The lazy eSIM with the longest history, and one bought after the deploy. + const moving = () => [eSIMWallets[0], newESIMWallets[0]]; + + it("the lazy eSIM with history and a new eSIM move to the other device, history intact", async () => { + for (const wallet of moving()) { + const [historyBefore, eSIMId] = await Promise.all([readAllHistory(wallet), readESIMWallet(wallet, "eSIMUniqueIdentifier")]); + + as(user).kokio.setESIMWalletAddress(wallet); + await sponsored(user, () => user.kokio.eSIMWallet!.requestTransferOwnership(next.deviceWallet)); + expect(await admin.registry.isESIMWalletOnStandby(wallet)).toBe(true); + + as(next).kokio.setESIMWalletAddress(wallet); + await sponsored(next, () => next.kokio.eSIMWallet!.acceptAndBindESIMWallet()); + + expect(await readESIMWallet(wallet, "owner")).toBe(next.deviceWallet); + expect(await admin.registry.isESIMWalletValid(wallet)).toBe(next.deviceWallet); + expect(await admin.registry.isESIMWalletOnStandby(wallet)).toBe(false); + expect(await next.kokio.deviceWallet!.isValidESIMWallet(wallet)).toBe(true); + expect(await next.kokio.deviceWallet!.canPullFunds(wallet)).toBe(false); + expect(await user.kokio.deviceWallet!.isValidESIMWallet(wallet)).toBe(false); + // The eSIM and everything it bought travel with the wallet. + expect(await readESIMWallet(wallet, "eSIMUniqueIdentifier")).toBe(eSIMId); + expect(await readAllHistory(wallet)).toEqual(historyBefore); + } - const receipt = await expectSponsored(user.client, target.publicClient, - () => user.kokio.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, ref), { confirmations: target.confirmations }); + // Every other eSIM stays with the first device. + for (const wallet of [...eSIMWallets.slice(1), newESIMWallets[1]]) { + expect(await admin.registry.isESIMWalletValid(wallet)).toBe(user.deviceWallet); + } + }, timeout); + + it("the new device buys on the moved eSIMs, and the old device no longer can", async () => { + for (const wallet of moving()) { + const historyBefore = await readAllHistory(wallet); + const bundle = await buy(next, wallet, "lz-moved"); + expect(await readAllHistory(wallet)).toEqual([...historyBefore, bundle]); + + as(user).kokio.setESIMWalletAddress(wallet); + const quote = await user.kokio.paymentAdapter!.quote(USDC, bundle.priceUSDCents); + const err = await user.kokio.eSIMWallet!.buyDataBundleWithToken(bundle, USDC, quote, nextRef()).then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(ContractRevertError); + expect((err as ContractRevertError).decoded?.errorName).toBe("OnlyDeviceWalletOrESIMWalletAdmin"); + } + }, timeout); + + const USDC = stringToHex("USDC", { size: 32 }); + // Payment references are spendable once per eSIM wallet, so each purchase gets its own. + const RUN = Date.now().toString(36); + let refs = 0; + const nextRef = () => testBytes32(`r${++refs}-${RUN}`); + + // Points the mocked passkey at this user, as their own phone would sign. + const as = (who: TestUser) => { + passkeyGet.mockImplementation(asPasskey(who.signer)); + return who; + }; + + const sponsored = (who: TestUser, send: () => Promise) => + expectSponsored(who.client, target.publicClient, send, { confirmations: target.confirmations }); + + // A purchase paid in USDC the device wallet sends over in the same operation. + const buy = async (buyer: TestUser, eSIMWallet: Address, bundleName: string): Promise => { + const bundle = { id: testBytes32(bundleName), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; + const ref = nextRef(); + + as(buyer).kokio.setESIMWalletAddress(eSIMWallet); + const { token } = await buyer.kokio.paymentAdapter!.resolveAsset(USDC); + const quote = await buyer.kokio.paymentAdapter!.quote(USDC, bundle.priceUSDCents); + await target.fund(token, buyer.deviceWallet, quote); + + const receipt = await sponsored(buyer, () => buyer.kokio.eSIMWallet!.buyDataBundleWithTransfer(bundle, USDC, quote, ref)); const [event] = await target.publicClient.getContractEvents({ - address: eSIMWallets[0], abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", + address: eSIMWallet, abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, }); expect(event.args).toMatchObject({ _dataBundleID: bundle.id, _token: token, _amountSpent: quote }); - // Lands after the copied history, not in front of it. - expect(await readHistory(eSIMWallets[0], BigInt(PURCHASES_PER_ESIM[0]))).toEqual(bundle); - expect(await target.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [user.deviceWallet] })).toBe(0n); - }, timeout); + expect(await target.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [buyer.deviceWallet] })).toBe(0n); + return bundle; + }; + + // The contract has no length getter, so read upwards until an index reverts. + const readAllHistory = async (address: Address): Promise => { + const entries: DataBundleDetails[] = []; + for (;;) { + const entry = await readHistory(address, BigInt(entries.length)).catch(() => undefined); + if (!entry) return entries; + entries.push(entry); + } + }; const waitFor = async (hash: Hex) => { const receipt = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); From 80bd23b42fdf4059ea2488f3a2b0353457f861d8 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:22:11 +0530 Subject: [PATCH 04/10] Describe the lazy wallet deploy flow in the tests README --- tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/README.md b/tests/README.md index 1fcad5d..7047070 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,6 +29,7 @@ Prerequisites, environment variables, and the exact commands are documented in The [consumer/](consumer/) folder uses the SDK the way an app or backend would: it imports `kokio-sdk` by package name from the built `dist/`, never from `src/`. Only the passkey prompt is replaced, by a software signer. - `*.fork.test.ts` runs the whole user journey on an anvil fork of Base Sepolia, through a local Alto bundler and mock paymaster: deploy a device wallet, register it, deploy and bind an eSIM wallet, grant and revoke access, buy with `USDC` and `USDCt`, and the refusals. Every user operation must be sponsored. Needs Foundry's `anvil`. +- `lazyWalletDeploy.fork.test.ts` covers a user who bought 88 bundles by card or external wallet before installing the app. The backend deploys the device wallet and 20 eSIM wallets, copies each eSIM's history in, and checks every entry. The user then buys 2 more eSIMs and moves two eSIMs to another device, one of them the lazy eSIM with the longest history. Fork only for now. - `*.live.test.ts` runs the same journey on Base Sepolia with Pimlico. It sends real testnet transactions and reads `BASE_SEPOLIA_RPC_URL`, `PIMLICO_API_SECRET` and `ESIM_WALLET_ADMIN_PK` (the registry's eSIM wallet admin) from `.env`. `PIMLICO_POLICY_ID` is optional. The admin account needs a little ETH and at least 1 USDCt. ```sh From ed78f6ad60f69fea15d469351699bbf7d6056273 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:38:05 +0530 Subject: [PATCH 05/10] Log each step of the lazy wallet flow, and pay in USDCt Each step prints the transactions it sends and the eSIMs and bundles they add or move. USDCt is the settlement token on Base Sepolia. --- tests/consumer/flows/lazyWalletDeployFlow.ts | 141 ++++++++++++++----- 1 file changed, 104 insertions(+), 37 deletions(-) diff --git a/tests/consumer/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts index c55bf9d..de33cd2 100644 --- a/tests/consumer/flows/lazyWalletDeployFlow.ts +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -1,5 +1,5 @@ -import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; -import { erc20Abi, stringToHex, type Address, type Hex } from "viem"; +import { afterAll, afterEach, beforeAll, describe, expect, it, type Mock } from "vitest"; +import { erc20Abi, formatUnits, hexToString, stringToHex, type Address, type Hex } from "viem"; import { ContractRevertError } from "kokio-sdk"; import type { KokioAdmin } from "kokio-sdk/admin"; import { ESIMWallet } from "kokio-sdk/abis"; @@ -9,7 +9,7 @@ import type { FlowTarget } from "./userFlow.js"; import { asPasskey } from "../fixtures/passkeyAuthenticator.js"; import { expectSponsored } from "../fixtures/sponsorship.js"; import { createTestUser, type TestUser } from "../fixtures/user.js"; -import { testBytes32 } from "../fixtures/testLabels.js"; +import { TEST_TAG, testBytes32 } from "../fixtures/testLabels.js"; // Two eSIMs with more history than one copy call takes by default (25), and 18 // small ones, so the 20 wallets also need more than one deploy call (10 each). @@ -39,6 +39,7 @@ export const describeLazyWalletDeployFlow = ( admin = target.admin; // Only a passkey and a counterfactual address so far. Nothing is onchain. user = await createTestUser(target, passkeyGet); + names.set(user.deviceWallet, "device A"); eSIMIds = PURCHASES_PER_ESIM.map((_, e) => `${user.uid}-e${e}`); history = PURCHASES_PER_ESIM.map((count, e) => Array.from({ length: count }, (_, n) => ({ @@ -49,11 +50,10 @@ export const describeLazyWalletDeployFlow = ( }, 180_000); afterAll(async () => { - console.log(`lazy device wallet ${user?.deviceWallet}, ${eSIMWallets?.length ?? 0} eSIM wallets`); await target?.stop?.(); }); - it(`the backend records ${TOTAL_PURCHASES} card and external wallet purchases as they come in`, async () => { + it(`1. the backend records ${TOTAL_PURCHASES} card and external wallet purchases as they come in`, async () => { // Round robin over the eSIMs, so one eSIM's purchases span several recordings // and their order has to survive across transactions. const arrivals: { eSIMId: string; details: DataBundleDetails }[] = []; @@ -64,11 +64,13 @@ export const describeLazyWalletDeployFlow = ( } expect(arrivals).toHaveLength(TOTAL_PURCHASES); + log(`device ${user.uid} has no wallet. Every purchase goes to the lazy wallet registry.`); + const recordings = Math.ceil(arrivals.length / PURCHASES_PER_RECORDING); for (let start = 0; start < arrivals.length; start += PURCHASES_PER_RECORDING) { const chunk = arrivals.slice(start, start + PURCHASES_PER_RECORDING); - await waitFor(await admin.lazyWalletRegistry.batchPopulateHistory( - [user.uid], [chunk.map((a) => a.eSIMId)], [chunk.map((a) => a.details)], - )); + await backend(`batchPopulateHistory ${start / PURCHASES_PER_RECORDING + 1}/${recordings}, ${chunk.length} purchases`, + admin.lazyWalletRegistry.batchPopulateHistory([user.uid], [chunk.map((a) => a.eSIMId)], [chunk.map((a) => a.details)])); + for (const { eSIMId, details } of chunk) detail(`+ ${purchase(shortId(eSIMId), details)}`); } expect(await admin.lazyWalletRegistry.isDeviceIdentifierReserved(user.uid)).toBe(true); @@ -78,19 +80,30 @@ export const describeLazyWalletDeployFlow = ( expect(await admin.lazyWalletRegistry.eSIMIdentifiersAssociatedWithDeviceIdentifier(user.uid, BigInt(e))).toBe(eSIMId); expect(await admin.lazyWalletRegistry.outstandingHistoryEntries(eSIMId)).toBe(BigInt(PURCHASES_PER_ESIM[e])); } + log(`waiting to be copied per eSIM: ${eSIMIds.map((id, e) => `${shortId(id)}=${PURCHASES_PER_ESIM[e]}`).join(" ")}`); }, timeout); - it("the app's passkey resolves to the address the backend will deploy", async () => { + it("2. the app's passkey resolves to the address the backend will deploy", async () => { // The app resolved its account from the passkey, the device id and the salt the backend stores. const counterfactual = await admin.deviceWalletFactory.getCounterFactualAddress(user.signer.ownerKey, user.uid, user.salt); expect(user.deviceWallet).toBe(counterfactual); expect(await target.publicClient.getCode({ address: counterfactual })).toBeUndefined(); + log(`app and backend agree: device A will be ${counterfactual} (salt ${user.salt}), nothing deployed yet`); }, timeout); - it("the backend deploys the device wallet and all 20 eSIM wallets", async () => { + it("3. the backend deploys the device wallet and all 20 eSIM wallets", async () => { const deployment = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(user.signer.ownerKey, user.uid, user.salt, 0n); eSIMWallets = deployment.eSIMWallets; + log(`backend deployLazyWalletAndSetESIMIdentifier: device A ${deployment.deviceWallet}, ${deployment.batches.length} transactions`); + for (const [b, batch] of deployment.batches.entries()) { + detail(`batch ${b + 1} tx ${batch.hash}: ${batch.eSIMWallets.length} eSIM wallets, ${batch.remaining} still to deploy`); + batch.eSIMIdentifiers.forEach((id, i) => { + names.set(batch.eSIMWallets[i], shortId(id)); + detail(` ${shortId(id).padEnd(4)} -> ${batch.eSIMWallets[i]}`); + }); + } + expect(deployment.alreadyComplete).toBe(false); expect(deployment.batches.map((batch) => batch.remaining)).toEqual([10n, 0n]); expect(deployment.eSIMIdentifiers).toEqual(eSIMIds); @@ -114,9 +127,10 @@ export const describeLazyWalletDeployFlow = ( expect(await admin.lazyWalletRegistry.lazyDeployedESIMWallet(eSIMIds[e])).toBe(wallet); expect(await admin.deviceWallet!.isValidESIMWallet(wallet)).toBe(true); } + log("every eSIM wallet is owned by device A, named, and known to the registry"); }, timeout); - it("a new purchase is refused until the eSIM's history is copied in", async () => { + it("4. a new purchase is refused until the eSIM's history is copied in", async () => { const usd = stringToHex("USD", { size: 32 }); const err = await admin.registry.recordSettledPurchase( eSIMWallets[0], @@ -127,13 +141,17 @@ export const describeLazyWalletDeployFlow = ( // Refused before sending, so this costs nothing. expect(err).toBeInstanceOf(ContractRevertError); expect((err as ContractRevertError).decoded?.errorName).toBe("HistoryNotFullyCopied"); + log(`backend recordSettledPurchase on e0 refused before sending: ${(err as ContractRevertError).decoded?.errorName}`); }, timeout); - it("the backend copies every eSIM's history onto its wallet", async () => { + it("5. the backend copies every eSIM's history onto its wallet", async () => { for (const [e, eSIMId] of eSIMIds.entries()) { const copy = await admin.lazyWalletRegistry.setHistoryForLazyWallet(eSIMId); const count = BigInt(PURCHASES_PER_ESIM[e]); + log(`backend setHistoryForLazyWallet ${shortId(eSIMId)}: ${nEntries(copy.copied)} in ${plural(copy.batches.length, "transaction")}`); + for (const batch of copy.batches) detail(`tx ${batch.hash}: copied ${batch.copied}, ${batch.remaining} left`); + expect(copy.eSIMWallet).toBe(eSIMWallets[e]); expect(copy.copied).toBe(count); expect(copy.batches.map((batch) => batch.copied)).toEqual(count > 25n ? [25n, count - 25n] : [count]); @@ -142,43 +160,51 @@ export const describeLazyWalletDeployFlow = ( } }, timeout); - it(`each eSIM wallet holds exactly its own purchases, in order, ${TOTAL_PURCHASES} in all`, async () => { + it(`6. each eSIM wallet holds exactly its own purchases, in order, ${TOTAL_PURCHASES} in all`, async () => { for (const [e, wallet] of eSIMWallets.entries()) { - expect(await readAllHistory(wallet), `eSIM ${e}`).toEqual(history[e]); + const stored = await readAllHistory(wallet); + expect(stored, `eSIM ${e}`).toEqual(history[e]); + log(`${nameOf(wallet)} ${wallet}: ${nEntries(stored.length)}, all match what was recorded`); + // The two long histories in full, so their order is visible. + if (e < 2) stored.forEach((d, n) => detail(purchase(`#${n}`, d))); } }, timeout); - it("running the deploy and the copy again sends nothing", async () => { + it("7. running the deploy and the copy again sends nothing", async () => { const deployAgain = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(user.signer.ownerKey, user.uid, user.salt, 0n); expect(deployAgain).toMatchObject({ deviceWallet: user.deviceWallet, alreadyComplete: true, batches: [] }); const copyAgain = await admin.lazyWalletRegistry.setHistoryForLazyWallet(eSIMIds[0]); expect(copyAgain).toMatchObject({ eSIMWallet: eSIMWallets[0], copied: 0n, alreadyComplete: true }); + log("backend reran deployLazyWalletAndSetESIMIdentifier and setHistoryForLazyWallet: both already complete, no transactions"); }, timeout); - it("the user's passkey buys a new bundle on the lazily deployed wallet", async () => { + it("8. the user's passkey buys a new bundle on the lazily deployed wallet", async () => { const bundle = await buy(user, eSIMWallets[0], "lz-after"); // Lands after the copied history, not in front of it. expect(await readAllHistory(eSIMWallets[0])).toEqual([...history[0], bundle]); + log(`e0 now has ${nEntries(history[0].length + 1)}, the new one last`); }, timeout); const newESIMWallets: Address[] = []; - it("the device wallet buys 2 more eSIMs, each with a first bundle", async () => { + it("9. the device wallet buys 2 more eSIMs, each with a first bundle", async () => { // Salts the lazy deploy never used: it took the device's own salt and the 19 after it. for (const salt of [1n, 2n]) { let eSIMWallet!: Address; - await sponsored(user, async () => { + await sponsored(user, `deployAndBindESIMWallet(salt ${salt})`, async () => { const result = await user.kokio.deviceWallet!.deployAndBindESIMWallet(salt); eSIMWallet = result.eSIMWalletAddress; return result.userOpHash; }); + names.set(eSIMWallet, `n${salt}`); + detail(`n${salt} -> ${eSIMWallet}`); expect(eSIMWallets).not.toContain(eSIMWallet); const bundle = await buy(user, eSIMWallet, `lz-new-${salt}`); const eSIMId = `${user.uid}-n${salt}`; - await waitFor(await admin.registry.assignESIMIdentifier(eSIMWallet, eSIMId)); + await backend(`assignESIMIdentifier n${salt} = ${eSIMId}`, admin.registry.assignESIMIdentifier(eSIMWallet, eSIMId)); expect(await readESIMWallet(eSIMWallet, "eSIMUniqueIdentifier")).toBe(eSIMId); expect(await admin.registry.isESIMWalletValid(eSIMWallet)).toBe(user.deviceWallet); @@ -189,10 +215,12 @@ export const describeLazyWalletDeployFlow = ( let next: TestUser; - it("another device is set up to take eSIMs over", async () => { + it("10. another device is set up to take eSIMs over", async () => { next = await createTestUser(target, passkeyGet); - await sponsored(next, () => next.kokio.deviceWallet!.sendUserOperation([])); - await waitFor(await admin.deviceWalletFactory.postCreateAccount(next.deviceWallet, next.uid, next.signer.ownerKey, next.salt)); + names.set(next.deviceWallet, "device B"); + await sponsored(next, "an empty operation to deploy itself", () => next.kokio.deviceWallet!.sendUserOperation([])); + await backend(`postCreateAccount device B ${next.deviceWallet}`, + admin.deviceWalletFactory.postCreateAccount(next.deviceWallet, next.uid, next.signer.ownerKey, next.salt)); expect(await admin.registry.isDeviceWalletValid(next.deviceWallet)).toBe(true); }, timeout); @@ -200,16 +228,19 @@ export const describeLazyWalletDeployFlow = ( // The lazy eSIM with the longest history, and one bought after the deploy. const moving = () => [eSIMWallets[0], newESIMWallets[0]]; - it("the lazy eSIM with history and a new eSIM move to the other device, history intact", async () => { + it("11. the lazy eSIM with history and a new eSIM move to the other device, history intact", async () => { for (const wallet of moving()) { const [historyBefore, eSIMId] = await Promise.all([readAllHistory(wallet), readESIMWallet(wallet, "eSIMUniqueIdentifier")]); + log(`moving ${nameOf(wallet)} (${nEntries(historyBefore.length)}) from device A to device B`); as(user).kokio.setESIMWalletAddress(wallet); - await sponsored(user, () => user.kokio.eSIMWallet!.requestTransferOwnership(next.deviceWallet)); + await sponsored(user, `requestTransferOwnership(${nameOf(wallet)} to device B)`, + () => user.kokio.eSIMWallet!.requestTransferOwnership(next.deviceWallet)); expect(await admin.registry.isESIMWalletOnStandby(wallet)).toBe(true); + detail(`${nameOf(wallet)} is on standby while the move is pending`); as(next).kokio.setESIMWalletAddress(wallet); - await sponsored(next, () => next.kokio.eSIMWallet!.acceptAndBindESIMWallet()); + await sponsored(next, `acceptAndBindESIMWallet(${nameOf(wallet)})`, () => next.kokio.eSIMWallet!.acceptAndBindESIMWallet()); expect(await readESIMWallet(wallet, "owner")).toBe(next.deviceWallet); expect(await admin.registry.isESIMWalletValid(wallet)).toBe(next.deviceWallet); @@ -220,29 +251,34 @@ export const describeLazyWalletDeployFlow = ( // The eSIM and everything it bought travel with the wallet. expect(await readESIMWallet(wallet, "eSIMUniqueIdentifier")).toBe(eSIMId); expect(await readAllHistory(wallet)).toEqual(historyBefore); + detail(`${nameOf(wallet)} now belongs to device B, identifier and ${nEntries(historyBefore.length)} unchanged`); } // Every other eSIM stays with the first device. - for (const wallet of [...eSIMWallets.slice(1), newESIMWallets[1]]) { + const staying = [...eSIMWallets.slice(1), newESIMWallets[1]]; + for (const wallet of staying) { expect(await admin.registry.isESIMWalletValid(wallet)).toBe(user.deviceWallet); } + log(`still with device A: ${staying.map(nameOf).join(" ")}`); }, timeout); - it("the new device buys on the moved eSIMs, and the old device no longer can", async () => { + it("12. the new device buys on the moved eSIMs, and the old device no longer can", async () => { for (const wallet of moving()) { const historyBefore = await readAllHistory(wallet); const bundle = await buy(next, wallet, "lz-moved"); expect(await readAllHistory(wallet)).toEqual([...historyBefore, bundle]); + detail(`${nameOf(wallet)} now has ${nEntries(historyBefore.length + 1)}`); as(user).kokio.setESIMWalletAddress(wallet); - const quote = await user.kokio.paymentAdapter!.quote(USDC, bundle.priceUSDCents); - const err = await user.kokio.eSIMWallet!.buyDataBundleWithToken(bundle, USDC, quote, nextRef()).then(() => undefined, (e: unknown) => e); + const quote = await user.kokio.paymentAdapter!.quote(ASSET, bundle.priceUSDCents); + const err = await user.kokio.eSIMWallet!.buyDataBundleWithToken(bundle, ASSET, quote, nextRef()).then(() => undefined, (e: unknown) => e); expect(err).toBeInstanceOf(ContractRevertError); expect((err as ContractRevertError).decoded?.errorName).toBe("OnlyDeviceWalletOrESIMWalletAdmin"); + log(`device A tries buyDataBundleWithToken on ${nameOf(wallet)}: refused, ${(err as ContractRevertError).decoded?.errorName}`); } }, timeout); - const USDC = stringToHex("USDC", { size: 32 }); + // Payment references are spendable once per eSIM wallet, so each purchase gets its own. const RUN = Date.now().toString(36); let refs = 0; @@ -254,20 +290,24 @@ export const describeLazyWalletDeployFlow = ( return who; }; - const sponsored = (who: TestUser, send: () => Promise) => - expectSponsored(who.client, target.publicClient, send, { confirmations: target.confirmations }); + const sponsored = async (who: TestUser, what: string, send: () => Promise) => { + const receipt = await expectSponsored(who.client, target.publicClient, send, { confirmations: target.confirmations }); + log(`${nameOf(who.deviceWallet)} signs ${what}: user operation ${receipt.userOpHash}, tx ${receipt.receipt.transactionHash}`); + return receipt; + }; - // A purchase paid in USDC the device wallet sends over in the same operation. + // A purchase paid in tokens the device wallet sends over in the same operation. const buy = async (buyer: TestUser, eSIMWallet: Address, bundleName: string): Promise => { const bundle = { id: testBytes32(bundleName), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; const ref = nextRef(); as(buyer).kokio.setESIMWalletAddress(eSIMWallet); - const { token } = await buyer.kokio.paymentAdapter!.resolveAsset(USDC); - const quote = await buyer.kokio.paymentAdapter!.quote(USDC, bundle.priceUSDCents); + const { token } = await buyer.kokio.paymentAdapter!.resolveAsset(ASSET); + const quote = await buyer.kokio.paymentAdapter!.quote(ASSET, bundle.priceUSDCents); await target.fund(token, buyer.deviceWallet, quote); - const receipt = await sponsored(buyer, () => buyer.kokio.eSIMWallet!.buyDataBundleWithTransfer(bundle, USDC, quote, ref)); + const receipt = await sponsored(buyer, `buyDataBundleWithTransfer on ${nameOf(eSIMWallet)}`, + () => buyer.kokio.eSIMWallet!.buyDataBundleWithTransfer(bundle, ASSET, quote, ref)); const [event] = await target.publicClient.getContractEvents({ address: eSIMWallet, abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", @@ -275,6 +315,8 @@ export const describeLazyWalletDeployFlow = ( }); expect(event.args).toMatchObject({ _dataBundleID: bundle.id, _token: token, _amountSpent: quote }); expect(await target.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [buyer.deviceWallet] })).toBe(0n); + const decimals = await target.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "decimals" }); + detail(`+ ${purchase(nameOf(eSIMWallet), bundle)}, paid ${formatUnits(quote, decimals)} ${ASSET_SYMBOL} sent over by ${nameOf(buyer.deviceWallet)}`); return bundle; }; @@ -288,9 +330,12 @@ export const describeLazyWalletDeployFlow = ( } }; - const waitFor = async (hash: Hex) => { + // An admin transaction: waits for it to land, then logs what it did. + const backend = async (what: string, sending: Promise) => { + const hash = await sending; const receipt = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); expect(receipt.status).toBe("success"); + log(`backend ${what}: tx ${hash}`); return receipt; }; @@ -303,4 +348,26 @@ export const describeLazyWalletDeployFlow = ( }); return { id, priceUSDCents, settlement }; }; + + // Short names so the log reads as a story: device A and B, e0 to e19 for the + // lazy eSIMs, n1 and n2 for the two bought afterwards. + const names = new Map(); + const nameOf = (address: Address) => names.get(address) ?? address; + const shortId = (eSIMId: string) => eSIMId.slice(user.uid.length + 1); + const bundleName = (id: Hex) => hexToString(id, { size: 32 }).replace(`${TEST_TAG}:`, ""); + const usd = (cents: bigint) => `$${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`; + const purchase = (eSIM: string, d: DataBundleDetails) => + `${eSIM.padEnd(4)} bundle ${bundleName(d.id).padEnd(10)} ${usd(d.priceUSDCents).padStart(6)} ${Settlement[d.settlement]}`; + + const plural = (count: number | bigint, one: string, many = `${one}s`) => `${count} ${Number(count) === 1 ? one : many}`; + const nEntries = (count: number | bigint) => plural(count, "entry", "entries"); + + // Collected per step and printed once it ends, so each step's story reads as + // one block rather than a header per line. + const lines: string[] = []; + const log = (message: string) => lines.push(message); + const detail = (message: string) => lines.push(` ${message}`); + afterEach(() => { + if (lines.length > 0) console.log(lines.splice(0).join("\n")); + }); }); From 2925e426d91cc672ca0e52e8a67cb3f3399b3d9e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 13:44:03 +0530 Subject: [PATCH 06/10] Restore the payment asset the lazy wallet flow buys with --- tests/consumer/flows/lazyWalletDeployFlow.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/consumer/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts index de33cd2..73ccd1c 100644 --- a/tests/consumer/flows/lazyWalletDeployFlow.ts +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -278,7 +278,9 @@ export const describeLazyWalletDeployFlow = ( } }, timeout); - + // The settlement token on Base Sepolia, and the one the test admin holds plenty of. + const ASSET_SYMBOL = "USDCt"; + const ASSET = stringToHex(ASSET_SYMBOL, { size: 32 }); // Payment references are spendable once per eSIM wallet, so each purchase gets its own. const RUN = Date.now().toString(36); let refs = 0; From 80dcc92c2525f49d0b123d120dd3a1db0bd21add Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 14:14:27 +0530 Subject: [PATCH 07/10] Report every eSIM wallet of a device when a lazy deploy resumes A resumed deploy returned only the wallets from its own batches, and an empty list when everything had already landed. A caller copying history from the result then skipped the eSIMs an earlier call deployed, leaving their purchases blocked. The wallets from earlier calls are now read back from the lazy registry. batches still holds only what this call sent. --- src/logic/admin/lazyWalletRegistry.eoa.ts | 31 +++++++++++++++++--- src/types.ts | 4 +-- tests/logic/admin/lazyWalletRegistry.test.ts | 30 ++++++++++++------- tests/utils/mockClient.ts | 12 ++++++-- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/logic/admin/lazyWalletRegistry.eoa.ts b/src/logic/admin/lazyWalletRegistry.eoa.ts index 4fb272f..fa29765 100644 --- a/src/logic/admin/lazyWalletRegistry.eoa.ts +++ b/src/logic/admin/lazyWalletRegistry.eoa.ts @@ -20,7 +20,11 @@ import { writeContractOrThrow, } from "../errors.js"; import { LazyWalletRegistry, Registry } from "../../abis/index.js"; -import { _eSIMWalletsDeployed, _lazyDeployedESIMWallet } from "./reads/lazyWalletRegistry.reads.js"; +import { + _eSIMIdentifiersAssociatedWithDeviceIdentifier, + _eSIMWalletsDeployed, + _lazyDeployedESIMWallet, +} from "./reads/lazyWalletRegistry.reads.js"; import type { DataBundleDetails, LazyDeployment, @@ -302,6 +306,7 @@ export const _deployLazyWalletAllBatches = async ( const alreadyDeployed = await _eSIMWalletsDeployed(client, deviceUniqueIdentifier); const batches: LazyDeploymentBatch[] = []; + let earlier: { eSIMWallets: readonly Address[]; eSIMIdentifiers: readonly string[] } = { eSIMWallets: [], eSIMIdentifiers: [] }; let deviceWallet: Address; let outstanding: boolean; @@ -335,6 +340,10 @@ export const _deployLazyWalletAllBatches = async ( else { if (depositAmount !== 0n) throw new DepositOnResumeError(deviceUniqueIdentifier, depositAmount); + // Wallets an earlier call deployed. Without them a resume would report only its + // own batches, and a caller walking the result would skip the rest of the device. + earlier = await _readDeployedESIMWallets(client, deviceUniqueIdentifier, alreadyDeployed); + deviceWallet = await publicClient.readContract({ address: values.factoryAddresses.REGISTRY, abi: Registry, @@ -351,7 +360,7 @@ export const _deployLazyWalletAllBatches = async ( }, "AllESIMWalletsDeployed"); if (finished) { - return { deviceWallet, eSIMWallets: [], eSIMIdentifiers: [], batches: [], alreadyComplete: true }; + return { deviceWallet, ...earlier, batches: [], alreadyComplete: true }; } outstanding = true; } @@ -378,13 +387,27 @@ export const _deployLazyWalletAllBatches = async ( return { deviceWallet, - eSIMWallets: batches.flatMap((batch) => [...batch.eSIMWallets]), - eSIMIdentifiers: batches.flatMap((batch) => [...batch.eSIMIdentifiers]), + eSIMWallets: [...earlier.eSIMWallets, ...batches.flatMap((batch) => [...batch.eSIMWallets])], + eSIMIdentifiers: [...earlier.eSIMIdentifiers, ...batches.flatMap((batch) => [...batch.eSIMIdentifiers])], batches, alreadyComplete: false, }; } +/** + * The first `count` eSIM wallets deployed for a device, in deploy order. The + * deploy walks the device's identifier list from the start, so the first + * `count` identifiers are exactly the ones with wallets. + */ +const _readDeployedESIMWallets = async (client: WalletClient, deviceUniqueIdentifier: string, count: bigint) => { + + const eSIMIdentifiers = await Promise.all(Array.from({ length: Number(count) }, (_, i) => + _eSIMIdentifiersAssociatedWithDeviceIdentifier(client, deviceUniqueIdentifier, BigInt(i)))); + const eSIMWallets = await Promise.all(eSIMIdentifiers.map((id) => _lazyDeployedESIMWallet(client, id))); + + return { eSIMWallets, eSIMIdentifiers }; +} + /** * Copy an eSIM's whole stored purchase history onto its wallet, over as many * transactions as that takes. `onlyESIMWalletAdmin`. diff --git a/src/types.ts b/src/types.ts index 2983fa7..a701490 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,8 +144,8 @@ export type LazyDeploymentBatch = { /** * What a fully paginated lazy deployment did. `eSIMWallets` and `eSIMIdentifiers` - * cover only the batches this call ran, so a resume reports what it finished - * rather than the device's whole set. + * cover the device's whole set, including wallets an earlier call deployed, so a + * resume can be walked like a fresh deploy. `batches` holds only what this call sent. */ export type LazyDeployment = { deviceWallet: Address; diff --git a/tests/logic/admin/lazyWalletRegistry.test.ts b/tests/logic/admin/lazyWalletRegistry.test.ts index 85ede81..9f17ddb 100644 --- a/tests/logic/admin/lazyWalletRegistry.test.ts +++ b/tests/logic/admin/lazyWalletRegistry.test.ts @@ -74,6 +74,14 @@ const revertsWith = (errorName: "AllESIMWalletsDeployed" | "HistoryAlreadyCopied }); }; +// What the registry holds for a device an earlier call already started deploying: +// identifier i is "eid-i", and its wallet is walletAt(i). +const deployedSoFar = { + uniqueIdentifierToDeviceWallet: DEVICE_WALLET, + eSIMIdentifiersAssociatedWithDeviceIdentifier: ([, index]: readonly unknown[]) => `eid-${index}`, + lazyDeployedESIMWallet: ([id]: readonly unknown[]) => walletAt(Number(String(id).slice("eid-".length))), +}; + const writesOf = (client: ReturnType) => (client.writeContract as ReturnType).mock.calls.map((call) => call[0]); @@ -131,14 +139,11 @@ describe("_deployLazyWalletAllBatches", () => { expect(result.batches.map((b) => b.remaining)).toEqual([2n, 1n, 0n]); }); - it("resumes a part-deployed device instead of restarting it", async () => { + it("resumes a part-deployed device, reporting the earlier wallets too", async () => { const client = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, - reads: { - eSIMWalletsDeployed: 3n, - uniqueIdentifierToDeviceWallet: DEVICE_WALLET, - }, + reads: { ...deployedSoFar, eSIMWalletsDeployed: 3n }, receipts: [{ logs: [deployedLog([walletAt(3)], ["eid-3"], 0n)] }], }); @@ -148,18 +153,19 @@ describe("_deployLazyWalletAllBatches", () => { expect(writes).toHaveLength(1); expect(writes[0].functionName).toBe("deployMoreESIMWalletsForLazyDevice"); expect(result.deviceWallet).toBe(DEVICE_WALLET); - expect(result.eSIMWallets).toEqual([walletAt(3)]); + // A caller copying history from this result must not miss the first three. + expect(result.eSIMWallets).toEqual([walletAt(0), walletAt(1), walletAt(2), walletAt(3)]); + expect(result.eSIMIdentifiers).toEqual(["eid-0", "eid-1", "eid-2", "eid-3"]); + // Only what this call sent. + expect(result.batches.map((b) => b.eSIMWallets)).toEqual([[walletAt(3)]]); expect(result.alreadyComplete).toBe(false); }); - it("sends nothing for a device that is already fully deployed", async () => { + it("sends nothing for a device that is already fully deployed, and still lists its wallets", async () => { const client = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, - reads: { - eSIMWalletsDeployed: 5n, - uniqueIdentifierToDeviceWallet: DEVICE_WALLET, - }, + reads: { ...deployedSoFar, eSIMWalletsDeployed: 5n }, simulate: revertsWith("AllESIMWalletsDeployed", DEVICE), }); @@ -169,6 +175,8 @@ describe("_deployLazyWalletAllBatches", () => { expect(result.alreadyComplete).toBe(true); expect(result.deviceWallet).toBe(DEVICE_WALLET); expect(result.batches).toEqual([]); + expect(result.eSIMWallets).toEqual([0, 1, 2, 3, 4].map(walletAt)); + expect(result.eSIMIdentifiers).toEqual(["eid-0", "eid-1", "eid-2", "eid-3", "eid-4"]); }); it("refuses a deposit on a resume, since the first batch already took one", async () => { diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index 16f2ebc..6058a7b 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -12,7 +12,10 @@ export const makeMockWalletClient = (opts: { url?: string; account?: `0x${string}`; readResult?: unknown; - /** Per-function read results, for logic that reads several values in one call. */ + /** + * Per-function read results, for logic that reads several values in one call. + * A function is called with the read's args, for getters read by index or key. + */ reads?: Record; /** * Receipts `waitForTransactionReceipt` hands back, one per call in order. Supply @@ -46,8 +49,11 @@ export const makeMockWalletClient = (opts: { if (arg.account !== client.account) throw new Error("writeContract was not given the client's account object"); return write ? write() : (receipts ? nextHash() : "0xwritehash"); }), - readContract: vi.fn(async ({ functionName }: { functionName: string }) => - reads && functionName in reads ? reads[functionName] : readResult), + readContract: vi.fn(async ({ functionName, args }: { functionName: string; args?: readonly unknown[] }) => { + if (!reads || !(functionName in reads)) return readResult; + const result = reads[functionName]; + return typeof result === "function" ? result(args ?? []) : result; + }), waitForTransactionReceipt: vi.fn(async () => { const receipt = receipts?.[delivered++]; if (!receipt) throw new Error(`Mock client has no receipt for transaction ${delivered}`); From b69ca6ebf6a1367ccba44e1c7595487b3745cba6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 14:16:17 +0530 Subject: [PATCH 08/10] Test that a resumed lazy deploy lists the whole device on a fork --- tests/consumer/flows/lazyWalletDeployFlow.ts | 3 ++ .../lazyDeploymentFork.integration.test.ts | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/consumer/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts index 73ccd1c..6a8c30a 100644 --- a/tests/consumer/flows/lazyWalletDeployFlow.ts +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -173,6 +173,9 @@ export const describeLazyWalletDeployFlow = ( it("7. running the deploy and the copy again sends nothing", async () => { const deployAgain = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(user.signer.ownerKey, user.uid, user.salt, 0n); expect(deployAgain).toMatchObject({ deviceWallet: user.deviceWallet, alreadyComplete: true, batches: [] }); + // Still lists all 20, so a backend retrying after a crash copies every eSIM. + expect(deployAgain.eSIMWallets).toEqual(eSIMWallets); + expect(deployAgain.eSIMIdentifiers).toEqual(eSIMIds); const copyAgain = await admin.lazyWalletRegistry.setHistoryForLazyWallet(eSIMIds[0]); expect(copyAgain).toMatchObject({ eSIMWallet: eSIMWallets[0], copied: 0n, alreadyComplete: true }); diff --git a/tests/integration/lazyDeploymentFork.integration.test.ts b/tests/integration/lazyDeploymentFork.integration.test.ts index 100d225..fb13d47 100644 --- a/tests/integration/lazyDeploymentFork.integration.test.ts +++ b/tests/integration/lazyDeploymentFork.integration.test.ts @@ -116,6 +116,41 @@ describe.skipIf(!forkAvailable())("Lazy deployment - pagination on a Base Sepoli expect(again.alreadyComplete).toBe(true); expect(again.batches).toEqual([]); expect(again.deviceWallet).toBe(result.deviceWallet); + // Still the whole device, so a caller retrying after a crash can walk it. + expect(again.eSIMWallets).toEqual(result.eSIMWallets); + expect(again.eSIMIdentifiers).toEqual(eSIMs); + }, + 300_000, + ); + + it( + "reports the whole device when a deploy interrupted after its first batch is resumed", + async () => { + const device = "fork-lazy-resumed-device"; + const eSIMs = Array.from({ length: 5 }, (_, i) => `${device}-esim-${i}`); + const ownerKey = freshOwnerKey(); + + const populate = await sdk.lazyWalletRegistry.batchPopulateHistory([device], [eSIMs], [bundles(eSIMs.length)]); + await fork.publicClient.waitForTransactionReceipt({ hash: populate }); + + // Only the first batch lands, as if the call threw before sending the next one. + const first = await sdk.lazyWalletRegistry.deployLazyWalletFirstBatch(ownerKey, device, 7_301n, 0n, 2n); + await fork.publicClient.waitForTransactionReceipt({ hash: first }); + + const resumed = await sdk.lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(ownerKey, device, 7_301n, 0n, 2n); + + expect(resumed.alreadyComplete).toBe(false); + expect(resumed.batches.map((batch) => batch.eSIMIdentifiers)).toEqual([eSIMs.slice(2, 4), eSIMs.slice(4)]); + expect(resumed.eSIMIdentifiers).toEqual(eSIMs); + for (const [i, eSIM] of eSIMs.entries()) { + expect(resumed.eSIMWallets[i]).toBe(await readLazy(fork).read.lazyDeployedESIMWallet([eSIM])); + } + + // Copying from the result reaches every eSIM, the first batch's included. + for (const eSIM of resumed.eSIMIdentifiers) await sdk.lazyWalletRegistry.setHistoryForLazyWallet(eSIM); + for (const eSIM of eSIMs) { + expect(await sdk.lazyWalletRegistry.outstandingHistoryEntries(eSIM)).toBe(0n); + } }, 300_000, ); From cac0f4b35af9ef1994b4eea158737886f45f0a9a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 14:16:25 +0530 Subject: [PATCH 09/10] Document that a lazy deploy's result lists the whole device --- docs/admin/lazy-wallet-registry.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/admin/lazy-wallet-registry.md b/docs/admin/lazy-wallet-registry.md index be0e524..1314f59 100644 --- a/docs/admin/lazy-wallet-registry.md +++ b/docs/admin/lazy-wallet-registry.md @@ -63,6 +63,8 @@ const deployment = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIden Returns: `Promise`, `{ deviceWallet, eSIMWallets, eSIMIdentifiers, batches, alreadyComplete }`. +`eSIMWallets` and `eSIMIdentifiers` always list every eSIM wallet the device has, in deploy order, even when this call resumed a deploy or found it already complete. Walk them to copy each eSIM's history with `setHistoryForLazyWallet`. `batches` lists only the transactions this call sent. + ## setHistoryForLazyWallet Copies one eSIM's whole recorded purchase history onto its deployed wallet, From 3d18ef9ffc355f8667c67210a618648fa0bfd7ce Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 14:21:19 +0530 Subject: [PATCH 10/10] Set package version to 3.2.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8838161..bd21af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio-sdk", - "version": "3.2.0", + "version": "3.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio-sdk", - "version": "3.2.0", + "version": "3.2.1", "license": "MIT", "dependencies": { "@noble/curves": "2.3.0", diff --git a/package.json b/package.json index a07085c..89ceeb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kokio-sdk", - "version": "3.2.0", + "version": "3.2.1", "description": "", "type": "module", "main": "./dist/esm/config.js",