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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ ordinary transaction, no bundler or passkey involved.
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.
- [Utils](admin/utils.md), `admin.utils`. Check what a mined transaction paid, with its payment reference and price in cents.
- [Protocol admin](admin/protocol-admin.md), `admin.protocolAdmin`. The
timelock that owns the contracts above. Schedule, execute, and cancel
delayed admin calls.
Expand Down
95 changes: 95 additions & 0 deletions docs/admin/utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Utils

`admin.utils`

Checks what a mined transaction paid and how far its block has settled. Nothing is signed or sent. Both methods accept a transaction hash or a receipt, and all reads are sent together, so each check is one round trip.

A hash is the safer input when it comes from a user. A receipt is checked against the chain's block at its height, but its logs are used as given, so it is best taken from the backend's own node.

Prices are `bigint` cents, as in the contracts: `123456n` is \$1234.56.

```ts
const paid = await admin.utils.verifyProtocolPayment(txHash, "USDC", eSIMWalletAddress);
if (!paid.payments.some((p) => p.paymentReference === order.paymentReference)) return;

if (paid.finality === "finalized") issueESIM(order);
else markConfirming(order, paid.blockNumber, paid.blockHash); // checked again later
```

## Finality

A block on Base can be reorged out after it is mined, taking its payment with it. Both methods report how far the transaction's block has settled:

| `finality` | Meaning | Time behind the latest block on Base mainnet (measured) |
|---|---|---|
| `"latest"` | Mined, but can still be reorged out. | 0 |
| `"safe"` | Batch posted to L1. Only an L1 reorg can undo it. | about 1 minute |
| `"finalized"` | Batch finalized on L1. It cannot be undone. | about 19 minutes |

Anything that cannot be taken back, such as an eSIM, is best issued only on `"finalized"`. `"latest"` and `"safe"` are not errors. They can be used to show users that the payment is confirming. `blockNumber` and `blockHash` should be stored and checked again later for finality.

A receipt from a block that has since been reorged out throws `ReceiptNotCanonicalError`. Checking again by hash shows whether the payment is gone (`UnknownTransactionError`) or landed in a new block.

## verifyProtocolPayment

Lists every purchase an eSIM wallet paid for through the protocol in one currency, within one transaction. It works for `buyDataBundleWithToken` and `buyDataBundleWithTransfer`, including several purchases in one user operation and bundler transactions that carry other users' purchases.

The values come from the contracts' own events, the payment adapter's `PaymentSettled` and the eSIM wallet's `DataBundleBoughtWithToken`. Events from other contracts are ignored.

```ts
const result = await admin.utils.verifyProtocolPayment(
txHash, // or a receipt
"USDCt", // symbol as registered on the payment adapter, case-sensitive
eSIMWalletAddress, // the wallet that paid, not its device wallet
);
// { priceUSDCents: 500n, payments: [{ paymentReference, dataBundleId, priceUSDCents: 500n, amountSpent: 5000000n, vault }],
// finality: "finalized", blockNumber, blockHash }
```

The symbol is converted to the `bytes32` the contracts expect (`"USDC"` becomes `0x5553444300…`). With no matching purchase, `priceUSDCents` is `0n` and `payments` is empty.

Errors:

- `InvalidAddressError`: malformed or zero address. `InvalidSymbolError`: empty symbol, or one over 32 bytes. Both are checked before any read.
- `UnknownTransactionError`: malformed hash, or no mined transaction yet.
- `TransactionRevertedError`: the transaction reverted.
- `ReceiptNotCanonicalError`: the receipt's block was reorged out.
- `NotAProtocolESIMWalletError`: the registry does not know the address as an eSIM wallet, for example when a device wallet is passed.
- `TokenNotAcceptedError`: `reason` is `"NOT_REGISTERED"` for a symbol the adapter never had (a typo or the wrong case), or `"NOT_ONCHAIN"` for one with no token, such as `USD`. A symbol withdrawn after the payment still verifies.
- `UnmatchedPaymentEventsError`: the two events do not pair up. The current contracts never produce this.

The payment adapter address comes from the SDK's configuration for the chain, so a move with `registry.setPaymentAdapter` needs an SDK update.

Returns: `Promise<ProtocolPaymentCheck>`, `{ priceUSDCents, payments, finality, blockNumber, blockHash }`. `priceUSDCents` is the total, and each payment is `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`.

## verifyERC20Transfer

Adds up what one address sent another directly in any ERC-20, within one transaction. It suits tokens the protocol does not handle, or a single leg of a purchase, such as a device wallet funding its eSIM wallet.

```ts
const { priceUSDCents, amount, finality } = await admin.utils.verifyERC20Transfer(
txHash,
tokenAddress,
senderAddress,
destinationAddress,
);
```

Only `Transfer` events from the token itself, from `sender` to `destination`, are counted. Several transfers are added together, and amounts sent back are not subtracted. With none, both values are `0n`.

