From 658b5a9e053e59f96e65dff474574cb89ae2ece8 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 07:57:19 +0530 Subject: [PATCH 01/10] Build the buy-with-transfer calls apart from sending them The calls now come from src/logic/calls, so the backend can build the same batch the app sends. --- src/logic/calls/eSIMWallet.calls.ts | 69 +++++++++++++++++++++++++++++ src/logic/eSIMWallet.ts | 43 ++---------------- 2 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 src/logic/calls/eSIMWallet.calls.ts diff --git a/src/logic/calls/eSIMWallet.calls.ts b/src/logic/calls/eSIMWallet.calls.ts new file mode 100644 index 0000000..d81da4f --- /dev/null +++ b/src/logic/calls/eSIMWallet.calls.ts @@ -0,0 +1,69 @@ +import { Address, Client, Hex, PublicActions, encodeFunctionData, erc20Abi } from "viem"; +import { Call, DataBundleDetails } from "../../types.js"; +import { ESIMWallet, PaymentAdapter, Registry } from "../../abis/index.js"; +import { _chainId, _getChainSpecificConstants } from "../constants.js"; + +// Builds the calls a device wallet signs as one user operation, without sending +// anything. The mobile surface sends them straight away; the backend hands them +// to the app to sign. + +/** Any client that can read contracts: the mobile smart account client, or a wallet client extended with `publicActions`. */ +export type CallBuilderClient = Pick & Pick; + +/** + * The calls for buying a data bundle with tokens the device wallet sends over + * in the same user operation, so the eSIM wallet needs no access to the device + * wallet's funds. + * + * Only the shortfall is sent: the quote for the bundle minus what the eSIM + * wallet already holds of `asset`, worked out when this runs. If that balance + * or the quote changes before the operation lands, it reverts, so build the + * calls again rather than resending old ones. + */ +export const _buyDataBundleWithTransferCalls = async ( + client: CallBuilderClient, + eSIMWalletAddress: Address, + dataBundleDetails: DataBundleDetails, + asset: Hex, + maxAmountIn: bigint, + paymentReference: Hex +): Promise => { + + const chainID = await _chainId(client); + const values = _getChainSpecificConstants(chainID, client.transport.url); + + // The eSIM wallet pays through whichever adapter the registry names, so read it there. + const adapter = await client.readContract({ + address: values.factoryAddresses.REGISTRY, abi: Registry, functionName: "paymentAdapter" + }) as Address; + const [{ token }, amountIn] = await Promise.all([ + client.readContract({ + address: adapter, abi: PaymentAdapter, functionName: "resolveAsset", args: [asset] + }) as Promise<{ token: Address }>, + client.readContract({ + address: adapter, abi: PaymentAdapter, functionName: "quote", args: [asset, dataBundleDetails.priceUSDCents] + }) as Promise, + ]); + const held = await client.readContract({ + address: token, abi: erc20Abi, functionName: "balanceOf", args: [eSIMWalletAddress] + }); + + const buy = { + to: eSIMWalletAddress, + data: encodeFunctionData({ + abi: ESIMWallet, + functionName: "buyDataBundleWithToken", + args: [dataBundleDetails, asset, maxAmountIn, paymentReference] + }) + }; + + if (held >= amountIn) return [buy]; + + return [ + { + to: token, + data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [eSIMWalletAddress, amountIn - held] }) + }, + buy + ]; +} diff --git a/src/logic/eSIMWallet.ts b/src/logic/eSIMWallet.ts index b253997..31c046d 100644 --- a/src/logic/eSIMWallet.ts +++ b/src/logic/eSIMWallet.ts @@ -1,10 +1,10 @@ -import { Address, Hex, encodeFunctionData, erc20Abi, maxUint256 } from "viem" +import { Address, Hex, encodeFunctionData, maxUint256 } from "viem" import { DataBundleDetails } from "../types.js"; import { KokioSmartAccountClient } from "../types.js"; import { MissingSmartWalletError } from "./errors.js"; -import { ESIMWallet, PaymentAdapter, Registry } from "../abis/index.js"; -import { _chainId, _getChainSpecificConstants } from "./constants.js"; +import { ESIMWallet } from "../abis/index.js"; import { _defaultPriceCapUSDCents } from "./registry.js"; +import { _buyDataBundleWithTransferCalls } from "./calls/eSIMWallet.calls.js"; // Not exposed on this surface: // - populateHistory and setESIMUniqueIdentifier are `onlyRegistry` - callable @@ -93,46 +93,11 @@ export const _buyDataBundleWithTransfer = async ( paymentReference: Hex ) => { - const chainID = await _chainId(client); - const rpcURL = client.transport.url; - const values = _getChainSpecificConstants(chainID, rpcURL); - if(!client.account) throw new MissingSmartWalletError() - // The eSIM wallet pays through whichever adapter the registry names, so read it there. - const adapter = await client.readContract({ - address: values.factoryAddresses.REGISTRY, abi: Registry, functionName: "paymentAdapter" - }) as Address; - const [{ token }, amountIn] = await Promise.all([ - client.readContract({ - address: adapter, abi: PaymentAdapter, functionName: "resolveAsset", args: [asset] - }) as Promise<{ token: Address }>, - client.readContract({ - address: adapter, abi: PaymentAdapter, functionName: "quote", args: [asset, dataBundleDetails.priceUSDCents] - }) as Promise, - ]); - const held = await client.readContract({ - address: token, abi: erc20Abi, functionName: "balanceOf", args: [address] - }); - - const buy = { - to: address, - data: encodeFunctionData({ - abi: ESIMWallet, - functionName: "buyDataBundleWithToken", - args: [dataBundleDetails, asset, maxAmountIn, paymentReference] - }) - }; - return client.sendUserOperation({ account: client.account, - calls: held >= amountIn ? [buy] : [ - { - to: token, - data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [address, amountIn - held] }) - }, - buy - ] + calls: await _buyDataBundleWithTransferCalls(client, address, dataBundleDetails, asset, maxAmountIn, paymentReference) }); } From 3bfb788b6c6e5c325ed96f7a0b96ba075f37cb2e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 07:58:04 +0530 Subject: [PATCH 02/10] Test the buy-with-transfer calls, with and without a shortfall --- tests/logic/calls/eSIMWallet.calls.test.ts | 81 ++++++++++++++++++++++ tests/utils/mockClient.ts | 7 +- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/logic/calls/eSIMWallet.calls.test.ts diff --git a/tests/logic/calls/eSIMWallet.calls.test.ts b/tests/logic/calls/eSIMWallet.calls.test.ts new file mode 100644 index 0000000..9a67549 --- /dev/null +++ b/tests/logic/calls/eSIMWallet.calls.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { encodeFunctionData, erc20Abi, type Address, type Hex } from "viem"; + +import { makeMockSmartAccountClient, makeMockWalletClient } from "../../utils/mockClient.js"; +import { baseSepoliaFactoryAddresses } from "../../../src/logic/constants.js"; +import { ESIMWallet } from "../../../src/abis/index.js"; +import { Settlement, type DataBundleDetails } from "../../../src/types.js"; +import { _buyDataBundleWithTransferCalls, type CallBuilderClient } from "../../../src/logic/calls/eSIMWallet.calls.js"; +import { _buyDataBundleWithTransfer } from "../../../src/logic/eSIMWallet.js"; + +const ESIM = "0x00000000000000000000000000000000000e51a1" as Address; +const ADAPTER = "0x00000000000000000000000000000000000ada91" as Address; +const TOKEN = "0x0000000000000000000000000000000000706b31" as Address; +const BUNDLE: DataBundleDetails = { + id: "0x0000000000000000000000000000000000000000000000000000000000000001", + priceUSDCents: 1000n, + settlement: Settlement.DeviceWallet, +}; +const ASSET = "0x5553444300000000000000000000000000000000000000000000000000000000" as Hex; +const REF = "0x000000000000000000000000000000000000000000000000000000000000ee11" as Hex; +const QUOTE = 100n; +const MAX_IN = 120n; + +const BUY = { + to: ESIM, + data: encodeFunctionData({ abi: ESIMWallet, functionName: "buyDataBundleWithToken", args: [BUNDLE, ASSET, MAX_IN, REF] }), +}; +const transfer = (amount: bigint) => ({ + to: TOKEN, + data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [ESIM, amount] }), +}); + +const reads = (held: bigint) => ({ paymentAdapter: ADAPTER, resolveAsset: { token: TOKEN }, quote: QUOTE, balanceOf: held }); + +// The builder takes any client that reads, so the admin wallet client stands in. +const builderClient = (held: bigint) => makeMockWalletClient({ chainId: 84532, reads: reads(held) }) as unknown as CallBuilderClient; +const build = (client: CallBuilderClient) => _buyDataBundleWithTransferCalls(client, ESIM, BUNDLE, ASSET, MAX_IN, REF); + +describe("_buyDataBundleWithTransferCalls", () => { + it("sends only the shortfall before the purchase", async () => { + expect(await build(builderClient(30n))).toEqual([transfer(70n), BUY]); + }); + + it("skips the transfer when the eSIM wallet already holds the quote", async () => { + expect(await build(builderClient(QUOTE))).toEqual([BUY]); + expect(await build(builderClient(QUOTE + 1n))).toEqual([BUY]); + }); + + it("sends the whole quote to an empty eSIM wallet", async () => { + expect(await build(builderClient(0n))).toEqual([transfer(QUOTE), BUY]); + }); + + it("reads the adapter from the registry, then the eSIM wallet's balance of the resolved token", async () => { + const client = builderClient(0n); + await build(client); + + const read = client.readContract as unknown as ReturnType; + const calls = read.mock.calls.map(([arg]) => arg); + expect(calls.find((c) => c.functionName === "paymentAdapter").address).toBe(baseSepoliaFactoryAddresses.REGISTRY); + expect(calls.find((c) => c.functionName === "resolveAsset")).toMatchObject({ address: ADAPTER, args: [ASSET] }); + expect(calls.find((c) => c.functionName === "quote")).toMatchObject({ address: ADAPTER, args: [ASSET, BUNDLE.priceUSDCents] }); + expect(calls.find((c) => c.functionName === "balanceOf")).toMatchObject({ address: TOKEN, args: [ESIM] }); + }); +}); + +describe("eSIMWallet._buyDataBundleWithTransfer", () => { + it("sends the built calls as one user operation", async () => { + const client = makeMockSmartAccountClient({ reads: reads(30n) }); + await _buyDataBundleWithTransfer(client, ESIM, BUNDLE, ASSET, MAX_IN, REF); + + const send = client.sendUserOperation as unknown as ReturnType; + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toEqual({ account: client.account, calls: [transfer(70n), BUY] }); + }); + + it("throws MISSING_SMART_WALLET before reading anything", async () => { + const client = makeMockSmartAccountClient({ withAccount: false, reads: reads(0n) }); + await expect(_buyDataBundleWithTransfer(client, ESIM, BUNDLE, ASSET, MAX_IN, REF)).rejects.toThrow(/smart wallet/i); + expect(client.readContract).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index 2539dd2..16f2ebc 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -71,9 +71,11 @@ const SENT_USER_OP = "0xuserophash" as const; export const makeMockSmartAccountClient = (opts?: { chainId?: number; withAccount?: boolean; + /** Per-function read results, for logic that reads before it sends. */ + reads?: Record; }): KokioSmartAccountClient => { // Base Sepolia: the only chain with a configured address book. - const { chainId = 84532, withAccount = true } = opts ?? {}; + const { chainId = 84532, withAccount = true, reads } = opts ?? {}; return { getChainId: async () => chainId, @@ -83,6 +85,7 @@ export const makeMockSmartAccountClient = (opts?: { : undefined, sendUserOperation: vi.fn(async () => SENT_USER_OP), // `view` calls are issued via readContract (PublicActions), not userOps. - readContract: vi.fn(async () => "0xreadresult"), + readContract: vi.fn(async ({ functionName }: { functionName: string }) => + reads && functionName in reads ? reads[functionName] : "0xreadresult"), } as unknown as KokioSmartAccountClient; }; From b130c52afbb4ceaf2e7d40b1a80337e8e572db0d Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 07:58:53 +0530 Subject: [PATCH 03/10] Let the backend build buy-with-transfer calls through admin.calls The backend passes the result to the app, which signs it with deviceWallet.sendUserOperation. --- src/admin/config-admin.ts | 5 +++++ src/admin/interface/callsClass.ts | 27 +++++++++++++++++++++++++++ src/logic/README.md | 1 + src/logic/eSIMWallet.ts | 1 + tests/admin/kokioAdmin.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 60 insertions(+) create mode 100644 src/admin/interface/callsClass.ts diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 67a841b..1bdfb35 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -8,6 +8,7 @@ import { AdminProtocolAdminSubPackage } from "./interface/protocolAdminClass.js" import { AdminDeviceWalletSubPackage } from "./interface/deviceWalletClass.js"; import { AdminESIMWalletSubPackage } from "./interface/eSIMWalletClass.js"; import { AdminPaymentAdapterSubPackage } from "./interface/paymentAdapterClass.js"; +import { AdminCallsSubPackage } from "./interface/callsClass.js"; // Re-export the typed error surface so backend consumers can `instanceof // KokioError` (or a subclass) and decode reverts without reaching into internal @@ -66,6 +67,8 @@ export class KokioAdmin { lazyWalletRegistry: AdminLazyWalletRegistrySubPackage; protocolAdmin: AdminProtocolAdminSubPackage; paymentAdapter: AdminPaymentAdapterSubPackage; + /** Calls for the app to sign as a user operation, built here so the backend holds the purchase logic. */ + calls: AdminCallsSubPackage; // Instance-scoped surfaces - undefined until their address is known. deviceWallet?: AdminDeviceWalletSubPackage; @@ -84,6 +87,7 @@ export class KokioAdmin { this.lazyWalletRegistry = new AdminLazyWalletRegistrySubPackage(walletClient); this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient); this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient); + this.calls = new AdminCallsSubPackage(walletClient); this.deviceWallet = deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, deviceWalletAddress) : undefined; this.eSIMWallet = eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, eSIMWalletAddress) : undefined; @@ -141,6 +145,7 @@ export class KokioAdmin { this.lazyWalletRegistry = new AdminLazyWalletRegistrySubPackage(walletClient); this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient); this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient); + this.calls = new AdminCallsSubPackage(walletClient); this.deviceWallet = this.deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, this.deviceWalletAddress) : undefined; this.eSIMWallet = this.eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, this.eSIMWalletAddress) : undefined; diff --git a/src/admin/interface/callsClass.ts b/src/admin/interface/callsClass.ts new file mode 100644 index 0000000..b9de009 --- /dev/null +++ b/src/admin/interface/callsClass.ts @@ -0,0 +1,27 @@ +import { Address, Hex, WalletClient, publicActions } from "viem"; +import { DataBundleDetails } from "../../types.js"; +import { _buyDataBundleWithTransferCalls } from "../../logic/calls/eSIMWallet.calls.js"; + +/** + * Builds the calls a user's device wallet signs as one user operation. Nothing + * is signed or sent here: hand the result to the app, which passes it to + * `deviceWallet.sendUserOperation`. Addresses are passed per call, since one + * backend builds calls for many users. + */ +export class AdminCallsSubPackage { + + walletClient: WalletClient; + + constructor(walletClient: WalletClient) { + this.walletClient = walletClient; + } + + /** + * The calls `kokio.eSIMWallet.buyDataBundleWithTransfer` sends: a token + * transfer from the device wallet for whatever the eSIM wallet is short of, + * then the purchase. The purchase emits `DataBundleBoughtWithToken` as usual. + */ + buyDataBundleWithTransfer(eSIMWalletAddress: Address, dataBundleDetails: DataBundleDetails, asset: Hex, maxAmountIn: bigint, paymentReference: Hex) { + return _buyDataBundleWithTransferCalls(this.walletClient.extend(publicActions), eSIMWalletAddress, dataBundleDetails, asset, maxAmountIn, paymentReference); + } +} diff --git a/src/logic/README.md b/src/logic/README.md index 8b75b66..d8aca9f 100644 --- a/src/logic/README.md +++ b/src/logic/README.md @@ -14,6 +14,7 @@ Layout: [deviceWallet.ts](deviceWallet.ts) and [eSIMWallet.ts](eSIMWallet.ts). - [admin/](admin/) holds the EOA equivalents used by `KokioAdmin`; each function sends a direct transaction with the admin or owner account. +- [calls/](calls/) builds the calls a device wallet signs as one user operation, without sending them. The mobile functions send what these return, and `KokioAdmin` hands them to the backend so it can pass them to the app to sign. - [account-kit/createSmartAccount.ts](account-kit/createSmartAccount.ts) builds the ERC-4337 smart account and its client, and handles the passkey signing envelope. - [constants.ts](constants.ts) resolves chain-specific addresses and custom errors. diff --git a/src/logic/eSIMWallet.ts b/src/logic/eSIMWallet.ts index 31c046d..3b9cfc1 100644 --- a/src/logic/eSIMWallet.ts +++ b/src/logic/eSIMWallet.ts @@ -83,6 +83,7 @@ export const _buyDataBundleWithToken = async ( * * Only the shortfall is sent: the quote for the bundle minus what this eSIM * wallet already holds of `asset`. Arguments are as for `buyDataBundleWithToken`. + * The backend builds the same calls with `admin.calls.buyDataBundleWithTransfer`. */ export const _buyDataBundleWithTransfer = async ( client: KokioSmartAccountClient, diff --git a/tests/admin/kokioAdmin.test.ts b/tests/admin/kokioAdmin.test.ts index 06d08e9..3879149 100644 --- a/tests/admin/kokioAdmin.test.ts +++ b/tests/admin/kokioAdmin.test.ts @@ -121,6 +121,32 @@ describe("KokioAdmin setters", () => { }); }); +describe("KokioAdmin calls", () => { + const TOKEN = "0x0000000000000000000000000000000000706b31" as Address; + const bundle = { id: "0x0000000000000000000000000000000000000000000000000000000000000001" as Hex, priceUSDCents: 1000n, settlement: 0 }; + const asset = "0x5553444300000000000000000000000000000000000000000000000000000000" as Hex; + const ref = "0x000000000000000000000000000000000000000000000000000000000000ee11" as Hex; + const reads = { paymentAdapter: DEVICE_B, resolveAsset: { token: TOKEN }, quote: 100n, balanceOf: 40n }; + + it("builds buyDataBundleWithTransfer for the app to sign, sending nothing itself", async () => { + const client = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, reads }); + const calls = await new KokioAdmin(client).calls.buyDataBundleWithTransfer(ESIM_A, bundle, asset, 100n, ref); + + expect(calls.map((c) => c.to)).toEqual([TOKEN, ESIM_A]); + expect(client.writeContract).not.toHaveBeenCalled(); + }); + + it("builds through the new client after setWalletClient", async () => { + const first = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, reads }); + const second = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, reads }); + const admin = new KokioAdmin(first).setWalletClient(second); + + await admin.calls.buyDataBundleWithTransfer(ESIM_A, bundle, asset, 100n, ref); + expect(first.readContract).not.toHaveBeenCalled(); + expect(second.readContract).toHaveBeenCalled(); + }); +}); + describe("KokioAdmin constants", () => { it("resolves chain-specific constants for the connected chain, with no Pimlico key", async () => { const admin = new KokioAdmin(makeMockWalletClient({ chainId: CHAIN_ID, account: EOA })); From cac852e95c2cd407d1d27eba28e3a77a77743782 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:00:12 +0530 Subject: [PATCH 04/10] Run a top-up the backend builds and the app signs in the user flow --- tests/consumer/flows/userFlow.ts | 24 ++++++++++++++++++++++++ tests/consumer/userFlow.live.test.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index c8d4adc..2b8d60a 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -295,6 +295,30 @@ export const describeUserFlow = ( expect(await record(fiat, testBytes32("coin"), testBytes32(`rx2-${Date.now()}`))).toBe("AssetNotAllowed"); }, timeout); + it("a top-up the backend builds as calls and the app signs", async () => { + const ref = testBytes32(`fb1-${Date.now()}`); + // Some is already in the eSIM wallet, so the device wallet sends only the rest. + const held = quote / 2n; + await target.fund(token, db.eSIMWallet, held); + await target.fund(token, db.deviceWallet, quote - held); + + // Backend: the "buy" endpoint returns these calls to the app. + const calls = await admin.calls.buyDataBundleWithTransfer(db.eSIMWallet, bundle, asset, quote, ref); + expect(calls.map((call) => call.to)).toEqual([token, db.eSIMWallet]); + + // App: signs the calls as given, without knowing what they do. + const receipt = await sponsored("backend-built", () => session.deviceWallet!.sendUserOperation(calls)); + + // Backend: the purchase event is what its webhook receives. + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, + }); + expect(event.args).toMatchObject({ _dataBundleID: bundle.id, _asset: asset, _token: token, _amountSpent: quote }); + expect(await balanceOf(token, db.deviceWallet)).toBe(0n); + expect(await balanceOf(token, db.eSIMWallet)).toBe(0n); + }, timeout); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); diff --git a/tests/consumer/userFlow.live.test.ts b/tests/consumer/userFlow.live.test.ts index 9e47e35..50eb75f 100644 --- a/tests/consumer/userFlow.live.test.ts +++ b/tests/consumer/userFlow.live.test.ts @@ -8,8 +8,8 @@ import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; // The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and // the registry's real eSIM wallet admin as the backend. Sends real testnet -// transactions: the admin pays gas for four and sends 2 USDCt and 1 USDC to the -// new device wallet. Run with `npm run test:consumer:live`. +// transactions: the admin pays gas for four and sends 3 USDCt and 1 USDC to the +// new device and eSIM wallets. Run with `npm run test:consumer:live`. describeUserFlow("user flow on Base Sepolia with Pimlico", async () => { const live = await startLiveStack(); From 1cfe037555b95607da25998e7aa602fad0d746d2 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:00:57 +0530 Subject: [PATCH 05/10] Document admin.calls.buyDataBundleWithTransfer --- docs/README.md | 2 ++ docs/admin/calls.md | 47 ++++++++++++++++++++++++++++++++++++++ docs/mobile/esim-wallet.md | 2 ++ 3 files changed, 51 insertions(+) create mode 100644 docs/admin/calls.md diff --git a/docs/README.md b/docs/README.md index 9160111..3d7927a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,8 @@ ordinary transaction, no bundler or passkey involved. they had one, and copy their purchase history in. - [Payment adapter](admin/payment-adapter.md), `admin.paymentAdapter`. Manage the currencies data bundle purchases can be paid in. +- [Calls](admin/calls.md), `admin.calls`. Build the calls a device wallet + signs as one user operation, for the app to sign. - [Protocol admin](admin/protocol-admin.md), `admin.protocolAdmin`. The timelock that owns the contracts above. Schedule, execute, and cancel delayed admin calls. diff --git a/docs/admin/calls.md b/docs/admin/calls.md new file mode 100644 index 0000000..410f0d5 --- /dev/null +++ b/docs/admin/calls.md @@ -0,0 +1,47 @@ +# Calls + +`admin.calls` + +Builds the calls a user's device wallet signs as one user operation. Nothing is signed or sent: the backend returns the calls to the app, and the app passes them to `deviceWallet.sendUserOperation`. Use this when a mobile SDK method batches several calls and the backend wants to keep that logic on its side. + +Every method takes the target address as an argument, so one `KokioAdmin` builds calls for any user without `setESIMWalletAddress`. + +```ts +import { Settlement } from "kokio-sdk/types"; + +// Backend, in the "buy" endpoint +const calls = await admin.calls.buyDataBundleWithTransfer( + eSIMWalletAddress, + { id: bundleId, priceUSDCents: 500n, settlement: Settlement.DeviceWallet }, + asset, + maxAmountIn, + paymentReference, +); +return calls; // plain { to, data } strings, safe to send as JSON + +// App +const userOpHash = await kokio.deviceWallet!.sendUserOperation(calls); +``` + +## buyDataBundleWithTransfer + +Builds the same calls as `kokio.eSIMWallet.buyDataBundleWithTransfer`: an ERC-20 `transfer` from the device wallet for whatever the eSIM wallet is short of the quote, then `buyDataBundleWithToken`. When the eSIM wallet already holds enough, only the purchase is returned. Arguments after the address are the same as `buyDataBundleWithToken`. + +```ts +const asset = "0x5553444300000000000000000000000000000000000000000000000000000000"; // "USDC" as bytes32 +const maxAmountIn = await admin.paymentAdapter.quote(asset, 500n); + +const calls = await admin.calls.buyDataBundleWithTransfer( + eSIMWalletAddress, + { id: bundleId, priceUSDCents: 500n, settlement: Settlement.DeviceWallet }, + asset, + maxAmountIn, + paymentReference, +); +``` + +The shortfall is worked out from the eSIM wallet's balance and the quote when the calls are built. If either changes before the user signs, the operation reverts. On a retry, build the calls again with the same `paymentReference` instead of resending the old ones. + +The purchase emits `DataBundleBoughtWithToken` on the eSIM wallet, the same event as a plain `buyDataBundleWithToken`, so a webhook filtering on that event and `_paymentReference` needs no change. + +Returns: `Promise`, one or two `{ to, data }` entries. diff --git a/docs/mobile/esim-wallet.md b/docs/mobile/esim-wallet.md index 1695556..450b94b 100644 --- a/docs/mobile/esim-wallet.md +++ b/docs/mobile/esim-wallet.md @@ -57,6 +57,8 @@ The same purchase as `buyDataBundleWithToken`, for when the eSIM wallet has no a The device wallet must hold enough of the asset's token. It sends only the shortfall, so tokens already on the eSIM wallet are used first. +When the backend builds these calls with [`admin.calls.buyDataBundleWithTransfer`](../admin/calls.md), sign what it returns with `kokio.deviceWallet!.sendUserOperation(calls)` instead. + ```ts const hash = await kokio.eSIMWallet!.buyDataBundleWithTransfer( dataBundleDetails, asset, maxAmountIn, paymentReference, From 296d2e1971e2e07513a9c45cd196bbc000f5de60 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:11:18 +0530 Subject: [PATCH 06/10] Accept and bind a transferred eSIM wallet in one user operation The new device wallet no longer needs a separate operation for each step. --- src/interface/eSIMWalletClass.ts | 10 ++++++ src/logic/calls/eSIMWallet.calls.ts | 32 ++++++++++++++++- src/logic/eSIMWallet.ts | 20 +++++++++-- tests/logic/calls/eSIMWallet.calls.test.ts | 41 ++++++++++++++++++++-- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/interface/eSIMWalletClass.ts b/src/interface/eSIMWalletClass.ts index 3de4b86..106630a 100644 --- a/src/interface/eSIMWalletClass.ts +++ b/src/interface/eSIMWalletClass.ts @@ -1,5 +1,6 @@ import { Address, Hex } from "viem"; import { + _acceptAndBindESIMWallet, _acceptOwnershipTransfer, _buyDataBundleWithToken, _buyDataBundleWithTransfer, @@ -29,6 +30,15 @@ export class ESIMWalletSubPackage { return _acceptOwnershipTransfer(this.client, this.address); } + /** + * Accept this eSIM wallet's transfer and bind it to the signing device + * wallet in one user operation, optionally granting it access to that + * wallet's tokens too. + */ + acceptAndBindESIMWallet (options: { grantAccessToFunds?: boolean } = {}) { + return _acceptAndBindESIMWallet(this.client, this.address, options.grantAccessToFunds ?? false); + } + buyDataBundleWithToken (dataBundleDetails: DataBundleDetails, asset: Hex, maxAmountIn: bigint, paymentReference: Hex) { return _buyDataBundleWithToken(this.client, this.address, dataBundleDetails, asset, maxAmountIn, paymentReference); } diff --git a/src/logic/calls/eSIMWallet.calls.ts b/src/logic/calls/eSIMWallet.calls.ts index d81da4f..a1b363c 100644 --- a/src/logic/calls/eSIMWallet.calls.ts +++ b/src/logic/calls/eSIMWallet.calls.ts @@ -1,6 +1,6 @@ import { Address, Client, Hex, PublicActions, encodeFunctionData, erc20Abi } from "viem"; import { Call, DataBundleDetails } from "../../types.js"; -import { ESIMWallet, PaymentAdapter, Registry } from "../../abis/index.js"; +import { DeviceWallet, ESIMWallet, PaymentAdapter, Registry } from "../../abis/index.js"; import { _chainId, _getChainSpecificConstants } from "../constants.js"; // Builds the calls a device wallet signs as one user operation, without sending @@ -67,3 +67,33 @@ export const _buyDataBundleWithTransferCalls = async ( buy ]; } + +/** + * The calls for the new device wallet to take over an eSIM wallet another + * device wallet asked to hand it: accept the transfer, then bind it, which also + * tells the registry and clears the standby flag. + * + * Accepting first is what lets the bind through: it makes this device wallet + * the owner and clears the pending transfer. Fund access cannot be granted at + * bind time, so `grantAccessToFunds` adds a `toggleAccessToFunds` after it. + */ +export const _acceptAndBindESIMWalletCalls = ( + eSIMWalletAddress: Address, + deviceWalletAddress: Address, + grantAccessToFunds: boolean +): Call[] => { + + const self = (functionName: "addESIMWallet" | "toggleAccessToFunds", hasAccessToFunds: boolean) => ({ + to: deviceWalletAddress, + data: encodeFunctionData({ abi: DeviceWallet, functionName, args: [eSIMWalletAddress, hasAccessToFunds] }) + }); + + return [ + { + to: eSIMWalletAddress, + data: encodeFunctionData({ abi: ESIMWallet, functionName: "acceptOwnershipTransfer", args: [] }) + }, + self("addESIMWallet", false), + ...(grantAccessToFunds ? [self("toggleAccessToFunds", true)] : []) + ]; +} diff --git a/src/logic/eSIMWallet.ts b/src/logic/eSIMWallet.ts index 3b9cfc1..e0fe875 100644 --- a/src/logic/eSIMWallet.ts +++ b/src/logic/eSIMWallet.ts @@ -4,7 +4,7 @@ import { KokioSmartAccountClient } from "../types.js"; import { MissingSmartWalletError } from "./errors.js"; import { ESIMWallet } from "../abis/index.js"; import { _defaultPriceCapUSDCents } from "./registry.js"; -import { _buyDataBundleWithTransferCalls } from "./calls/eSIMWallet.calls.js"; +import { _acceptAndBindESIMWalletCalls, _buyDataBundleWithTransferCalls } from "./calls/eSIMWallet.calls.js"; // Not exposed on this surface: // - populateHistory and setESIMUniqueIdentifier are `onlyRegistry` - callable @@ -235,7 +235,23 @@ export const _acceptOwnershipTransfer = async (client: KokioSmartAccountClient, }); } -export const _sendETHToDeviceWallet = async (client: KokioSmartAccountClient, address: Address, amount: bigint) => { +/** + * Accept an eSIM wallet another device wallet asked to hand over, and bind it, + * in one user operation. `grantAccessToFunds` also lets it pull this device + * wallet's tokens. + */ +export const _acceptAndBindESIMWallet = async (client: KokioSmartAccountClient, address: Address, grantAccessToFunds: boolean) => { + + if(!client.account) throw new MissingSmartWalletError() + + // UserOp - the sender is the pending `newRequestedOwner`, and then the owner the bind needs. + return client.sendUserOperation({ + account: client.account, + calls: _acceptAndBindESIMWalletCalls(address, client.account.address, grantAccessToFunds) + }); +} + +export const _sendETHToDeviceWallet =async (client: KokioSmartAccountClient, address: Address, amount: bigint) => { if(!client.account) throw new MissingSmartWalletError() diff --git a/tests/logic/calls/eSIMWallet.calls.test.ts b/tests/logic/calls/eSIMWallet.calls.test.ts index 9a67549..a07a042 100644 --- a/tests/logic/calls/eSIMWallet.calls.test.ts +++ b/tests/logic/calls/eSIMWallet.calls.test.ts @@ -3,10 +3,14 @@ import { encodeFunctionData, erc20Abi, type Address, type Hex } from "viem"; import { makeMockSmartAccountClient, makeMockWalletClient } from "../../utils/mockClient.js"; import { baseSepoliaFactoryAddresses } from "../../../src/logic/constants.js"; -import { ESIMWallet } from "../../../src/abis/index.js"; +import { DeviceWallet, ESIMWallet } from "../../../src/abis/index.js"; import { Settlement, type DataBundleDetails } from "../../../src/types.js"; -import { _buyDataBundleWithTransferCalls, type CallBuilderClient } from "../../../src/logic/calls/eSIMWallet.calls.js"; -import { _buyDataBundleWithTransfer } from "../../../src/logic/eSIMWallet.js"; +import { + _acceptAndBindESIMWalletCalls, + _buyDataBundleWithTransferCalls, + type CallBuilderClient, +} from "../../../src/logic/calls/eSIMWallet.calls.js"; +import { _acceptAndBindESIMWallet, _buyDataBundleWithTransfer } from "../../../src/logic/eSIMWallet.js"; const ESIM = "0x00000000000000000000000000000000000e51a1" as Address; const ADAPTER = "0x00000000000000000000000000000000000ada91" as Address; @@ -63,6 +67,37 @@ describe("_buyDataBundleWithTransferCalls", () => { }); }); +const DEVICE = "0x000000000000000000000000000000000000acc7" as Address; +const ACCEPT = { to: ESIM, data: encodeFunctionData({ abi: ESIMWallet, functionName: "acceptOwnershipTransfer", args: [] }) }; +const BIND = { to: DEVICE, data: encodeFunctionData({ abi: DeviceWallet, functionName: "addESIMWallet", args: [ESIM, false] }) }; +const GRANT = { to: DEVICE, data: encodeFunctionData({ abi: DeviceWallet, functionName: "toggleAccessToFunds", args: [ESIM, true] }) }; + +describe("_acceptAndBindESIMWalletCalls", () => { + it("accepts the transfer before binding, since the bind needs this device wallet to own it", () => { + expect(_acceptAndBindESIMWalletCalls(ESIM, DEVICE, false)).toEqual([ACCEPT, BIND]); + }); + + it("grants fund access after the bind when asked", () => { + expect(_acceptAndBindESIMWalletCalls(ESIM, DEVICE, true)).toEqual([ACCEPT, BIND, GRANT]); + }); +}); + +describe("eSIMWallet._acceptAndBindESIMWallet", () => { + // The mock smart account signs as DEVICE. + it("binds to the signing device wallet", async () => { + const client = makeMockSmartAccountClient(); + await _acceptAndBindESIMWallet(client, ESIM, true); + + const send = client.sendUserOperation as unknown as ReturnType; + expect(send.mock.calls[0][0]).toEqual({ account: client.account, calls: [ACCEPT, BIND, GRANT] }); + }); + + it("throws MISSING_SMART_WALLET without an account", async () => { + const client = makeMockSmartAccountClient({ withAccount: false }); + await expect(_acceptAndBindESIMWallet(client, ESIM, false)).rejects.toThrow(/smart wallet/i); + }); +}); + describe("eSIMWallet._buyDataBundleWithTransfer", () => { it("sends the built calls as one user operation", async () => { const client = makeMockSmartAccountClient({ reads: reads(30n) }); From 6bb7def47b6f637c628782fb5e328d18b650e82d Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:11:42 +0530 Subject: [PATCH 07/10] Let the backend build accept-and-bind calls through admin.calls --- src/admin/interface/callsClass.ts | 11 ++++++++++- src/logic/eSIMWallet.ts | 3 ++- tests/admin/kokioAdmin.test.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/admin/interface/callsClass.ts b/src/admin/interface/callsClass.ts index b9de009..d842fde 100644 --- a/src/admin/interface/callsClass.ts +++ b/src/admin/interface/callsClass.ts @@ -1,6 +1,6 @@ import { Address, Hex, WalletClient, publicActions } from "viem"; import { DataBundleDetails } from "../../types.js"; -import { _buyDataBundleWithTransferCalls } from "../../logic/calls/eSIMWallet.calls.js"; +import { _acceptAndBindESIMWalletCalls, _buyDataBundleWithTransferCalls } from "../../logic/calls/eSIMWallet.calls.js"; /** * Builds the calls a user's device wallet signs as one user operation. Nothing @@ -24,4 +24,13 @@ export class AdminCallsSubPackage { buyDataBundleWithTransfer(eSIMWalletAddress: Address, dataBundleDetails: DataBundleDetails, asset: Hex, maxAmountIn: bigint, paymentReference: Hex) { return _buyDataBundleWithTransferCalls(this.walletClient.extend(publicActions), eSIMWalletAddress, dataBundleDetails, asset, maxAmountIn, paymentReference); } + + /** + * The calls `kokio.eSIMWallet.acceptAndBindESIMWallet` sends, for the device + * wallet named in `requestTransferOwnership` to sign. Build them once the + * `OwnershipTransferRequested` event names that device wallet as `_newOwner`. + */ + acceptAndBindESIMWallet(eSIMWalletAddress: Address, newDeviceWalletAddress: Address, options: { grantAccessToFunds?: boolean } = {}) { + return _acceptAndBindESIMWalletCalls(eSIMWalletAddress, newDeviceWalletAddress, options.grantAccessToFunds ?? false); + } } diff --git a/src/logic/eSIMWallet.ts b/src/logic/eSIMWallet.ts index e0fe875..2d8bdff 100644 --- a/src/logic/eSIMWallet.ts +++ b/src/logic/eSIMWallet.ts @@ -238,7 +238,8 @@ export const _acceptOwnershipTransfer = async (client: KokioSmartAccountClient, /** * Accept an eSIM wallet another device wallet asked to hand over, and bind it, * in one user operation. `grantAccessToFunds` also lets it pull this device - * wallet's tokens. + * wallet's tokens. The backend builds the same calls with + * `admin.calls.acceptAndBindESIMWallet`. */ export const _acceptAndBindESIMWallet = async (client: KokioSmartAccountClient, address: Address, grantAccessToFunds: boolean) => { diff --git a/tests/admin/kokioAdmin.test.ts b/tests/admin/kokioAdmin.test.ts index 3879149..6b19333 100644 --- a/tests/admin/kokioAdmin.test.ts +++ b/tests/admin/kokioAdmin.test.ts @@ -136,6 +136,16 @@ describe("KokioAdmin calls", () => { expect(client.writeContract).not.toHaveBeenCalled(); }); + it("builds acceptAndBindESIMWallet for the new device wallet, reading and sending nothing", () => { + const client = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA }); + const admin = new KokioAdmin(client); + + expect(admin.calls.acceptAndBindESIMWallet(ESIM_A, DEVICE_B).map((c) => c.to)).toEqual([ESIM_A, DEVICE_B]); + expect(admin.calls.acceptAndBindESIMWallet(ESIM_A, DEVICE_B, { grantAccessToFunds: true })).toHaveLength(3); + expect(client.readContract).not.toHaveBeenCalled(); + expect(client.writeContract).not.toHaveBeenCalled(); + }); + it("builds through the new client after setWalletClient", async () => { const first = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, reads }); const second = makeMockWalletClient({ chainId: CHAIN_ID, account: EOA, reads }); From 39910646db29b825e962dcb662dfc4aa842780ea Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:12:50 +0530 Subject: [PATCH 08/10] Move the eSIM to a new device in the user flow, with backend-built calls --- tests/consumer/flows/userFlow.ts | 36 ++++++++++++++++++++++++++-- tests/consumer/userFlow.live.test.ts | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index 2b8d60a..b7a757b 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -13,6 +13,7 @@ import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; import { createSoftSigner } from "../../utils/softP256Signer.js"; import { asPasskey } from "../fixtures/passkeyAuthenticator.js"; import { expectSponsored } from "../fixtures/sponsorship.js"; +import { createTestUser } from "../fixtures/user.js"; import { CREDENTIAL_ID, RP_ID, TEST_TAG, testBytes32, testDeviceId } from "../fixtures/testLabels.js"; /** Where the flow runs, and the few things that differ between a fork and Base Sepolia. */ @@ -319,11 +320,42 @@ export const describeUserFlow = ( expect(await balanceOf(token, db.eSIMWallet)).toBe(0n); }, timeout); + it("the eSIM moves to a new device, which signs calls the backend built", async () => { + // New device: its own passkey and device wallet, set up like steps 1 to 3. + const next = await createTestUser(target, passkeyGet); + await sponsored("new device", () => next.kokio.deviceWallet!.sendUserOperation([]), next.client); + await waitFor("new device", await admin.deviceWalletFactory.postCreateAccount(next.deviceWallet, next.uid, next.signer.ownerKey, next.salt)); + + // Old device: asks to hand the eSIM wallet over. + passkeyGet.mockImplementation(asPasskey(signer)); + const request = await sponsored("transfer request", () => session.eSIMWallet!.requestTransferOwnership(next.deviceWallet)); + expect(await admin.registry.isESIMWalletOnStandby(db.eSIMWallet)).toBe(true); + + // Backend: its webhook receives the request, which names the new device wallet. + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, abi: ESIMWallet, eventName: "OwnershipTransferRequested", + args: { _newOwner: next.deviceWallet }, fromBlock: request.receipt.blockNumber, + }); + expect(event.args._currentOwner).toBe(db.deviceWallet); + const calls = admin.calls.acceptAndBindESIMWallet(db.eSIMWallet, event.args._newOwner!, { grantAccessToFunds: true }); + + // New device: signs the calls as given. + passkeyGet.mockImplementation(asPasskey(next.signer)); + await sponsored("accept and bind", () => next.kokio.deviceWallet!.sendUserOperation(calls), next.client); + + expect(await admin.eSIMWallet!.owner()).toBe(next.deviceWallet); + expect(await admin.registry.isESIMWalletValid(db.eSIMWallet)).toBe(next.deviceWallet); + expect(await admin.registry.isESIMWalletOnStandby(db.eSIMWallet)).toBe(false); + expect(await next.kokio.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(true); + expect(await next.kokio.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(true); + expect(await session.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(false); + }, timeout); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); - const sponsored = async (step: string, send: () => Promise) => { - const receipt = await expectSponsored(client, target.publicClient, send, { confirmations: target.confirmations }); + const sponsored = async (step: string, send: () => Promise, sender: KokioSmartAccountClient = client) => { + const receipt = await expectSponsored(sender, target.publicClient, send, { confirmations: target.confirmations }); log(step, `user operation ${receipt.userOpHash}, transaction ${link(receipt.receipt.transactionHash)}`); return receipt; }; diff --git a/tests/consumer/userFlow.live.test.ts b/tests/consumer/userFlow.live.test.ts index 50eb75f..e78d35a 100644 --- a/tests/consumer/userFlow.live.test.ts +++ b/tests/consumer/userFlow.live.test.ts @@ -8,7 +8,7 @@ import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; // The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and // the registry's real eSIM wallet admin as the backend. Sends real testnet -// transactions: the admin pays gas for four and sends 3 USDCt and 1 USDC to the +// transactions: the admin pays gas for five and sends 3 USDCt and 1 USDC to the // new device and eSIM wallets. Run with `npm run test:consumer:live`. describeUserFlow("user flow on Base Sepolia with Pimlico", async () => { const live = await startLiveStack(); From 22e56e2e4faddc3988ea4248781c832944b5ec49 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:13:11 +0530 Subject: [PATCH 09/10] Document acceptAndBindESIMWallet for the app and the backend --- docs/admin/calls.md | 22 ++++++++++++++++++++++ docs/mobile/esim-wallet.md | 13 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/admin/calls.md b/docs/admin/calls.md index 410f0d5..52fcaa5 100644 --- a/docs/admin/calls.md +++ b/docs/admin/calls.md @@ -45,3 +45,25 @@ The shortfall is worked out from the eSIM wallet's balance and the quote when th The purchase emits `DataBundleBoughtWithToken` on the eSIM wallet, the same event as a plain `buyDataBundleWithToken`, so a webhook filtering on that event and `_paymentReference` needs no change. Returns: `Promise`, one or two `{ to, data }` entries. + +## acceptAndBindESIMWallet + +Builds the calls a new device wallet signs to take over an eSIM wallet: `acceptOwnershipTransfer` on the eSIM wallet, then `addESIMWallet` on the new device wallet, which also updates the registry and clears the standby flag. Pass `grantAccessToFunds: true` to add a `toggleAccessToFunds` after the bind. These are the same calls as `kokio.eSIMWallet.acceptAndBindESIMWallet`. + +Build them when the old device's `requestTransferOwnership` emits `OwnershipTransferRequested` on the eSIM wallet. Its `_newOwner` is the device wallet that has to sign, and the new device wallet must already be registered with `admin.deviceWalletFactory.postCreateAccount`. + +```ts +// Backend, when the webhook delivers OwnershipTransferRequested(_currentOwner, _newOwner) +const calls = admin.calls.acceptAndBindESIMWallet( + eSIMWalletAddress, + newDeviceWalletAddress, // _newOwner + { grantAccessToFunds: true }, +); + +// App, on the new device +const userOpHash = await kokio.deviceWallet!.sendUserOperation(calls); +``` + +The operation reverts if any other device wallet signs it, or if the old device cancelled the transfer first. + +Returns: `Call[]`, two or three `{ to, data }` entries. Nothing is read, so this one is synchronous. diff --git a/docs/mobile/esim-wallet.md b/docs/mobile/esim-wallet.md index 450b94b..9db7070 100644 --- a/docs/mobile/esim-wallet.md +++ b/docs/mobile/esim-wallet.md @@ -116,6 +116,19 @@ const hash = await kokio.eSIMWallet!.acceptOwnershipTransfer(); Returns: `Promise`. +## acceptAndBindESIMWallet + +Accepts the transfer and adds the eSIM wallet to this device wallet's list, in one user operation and one passkey prompt. Use it instead of `acceptOwnershipTransfer` followed by `deviceWallet.addESIMWallet`. Pass `grantAccessToFunds: true` to also let the eSIM wallet pull tokens from this device wallet. + +```ts +kokio.setESIMWalletAddress(eSIMWalletAddress); +const hash = await kokio.eSIMWallet!.acceptAndBindESIMWallet({ grantAccessToFunds: true }); +``` + +When the backend builds these calls with [`admin.calls.acceptAndBindESIMWallet`](../admin/calls.md), sign what it returns with `kokio.deviceWallet!.sendUserOperation(calls)` instead. + +Returns: `Promise`, a user operation hash. + ## sendETHToDeviceWallet Sends ETH held by this eSIM wallet back to its owning device wallet. Data From e16de25c0f55ff7a7774f5fdc47603be7f6cd22c Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 25 Sep 2026 08:17:52 +0530 Subject: [PATCH 10/10] Set package version to 3.2.0 --- 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 93981ed..8838161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio-sdk", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio-sdk", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "@noble/curves": "2.3.0", diff --git a/package.json b/package.json index 5361d1f..a07085c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kokio-sdk", - "version": "3.1.0", + "version": "3.2.0", "description": "", "type": "module", "main": "./dist/esm/config.js",