Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/admin/lazy-wallet-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const deployment = await admin.lazyWalletRegistry.deployLazyWalletAndSetESIMIden

Returns: `Promise<LazyDeployment>`, `{ 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,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kokio-sdk",
"version": "3.2.0",
"version": "3.2.1",
"description": "",
"type": "module",
"main": "./dist/esm/config.js",
Expand Down
31 changes: 27 additions & 4 deletions src/logic/admin/lazyWalletRegistry.eoa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/consumer/fixtures/forkFlowTarget.ts
Original file line number Diff line number Diff line change
@@ -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<FlowTarget> => {
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,
};
};
Loading
Loading