`priceUSDCents` treats one token as one dollar and rounds down, so `1_234_567n` of a 6-decimal token is `123n`. For tokens not worth a dollar, such as WETH, `amount` (in the token's smallest unit) is the meaningful value.

Errors:

- `InvalidAddressError`: malformed or zero address, or the same sender and destination.
- `UnknownTransactionError`, `TransactionRevertedError` and `ReceiptNotCanonicalError`, as above.
- `NotAnERC20TokenError`: the address does not answer `decimals()` and `totalSupply()`, such as an address with no code or an NFT contract. Network failures are passed on unchanged.
- `PriceOutOfRangeError`: the cents do not fit the contracts' `uint64`.

A token that moves balances without emitting `Transfer` reads as `0n`.

Returns: `Promise<ERC20TransferCheck>`, `{ priceUSDCents, amount, finality, blockNumber, blockHash }`.

## Fewer requests

Each check sends five reads at once. For RPC providers with request limits, a client created with `http(rpcUrl, { batch: true })` sends them as one HTTP request.
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.1",
"version": "3.3.0",
"description": "",
"type": "module",
"main": "./dist/esm/config.js",
Expand Down
15 changes: 15 additions & 0 deletions src/admin/config-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AdminDeviceWalletSubPackage } from "./interface/deviceWalletClass.js";
import { AdminESIMWalletSubPackage } from "./interface/eSIMWalletClass.js";
import { AdminPaymentAdapterSubPackage } from "./interface/paymentAdapterClass.js";
import { AdminCallsSubPackage } from "./interface/callsClass.js";
import { AdminUtilsSubPackage } from "./interface/utilsClass.js";

// Re-export the typed error surface so backend consumers can `instanceof
// KokioError` (or a subclass) and decode reverts without reaching into internal
Expand All @@ -27,6 +28,16 @@ export {
ESIMWalletNotLazyDeployedError,
MissingBatchEventError,
StalledBatchError,
InvalidAddressError,
InvalidSymbolError,
TokenNotAcceptedError,
NotAProtocolESIMWalletError,
UnknownTransactionError,
TransactionRevertedError,
ReceiptNotCanonicalError,
NotAnERC20TokenError,
UnmatchedPaymentEventsError,
PriceOutOfRangeError,
ContractRevertError,
decodeContractRevert,
} from "../logic/errors.js";
Expand Down Expand Up @@ -69,6 +80,8 @@ export class KokioAdmin {
paymentAdapter: AdminPaymentAdapterSubPackage;
/** Calls for the app to sign as a user operation, built here so the backend holds the purchase logic. */
calls: AdminCallsSubPackage;
/** Checks what a mined transaction paid, before the backend acts on it. */
utils: AdminUtilsSubPackage;

// Instance-scoped surfaces - undefined until their address is known.
deviceWallet?: AdminDeviceWalletSubPackage;
Expand All @@ -88,6 +101,7 @@ export class KokioAdmin {
this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient);
this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient);
this.calls = new AdminCallsSubPackage(walletClient);
this.utils = new AdminUtilsSubPackage(walletClient);

this.deviceWallet = deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, deviceWalletAddress) : undefined;
this.eSIMWallet = eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, eSIMWalletAddress) : undefined;
Expand Down Expand Up @@ -146,6 +160,7 @@ export class KokioAdmin {
this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient);
this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient);
this.calls = new AdminCallsSubPackage(walletClient);
this.utils = new AdminUtilsSubPackage(walletClient);

this.deviceWallet = this.deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, this.deviceWalletAddress) : undefined;
this.eSIMWallet = this.eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, this.eSIMWalletAddress) : undefined;
Expand Down
33 changes: 33 additions & 0 deletions src/admin/interface/utilsClass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Address, Hash, TransactionReceipt, WalletClient } from "viem";
import { _verifyERC20Transfer, _verifyProtocolPayment } from "../../logic/admin/utils/tokenTransfer.js";

/**
* Checks what a mined transaction actually paid. Nothing is sent. Pass a hash
* when it came from a user: a receipt is used as given, so it must come from
* your own node.
*/
export class AdminUtilsSubPackage {

walletClient: WalletClient;

constructor(walletClient: WalletClient) {
this.walletClient = walletClient;
}

/**
* The purchases `eSIMWallet` paid for through the protocol in `symbol`
* ("USDC", "USDCt"), with each one's `paymentReference` and price in cents.
* Throws if the symbol is not an onchain currency on the payment adapter.
*/
verifyProtocolPayment(transaction: Hash | TransactionReceipt, symbol: string, eSIMWallet: Address) {
return _verifyProtocolPayment(this.walletClient, transaction, symbol, eSIMWallet);
}

/**
* What `sender` sent `destination` directly in any ERC-20. The cents count
* one token as one dollar, so they mean nothing for a token that is not.
*/
verifyERC20Transfer(transaction: Hash | TransactionReceipt, token: Address, sender: Address, destination: Address) {
return _verifyERC20Transfer(this.walletClient, transaction, token, sender, destination);
}
}
Loading
Loading