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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions docs/admin/calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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<Call[]>`, 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.
15 changes: 15 additions & 0 deletions docs/mobile/esim-wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -114,6 +116,19 @@ const hash = await kokio.eSIMWallet!.acceptOwnershipTransfer();

Returns: `Promise<Hash>`.

## 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<Hash>`, a user operation hash.

## sendETHToDeviceWallet

Sends ETH held by this eSIM wallet back to its owning device wallet. Data
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.1.0",
"version": "3.2.0",
"description": "",
"type": "module",
"main": "./dist/esm/config.js",
Expand Down
5 changes: 5 additions & 0 deletions src/admin/config-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/admin/interface/callsClass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Address, Hex, WalletClient, publicActions } from "viem";
import { DataBundleDetails } from "../../types.js";
import { _acceptAndBindESIMWalletCalls, _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);
}

/**
* 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);
}
}
10 changes: 10 additions & 0 deletions src/interface/eSIMWalletClass.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Address, Hex } from "viem";
import {
_acceptAndBindESIMWallet,
_acceptOwnershipTransfer,
_buyDataBundleWithToken,
_buyDataBundleWithTransfer,
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/logic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
99 changes: 99 additions & 0 deletions src/logic/calls/eSIMWallet.calls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Address, Client, Hex, PublicActions, encodeFunctionData, erc20Abi } from "viem";
import { Call, DataBundleDetails } from "../../types.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
// 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<PublicActions, "readContract" | "getChainId"> & Pick<Client, "transport">;

/**
* 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<Call[]> => {

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<bigint>,
]);
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
];
}

/**
* 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)] : [])
];
}
Loading
Loading