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, 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", 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/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 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/flows/lazyWalletDeployFlow.ts b/tests/consumer/flows/lazyWalletDeployFlow.ts new file mode 100644 index 0000000..6a8c30a --- /dev/null +++ b/tests/consumer/flows/lazyWalletDeployFlow.ts @@ -0,0 +1,378 @@ +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"; +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 { 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). +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); + 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) => ({ + 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 () => { + await target?.stop?.(); + }); + + 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 }[] = []; + 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); + + 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 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); + 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])); + } + log(`waiting to be copied per eSIM: ${eSIMIds.map((id, e) => `${shortId(id)}=${PURCHASES_PER_ESIM[e]}`).join(" ")}`); + }, timeout); + + 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("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); + 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); + } + log("every eSIM wallet is owned by device A, named, and known to the registry"); + }, timeout); + + 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], + { 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"); + log(`backend recordSettledPurchase on e0 refused before sending: ${(err as ContractRevertError).decoded?.errorName}`); + }, timeout); + + 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]); + expect(await admin.lazyWalletRegistry.historyEntriesCopied(eSIMId)).toBe(count); + expect(await admin.lazyWalletRegistry.outstandingHistoryEntries(eSIMId)).toBe(0n); + } + }, timeout); + + it(`6. each eSIM wallet holds exactly its own purchases, in order, ${TOTAL_PURCHASES} in all`, async () => { + for (const [e, wallet] of eSIMWallets.entries()) { + 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("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 }); + log("backend reran deployLazyWalletAndSetESIMIdentifier and setHistoryForLazyWallet: both already complete, no transactions"); + }, timeout); + + 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("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, `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 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); + expect(await readAllHistory(eSIMWallet)).toEqual([bundle]); + newESIMWallets.push(eSIMWallet); + } + }, timeout); + + let next: TestUser; + + it("10. another device is set up to take eSIMs over", async () => { + next = await createTestUser(target, passkeyGet); + 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); + + // The lazy eSIM with the longest history, and one bought after the deploy. + const moving = () => [eSIMWallets[0], newESIMWallets[0]]; + + 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, `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, `acceptAndBindESIMWallet(${nameOf(wallet)})`, () => 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); + detail(`${nameOf(wallet)} now belongs to device B, identifier and ${nEntries(historyBefore.length)} unchanged`); + } + + // Every other eSIM stays with the first device. + 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("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(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); + + // 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; + 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 = 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 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(ASSET); + const quote = await buyer.kokio.paymentAdapter!.quote(ASSET, bundle.priceUSDCents); + await target.fund(token, buyer.deviceWallet, quote); + + 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", + args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, + }); + 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; + }; + + // 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); + } + }; + + // 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; + }; + + 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 }; + }; + + // 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")); + }); +}); 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 }); 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"] }); 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, ); 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}`);