From b7381ec06c1983ae924db75473b48b7c03e6eb5e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:40:18 +0530 Subject: [PATCH 01/12] Add the errors a transfer check can throw --- src/admin/config-admin.ts | 9 ++++ src/logic/errors.ts | 104 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 1bdfb35..b517185 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -27,6 +27,15 @@ export { ESIMWalletNotLazyDeployedError, MissingBatchEventError, StalledBatchError, + InvalidAddressError, + InvalidSymbolError, + TokenNotAcceptedError, + NotAProtocolESIMWalletError, + UnknownTransactionError, + TransactionRevertedError, + NotAnERC20TokenError, + UnmatchedPaymentEventsError, + PriceOutOfRangeError, ContractRevertError, decodeContractRevert, } from "../logic/errors.js"; diff --git a/src/logic/errors.ts b/src/logic/errors.ts index 389807a..a58798d 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -202,6 +202,110 @@ export class StalledBatchError extends KokioError { } } +/** An address argument is malformed, zero, or the same as the other side of the transfer. */ +export class InvalidAddressError extends KokioError { + readonly value: string; + + constructor(what: string, value: string, reason: string) { + super("INVALID_ADDRESS", `${what} ${value} ${reason}.`); + this.value = value; + } +} + +/** A currency symbol is empty or does not fit the contracts' 32-byte symbol. */ +export class InvalidSymbolError extends KokioError { + readonly symbol: string; + + constructor(symbol: string) { + super("INVALID_SYMBOL", `Symbol "${symbol}" must be 1 to 32 bytes long.`); + this.symbol = symbol; + } +} + +/** + * The payment adapter cannot have taken this symbol as an onchain payment: + * `NOT_REGISTERED` if it was never added, `NOT_ONCHAIN` for fiat and non-EVM + * entries, which have no token address. + */ +export class TokenNotAcceptedError extends KokioError { + readonly symbol: string; + readonly reason: "NOT_REGISTERED" | "NOT_ONCHAIN"; + + constructor(symbol: string, reason: "NOT_REGISTERED" | "NOT_ONCHAIN") { + super( + "TOKEN_NOT_ACCEPTED", + reason === "NOT_REGISTERED" + ? `Symbol "${symbol}" is not registered on the payment adapter.` + : `Symbol "${symbol}" has no token address on the payment adapter, so it cannot be paid onchain.`, + ); + this.symbol = symbol; + this.reason = reason; + } +} + +/** The registry has no record of this address as an eSIM wallet. */ +export class NotAProtocolESIMWalletError extends KokioError { + readonly address: string; + + constructor(address: string) { + super("NOT_A_PROTOCOL_ESIM_WALLET", `${address} is not an eSIM wallet the registry knows.`); + this.address = address; + } +} + +/** No mined transaction with this hash on the client's chain. */ +export class UnknownTransactionError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("UNKNOWN_TRANSACTION", `No mined transaction ${hash} on this chain.`); + this.hash = hash; + } +} + +/** The transaction was mined but reverted, so nothing in it moved. */ +export class TransactionRevertedError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("TRANSACTION_REVERTED", `Transaction ${hash} reverted.`); + this.hash = hash; + } +} + +/** The address does not answer `decimals()` and `totalSupply()` like an ERC-20. */ +export class NotAnERC20TokenError extends KokioError { + readonly token: string; + + constructor(token: string) { + super("NOT_AN_ERC20_TOKEN", `${token} is not an ERC-20 token on this chain.`); + this.token = token; + } +} + +/** + * A purchase's adapter event and eSIM wallet event disagree or do not pair up. + * The current contracts always emit them together, so this means they changed. + */ +export class UnmatchedPaymentEventsError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("UNMATCHED_PAYMENT_EVENTS", `Transaction ${hash} has payment events that do not pair up.`); + this.hash = hash; + } +} + +/** A cent figure too large for the uint64 the contracts price in. */ +export class PriceOutOfRangeError extends KokioError { + readonly priceUSDCents: bigint; + + constructor(priceUSDCents: bigint) { + super("PRICE_OUT_OF_RANGE", `${priceUSDCents} cents does not fit the contracts' uint64 price.`); + this.priceUSDCents = priceUSDCents; + } +} + // Every ABI that can surface a custom error from an on-chain revert. viem's // `decodeErrorResult` walks each ABI's `error` fragments to match the 4-byte // selector in the revert data. From 70fdd8d94dcd12aa558aa9085e3cb207b00f484f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:42:15 +0530 Subject: [PATCH 02/12] Verify protocol payments and ERC-20 transfers from a transaction --- src/logic/admin/utils/tokenTransfer.ts | 182 +++++++++++++++++++++++++ src/types-export.ts | 5 +- src/types.ts | 27 ++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/logic/admin/utils/tokenTransfer.ts diff --git a/src/logic/admin/utils/tokenTransfer.ts b/src/logic/admin/utils/tokenTransfer.ts new file mode 100644 index 0000000..edb0298 --- /dev/null +++ b/src/logic/admin/utils/tokenTransfer.ts @@ -0,0 +1,182 @@ +import { + Address, + BaseError, + ContractFunctionRevertedError, + ContractFunctionZeroDataError, + AbiDecodingDataSizeTooSmallError, + AbiDecodingZeroDataError, + Hash, + Hex, + TransactionReceipt, + TransactionReceiptNotFoundError, + WalletClient, + erc20Abi, + isAddress, + isAddressEqual, + isHash, + parseEventLogs, + publicActions, + stringToHex, + zeroAddress, +} from "viem"; +import { _chainId, _getChainSpecificConstants } from "../../constants.js"; +import { ESIMWallet, PaymentAdapter, Registry } from "../../../abis/index.js"; +import { + InvalidAddressError, + InvalidSymbolError, + NotAProtocolESIMWalletError, + NotAnERC20TokenError, + PriceOutOfRangeError, + TokenNotAcceptedError, + TransactionRevertedError, + UnknownTransactionError, + UnmatchedPaymentEventsError, +} from "../../errors.js"; +import { ERC20TransferCheck, ProtocolPaymentCheck } from "../../../types.js"; + +// The contracts price everything in uint64 cents. +const MAX_UINT64 = 2n ** 64n - 1n; + +const _checkAddress = (what: string, value: string): Address => { + if (!isAddress(value)) throw new InvalidAddressError(what, value, "is not a valid address"); + if (isAddressEqual(value, zeroAddress)) throw new InvalidAddressError(what, value, "is the zero address"); + return value; +} + +// Same bytes as Solidity's `bytes32("USDC")`: the text on the left, zeros after. +const _symbolToBytes32 = (symbol: string): Hex => { + if (!symbol) throw new InvalidSymbolError(symbol); + try { + return stringToHex(symbol, { size: 32 }); + } catch { + throw new InvalidSymbolError(symbol); + } +} + +const _checkTransaction = (transaction: Hash | TransactionReceipt) => { + if (typeof transaction === "string" && !isHash(transaction)) throw new UnknownTransactionError(transaction); +} + +// A receipt passed in is used as given, so it must come from the caller's own node. +const _receipt = async (client: WalletClient, transaction: Hash | TransactionReceipt): Promise => { + let receipt = transaction as TransactionReceipt; + if (typeof transaction === "string") { + try { + receipt = await client.extend(publicActions).getTransactionReceipt({ hash: transaction }); + } catch (err) { + if (err instanceof TransactionReceiptNotFoundError) throw new UnknownTransactionError(transaction); + throw err; + } + } + if (receipt.status !== "success") throw new TransactionRevertedError(receipt.transactionHash); + return receipt; +} + +/** + * Every purchase `eSIMWallet` paid for in `symbol` within one transaction, read + * from the payment adapter's `PaymentSettled` and the eSIM wallet's + * `DataBundleBoughtWithToken`. Cents and references are the contracts' own. + */ +export const _verifyProtocolPayment = async ( + client: WalletClient, + transaction: Hash | TransactionReceipt, + symbol: string, + eSIMWallet: Address, +): Promise => { + const wallet = _checkAddress("eSIM wallet", eSIMWallet); + const asset = _symbolToBytes32(symbol); + _checkTransaction(transaction); + + const publicClient = client.extend(publicActions); + const reads = async () => { + const F = _getChainSpecificConstants(await _chainId(client), client.transport.url).factoryAddresses; + const [deviceWallet, entry] = await Promise.all([ + publicClient.readContract({ address: F.REGISTRY, abi: Registry, functionName: "isESIMWalletValid", args: [wallet] }), + publicClient.readContract({ address: F.PAYMENT_ADAPTER, abi: PaymentAdapter, functionName: "assets", args: [asset] }), + ]); + return { adapter: F.PAYMENT_ADAPTER, deviceWallet, entry }; + }; + + const [receipt, { adapter, deviceWallet, entry: [, , decimals, token] }] = await Promise.all([_receipt(client, transaction), reads()]); + + if (deviceWallet === zeroAddress) throw new NotAProtocolESIMWalletError(wallet); + // A registered symbol always has non-zero decimals, even once withdrawn. + if (decimals === 0) throw new TokenNotAcceptedError(symbol, "NOT_REGISTERED"); + if (token === zeroAddress) throw new TokenNotAcceptedError(symbol, "NOT_ONCHAIN"); + + // Filtered on the emitting address, so a look-alike event from any other contract is ignored. + const settled = parseEventLogs({ abi: PaymentAdapter, eventName: "PaymentSettled", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, adapter) && log.args._symbol === asset && isAddressEqual(log.args._eSIMWallet, wallet)); + const bought = parseEventLogs({ abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, wallet) && log.args._asset === asset); + + // The adapter settles first and the wallet emits after, once per purchase. + if (settled.length !== bought.length) throw new UnmatchedPaymentEventsError(receipt.transactionHash); + const payments = settled.map((s, i) => { + const b = bought[i]; + if (s.logIndex > b.logIndex || s.args._priceUSDCents !== b.args._priceUSDCents || s.args._spent !== b.args._amountSpent) { + throw new UnmatchedPaymentEventsError(receipt.transactionHash); + } + return { + paymentReference: b.args._paymentReference, + dataBundleId: b.args._dataBundleID, + priceUSDCents: b.args._priceUSDCents, + amountSpent: b.args._amountSpent, + vault: s.args._vault, + }; + }); + + return { priceUSDCents: payments.reduce((sum, p) => sum + p.priceUSDCents, 0n), payments }; +} + +// A revert or an empty answer means the address is not an ERC-20. Anything else, like a +// network failure, is passed on as-is. +const _erc20Read = (read: Promise, token: Address): Promise => read.catch((err) => { + const notAToken = err instanceof BaseError && err.walk((e) => + e instanceof ContractFunctionRevertedError || + e instanceof ContractFunctionZeroDataError || + e instanceof AbiDecodingZeroDataError || + e instanceof AbiDecodingDataSizeTooSmallError); + throw notAToken ? new NotAnERC20TokenError(token) : err; +}); + +/** + * What `sender` sent `destination` directly in `token` within one transaction. + * Cents treat one token as one dollar, the way the payment adapter prices a + * dollar currency, so they mean nothing for a token that is not. + */ +export const _verifyERC20Transfer = async ( + client: WalletClient, + transaction: Hash | TransactionReceipt, + token: Address, + sender: Address, + destination: Address, +): Promise => { + const tokenAddress = _checkAddress("Token", token); + const from = _checkAddress("Sender", sender); + const to = _checkAddress("Destination", destination); + if (isAddressEqual(from, to)) throw new InvalidAddressError("Destination", to, "is the same as the sender"); + _checkTransaction(transaction); + + const publicClient = client.extend(publicActions); + + // decimals() is what an ERC-721 lacks, and an address with no code answers neither. + const [receipt, decimals] = await Promise.all([ + _receipt(client, transaction), + _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "decimals" }), tokenAddress), + _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply" }), tokenAddress), + ]); + + // Strict parsing skips an ERC-721 Transfer, whose third topic is the token id. + const amount = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, tokenAddress) && isAddressEqual(log.args.from, from) && isAddressEqual(log.args.to, to)) + .reduce((sum, log) => sum + log.args.value, 0n); + + // amount * 100 / 10^decimals, without the large intermediate. + const priceUSDCents = decimals >= 2 + ? amount / 10n ** BigInt(decimals - 2) + : amount * 10n ** BigInt(2 - decimals); + if (priceUSDCents > MAX_UINT64) throw new PriceOutOfRangeError(priceUSDCents); + + return { priceUSDCents, amount }; +} diff --git a/src/types-export.ts b/src/types-export.ts index eb1ba92..04b5724 100644 --- a/src/types-export.ts +++ b/src/types-export.ts @@ -15,7 +15,10 @@ export type { LazyDeploymentBatch, LazyDeployment, LazyHistoryBatch, - LazyHistoryCopy + LazyHistoryCopy, + ProtocolPayment, + ProtocolPaymentCheck, + ERC20TransferCheck } from './types.js'; export type { KokioConstants } from './interface/constantsClass.js'; diff --git a/src/types.ts b/src/types.ts index a701490..9bc4c5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,6 +174,33 @@ export type LazyHistoryCopy = { alreadyComplete: boolean; } +/** One data bundle an eSIM wallet paid for through the protocol. */ +export type ProtocolPayment = { + /** The offchain order id the purchase spent. */ + paymentReference: Hex; + dataBundleId: Hex; + /** 123456n is $1234.56. */ + priceUSDCents: bigint; + /** What reached the vault, in the token's smallest unit. */ + amountSpent: bigint; + vault: Address; +} + +/** Every protocol purchase one eSIM wallet made in one transaction and currency. */ +export type ProtocolPaymentCheck = { + /** Total across `payments`, 0n if there were none. */ + priceUSDCents: bigint; + payments: readonly ProtocolPayment[]; +} + +/** What one address sent another directly in one ERC-20, in one transaction. */ +export type ERC20TransferCheck = { + /** `amount` as cents, counting one token as one dollar and rounding down. */ + priceUSDCents: bigint; + /** In the token's smallest unit, 0n if nothing was sent. */ + amount: bigint; +} + export type SignedRequest = { body: string; stamp : { From 68c3339e4be36e332fd097e4de54d54cd9c5634f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:42:46 +0530 Subject: [PATCH 03/12] Expose the transfer checks as admin.utils --- src/admin/config-admin.ts | 5 +++++ src/admin/interface/utilsClass.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/admin/interface/utilsClass.ts diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index b517185..24c7a65 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -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 @@ -78,6 +79,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; @@ -97,6 +100,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; @@ -155,6 +159,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; diff --git a/src/admin/interface/utilsClass.ts b/src/admin/interface/utilsClass.ts new file mode 100644 index 0000000..b322ba7 --- /dev/null +++ b/src/admin/interface/utilsClass.ts @@ -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); + } +} From cca8ae94ff3a3ff442e47f233ce731122751725e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:44:48 +0530 Subject: [PATCH 04/12] Test the transfer checks against encoded logs --- tests/logic/admin/tokenTransfer.test.ts | 294 ++++++++++++++++++++++++ tests/utils/mockClient.ts | 8 +- 2 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 tests/logic/admin/tokenTransfer.test.ts diff --git a/tests/logic/admin/tokenTransfer.test.ts b/tests/logic/admin/tokenTransfer.test.ts new file mode 100644 index 0000000..7bab523 --- /dev/null +++ b/tests/logic/admin/tokenTransfer.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect } from "vitest"; +import { + type Abi, + type Address, + type Hex, + ContractFunctionZeroDataError, + HttpRequestError, + TransactionReceiptNotFoundError, + encodeAbiParameters, + encodeEventTopics, + erc20Abi, + getAddress, + parseAbi, + stringToHex, + zeroAddress, +} from "viem"; + +import { makeMockWalletClient } from "../../utils/mockClient.js"; +import { baseSepoliaFactoryAddresses } from "../../../src/logic/constants.js"; +import { ESIMWallet, PaymentAdapter } from "../../../src/abis/index.js"; +import { _verifyERC20Transfer, _verifyProtocolPayment } from "../../../src/logic/admin/utils/tokenTransfer.js"; +import { + InvalidAddressError, + InvalidSymbolError, + NotAProtocolESIMWalletError, + NotAnERC20TokenError, + PriceOutOfRangeError, + TokenNotAcceptedError, + TransactionRevertedError, + UnknownTransactionError, + UnmatchedPaymentEventsError, +} from "../../../src/logic/errors.js"; + +// --- Fixtures --------------------------------------------------------------- +const F = baseSepoliaFactoryAddresses; +const CHAIN_ID = 84532; +const HASH = "0x00000000000000000000000000000000000000000000000000000000000000a1" as Hex; + +const ESIM = "0x00000000000000000000000000000000000e51a1" as Address; +const OTHER_ESIM = "0x00000000000000000000000000000000000e51a2" as Address; +const DEVICE = "0x00000000000000000000000000000000000dead1" as Address; +// Checksummed, the way viem decodes an address out of an event. +const VAULT = getAddress("0x000000000000000000000000000000000000a017"); +const TOKEN = "0x0000000000000000000000000000000000706b31" as Address; +const SENDER = "0x0000000000000000000000000000000000005e0d" as Address; +const DESTINATION = "0x000000000000000000000000000000000000de57" as Address; +const STRANGER = "0x0000000000000000000000000000000000000bad" as Address; + +const SYMBOL = "USDCt"; +const ASSET = stringToHex(SYMBOL, { size: 32 }); +const REF_1 = stringToHex("order-1", { size: 32 }); +const REF_2 = stringToHex("order-2", { size: 32 }); +const BUNDLE = stringToHex("bundle", { size: 32 }); + +const erc721Abi = parseAbi(["event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"]); + +// Encodes a real log, so the SDK's parsing runs exactly as it does against a node. +let logIndex = 0; +const log = (address: Address, abi: Abi, eventName: string, args: Record) => { + const event = abi.find((item) => item.type === "event" && item.name === eventName) as { inputs: { name: string; type: string; indexed?: boolean }[] }; + const nonIndexed = event.inputs.filter((input) => !input.indexed); + return { + address, + topics: encodeEventTopics({ abi, eventName, args } as never), + data: encodeAbiParameters(nonIndexed, nonIndexed.map((input) => args[input.name])), + logIndex: logIndex++, + blockHash: HASH, blockNumber: 1n, transactionHash: HASH, transactionIndex: 0, removed: false, + }; +}; + +const receipt = (logs: unknown[], status = "success") => ({ status, transactionHash: HASH, logs }) as never; + +// One protocol purchase: the adapter settles, then the eSIM wallet records it. +const purchase = (opts: { wallet?: Address; asset?: Hex; ref?: Hex; cents?: bigint; spent?: bigint } = {}) => { + const { wallet = ESIM, asset = ASSET, ref = REF_1, cents = 500n, spent = cents * 10_000n } = opts; + return [ + log(F.PAYMENT_ADAPTER, PaymentAdapter, "PaymentSettled", { + _symbol: asset, _eSIMWallet: wallet, _vault: VAULT, _priceUSDCents: cents, _spent: spent, _refunded: 0n, + }), + log(wallet, ESIMWallet, "DataBundleBoughtWithToken", { + _dataBundleID: BUNDLE, _priceUSDCents: cents, _asset: asset, _token: TOKEN, _amountSpent: spent, _paymentReference: ref, + }), + ]; +}; + +const transfer = (token: Address, from: Address, to: Address, value: bigint) => + log(token, erc20Abi, "Transfer", { from, to, value }); + +const protocolClient = (reads: Record = {}, getReceipt?: () => unknown) => makeMockWalletClient({ + chainId: CHAIN_ID, + reads: { isESIMWalletValid: DEVICE, assets: [true, true, 6, TOKEN], ...reads }, + getReceipt, +}); + +const erc20Client = (reads: Record = {}, getReceipt?: () => unknown) => makeMockWalletClient({ + chainId: CHAIN_ID, + reads: { decimals: 6, totalSupply: 1_000_000n, ...reads }, + getReceipt, +}); + +// --- verifyProtocolPayment --------------------------------------------------- +describe("_verifyProtocolPayment", () => { + it("returns the contracts' cents, reference and vault for one purchase", async () => { + const result = await _verifyProtocolPayment(protocolClient(), receipt(purchase({ cents: 123_456n })), SYMBOL, ESIM); + + expect(result).toEqual({ + priceUSDCents: 123_456n, + payments: [{ paymentReference: REF_1, dataBundleId: BUNDLE, priceUSDCents: 123_456n, amountSpent: 1_234_560_000n, vault: VAULT }], + }); + }); + + it("passes the symbol to the adapter as the contracts' bytes32", async () => { + const client = protocolClient(); + await _verifyProtocolPayment(client, receipt(purchase()), SYMBOL, ESIM); + + expect(client.readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: F.PAYMENT_ADAPTER, functionName: "assets", args: ["0x5553444374000000000000000000000000000000000000000000000000000000"], + })); + }); + + it("totals several purchases by the wallet and skips other wallets and currencies", async () => { + const logs = [ + ...purchase({ ref: REF_1, cents: 500n }), + ...purchase({ wallet: OTHER_ESIM, cents: 900n }), + ...purchase({ asset: stringToHex("USDC", { size: 32 }), cents: 700n }), + ...purchase({ ref: REF_2, cents: 250n }), + ]; + const result = await _verifyProtocolPayment(protocolClient(), receipt(logs), SYMBOL, ESIM); + + expect(result.priceUSDCents).toBe(750n); + expect(result.payments.map((p) => p.paymentReference)).toEqual([REF_1, REF_2]); + }); + + it("ignores a look-alike settlement from a contract that is not the adapter", async () => { + const fake = log(STRANGER, PaymentAdapter, "PaymentSettled", { + _symbol: ASSET, _eSIMWallet: ESIM, _vault: STRANGER, _priceUSDCents: 99_999n, _spent: 1n, _refunded: 0n, + }); + const result = await _verifyProtocolPayment(protocolClient(), receipt([fake, ...purchase()]), SYMBOL, ESIM); + + expect(result.payments).toHaveLength(1); + expect(result.payments[0].vault).toBe(VAULT); + }); + + it("answers 0 cents and no payments when the wallet bought nothing", async () => { + const result = await _verifyProtocolPayment(protocolClient(), receipt([transfer(TOKEN, ESIM, VAULT, 5n)]), SYMBOL, ESIM); + expect(result).toEqual({ priceUSDCents: 0n, payments: [] }); + }); + + it("refuses events that do not pair up", async () => { + const [settled, bought] = purchase(); + await expect(_verifyProtocolPayment(protocolClient(), receipt([settled]), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + // The wallet's event landing before the adapter's. + const swapped = [{ ...bought, logIndex: settled.logIndex }, { ...settled, logIndex: bought.logIndex }]; + await expect(_verifyProtocolPayment(protocolClient(), receipt(swapped), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + + const [, otherPrice] = purchase({ cents: 501n, spent: 5_000_000n }); + await expect(_verifyProtocolPayment(protocolClient(), receipt([settled, otherPrice]), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + }); + + it("refuses an address the registry does not know as an eSIM wallet", async () => { + await expect(_verifyProtocolPayment(protocolClient({ isESIMWalletValid: zeroAddress }), receipt(purchase()), SYMBOL, DEVICE)) + .rejects.toBeInstanceOf(NotAProtocolESIMWalletError); + }); + + it("refuses a symbol never registered, and one with no token", async () => { + await expect(_verifyProtocolPayment(protocolClient({ assets: [false, false, 0, zeroAddress] }), receipt([]), "usdct", ESIM)) + .rejects.toThrow(expect.objectContaining({ constructor: TokenNotAcceptedError, reason: "NOT_REGISTERED" })); + await expect(_verifyProtocolPayment(protocolClient({ assets: [true, true, 2, zeroAddress] }), receipt([]), "USD", ESIM)) + .rejects.toThrow(expect.objectContaining({ constructor: TokenNotAcceptedError, reason: "NOT_ONCHAIN" })); + }); + + it("still verifies a symbol withdrawn after the payment", async () => { + const result = await _verifyProtocolPayment(protocolClient({ assets: [false, true, 6, TOKEN] }), receipt(purchase()), SYMBOL, ESIM); + expect(result.payments).toHaveLength(1); + }); + + it("refuses an empty symbol and one longer than 32 bytes before any read", async () => { + const client = protocolClient(); + await expect(_verifyProtocolPayment(client, receipt([]), "", ESIM)).rejects.toBeInstanceOf(InvalidSymbolError); + await expect(_verifyProtocolPayment(client, receipt([]), "X".repeat(33), ESIM)).rejects.toBeInstanceOf(InvalidSymbolError); + expect(client.readContract).not.toHaveBeenCalled(); + }); + + it("refuses a malformed or zero eSIM wallet", async () => { + await expect(_verifyProtocolPayment(protocolClient(), receipt([]), SYMBOL, "0x1234" as Address)) + .rejects.toBeInstanceOf(InvalidAddressError); + await expect(_verifyProtocolPayment(protocolClient(), receipt([]), SYMBOL, zeroAddress)) + .rejects.toBeInstanceOf(InvalidAddressError); + }); + + it("refuses a reverted transaction", async () => { + await expect(_verifyProtocolPayment(protocolClient(), receipt(purchase(), "reverted"), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(TransactionRevertedError); + }); + + it("looks a hash up, and sends every read before the receipt answers", async () => { + let answer!: (value: unknown) => void; + const client = protocolClient({}, () => new Promise((resolve) => { answer = resolve; })); + const pending = _verifyProtocolPayment(client, HASH, SYMBOL, ESIM); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.readContract).toHaveBeenCalledTimes(2); + answer(receipt(purchase())); + + expect((await pending).payments).toHaveLength(1); + }); + + it("reports a hash with no mined transaction, and a malformed hash without asking the node", async () => { + const client = protocolClient({}, () => { throw new TransactionReceiptNotFoundError({ hash: HASH }); }); + await expect(_verifyProtocolPayment(client, HASH, SYMBOL, ESIM)).rejects.toBeInstanceOf(UnknownTransactionError); + + const untouched = protocolClient(); + await expect(_verifyProtocolPayment(untouched, "0x1234" as Hex, SYMBOL, ESIM)).rejects.toBeInstanceOf(UnknownTransactionError); + expect(untouched.readContract).not.toHaveBeenCalled(); + }); +}); + +// --- verifyERC20Transfer ----------------------------------------------------- +describe("_verifyERC20Transfer", () => { + it("totals direct transfers from sender to destination and nothing else", async () => { + const logs = [ + transfer(TOKEN, SENDER, DESTINATION, 1_000_000n), + transfer(TOKEN, SENDER, DESTINATION, 500_000n), + transfer(TOKEN, STRANGER, DESTINATION, 7n), // someone else paying + transfer(TOKEN, SENDER, STRANGER, 7n), // sender paying someone else + transfer(TOKEN, DESTINATION, SENDER, 200_000n), // sent back, not subtracted + transfer(STRANGER, SENDER, DESTINATION, 9n), // look-alike from another contract + log(TOKEN, erc721Abi, "Transfer", { from: SENDER, to: DESTINATION, tokenId: 3n }), + ]; + const result = await _verifyERC20Transfer(erc20Client(), receipt(logs), TOKEN, SENDER, DESTINATION); + + expect(result).toEqual({ priceUSDCents: 150n, amount: 1_500_000n }); + }); + + it("rounds cents down and scales any number of decimals", async () => { + const one = (value: bigint) => receipt([transfer(TOKEN, SENDER, DESTINATION, value)]); + + expect(await _verifyERC20Transfer(erc20Client(), one(1_234_567n), TOKEN, SENDER, DESTINATION)) + .toEqual({ priceUSDCents: 123n, amount: 1_234_567n }); + expect((await _verifyERC20Transfer(erc20Client({ decimals: 18 }), one(12_345n * 10n ** 16n), TOKEN, SENDER, DESTINATION)).priceUSDCents) + .toBe(12_345n); + expect((await _verifyERC20Transfer(erc20Client({ decimals: 0 }), one(12n), TOKEN, SENDER, DESTINATION)).priceUSDCents) + .toBe(1_200n); + }); + + it("answers 0 when nothing was sent", async () => { + expect(await _verifyERC20Transfer(erc20Client(), receipt([]), TOKEN, SENDER, DESTINATION)) + .toEqual({ priceUSDCents: 0n, amount: 0n }); + }); + + it("refuses an address that does not answer like an ERC-20", async () => { + const empty = erc20Client({ decimals: () => { throw new ContractFunctionZeroDataError({ functionName: "decimals" }); } }); + await expect(_verifyERC20Transfer(empty, receipt([]), TOKEN, SENDER, DESTINATION)).rejects.toBeInstanceOf(NotAnERC20TokenError); + }); + + it("passes a network failure on rather than calling the address a non-token", async () => { + const down = erc20Client({ totalSupply: () => { throw new HttpRequestError({ url: "https://rpc.test.invalid" }); } }); + await expect(_verifyERC20Transfer(down, receipt([]), TOKEN, SENDER, DESTINATION)).rejects.toBeInstanceOf(HttpRequestError); + }); + + it("refuses a total too large for the contracts' uint64 cents", async () => { + const huge = receipt([transfer(TOKEN, SENDER, DESTINATION, 2n ** 64n)]); + await expect(_verifyERC20Transfer(erc20Client({ decimals: 2 }), huge, TOKEN, SENDER, DESTINATION)) + .rejects.toBeInstanceOf(PriceOutOfRangeError); + }); + + it("refuses zero, malformed and matching addresses before any read", async () => { + const client = erc20Client(); + const cases: [Address, Address, Address][] = [ + [zeroAddress, SENDER, DESTINATION], + [TOKEN, zeroAddress, DESTINATION], + [TOKEN, SENDER, zeroAddress], + [TOKEN, "0xnothex" as Address, DESTINATION], + [TOKEN, SENDER, SENDER], + ]; + for (const [token, from, to] of cases) { + await expect(_verifyERC20Transfer(client, receipt([]), token, from, to)).rejects.toBeInstanceOf(InvalidAddressError); + } + expect(client.readContract).not.toHaveBeenCalled(); + }); + + it("refuses a reverted transaction", async () => { + await expect(_verifyERC20Transfer(erc20Client(), receipt([], "reverted"), TOKEN, SENDER, DESTINATION)) + .rejects.toBeInstanceOf(TransactionRevertedError); + }); + + it("looks a hash up alongside the token reads", async () => { + const client = erc20Client({}, () => receipt([transfer(TOKEN, SENDER, DESTINATION, 10_000n)])); + expect((await _verifyERC20Transfer(client, HASH, TOKEN, SENDER, DESTINATION)).priceUSDCents).toBe(1n); + }); +}); diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index 6058a7b..690e43a 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -26,8 +26,10 @@ export const makeMockWalletClient = (opts: { simulate?: () => unknown; /** What `writeContract` does. Throw from here to exercise a revert path. */ write?: () => unknown; + /** What `getTransactionReceipt` does, for logic that looks a mined transaction up by hash. */ + getReceipt?: () => unknown; }): WalletClient => { - const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write } = opts; + const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write, getReceipt } = opts; // Each write gets its own hash so a test driving several batches can tell them // apart and check the order they were sent in. @@ -60,6 +62,10 @@ export const makeMockWalletClient = (opts: { return receipt; }), simulateContract: vi.fn(async () => (simulate ? simulate() : { result: undefined })), + getTransactionReceipt: vi.fn(async () => { + if (!getReceipt) throw new Error("Mock client has no getTransactionReceipt result"); + return getReceipt(); + }), }; client.extend = () => client; From fd0dd3deb98e42a0bb880d46f5f40390385b6803 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:47:27 +0530 Subject: [PATCH 05/12] Check each purchase in the user flow the way the backend would --- tests/consumer/flows/userFlow.ts | 65 +++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index b7a757b..0e06adb 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -6,7 +6,7 @@ import { import { createBundlerClient } from "viem/account-abstraction"; import { baseSepolia } from "viem/chains"; import { ContractRevertError, Kokio } from "kokio-sdk"; -import type { KokioAdmin } from "kokio-sdk/admin"; +import { NotAProtocolESIMWalletError, NotAnERC20TokenError, type KokioAdmin } from "kokio-sdk/admin"; import { ESIMWallet, ESIMWalletFactory, Registry } from "kokio-sdk/abis"; import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; @@ -190,6 +190,43 @@ export const describeUserFlow = ( _token: token, _amountSpent: quote, }); + purchaseTx = event.transactionHash; + }, timeout); + + let purchaseTx: Hex; + + it("the backend checks what the purchase paid from its transaction hash", async () => { + const paid = await admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.eSIMWallet); + log("7", `protocol payment ${paid.priceUSDCents} cents, reference ${paid.payments[0]?.paymentReference}, no transaction`); + + expect(paid).toEqual({ + priceUSDCents: bundle.priceUSDCents, + payments: [{ paymentReference: REF_1, dataBundleId: bundle.id, priceUSDCents: bundle.priceUSDCents, amountSpent: quote, vault: await admin.registry.vault() }], + }); + + // The same transaction as a plain ERC-20 transfer: the device wallet funding its eSIM wallet. + const sent = await admin.utils.verifyERC20Transfer(purchaseTx, token, db.deviceWallet, db.eSIMWallet); + expect(sent).toEqual({ priceUSDCents: bundle.priceUSDCents, amount: quote }); + + // The device wallet is not who pays the protocol, so asking with it is refused. + await expect(admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.deviceWallet)) + .rejects.toBeInstanceOf(NotAProtocolESIMWalletError); + }, timeout); + + it("the backend's checks refuse a token or symbol the chain does not back", async () => { + const { factoryAddresses } = await admin.constants; + + // A contract that is not a token, and an address with no code at all. + const noCode = testBytes32("no-code").slice(0, 42) as Address; + expect(await target.publicClient.getCode({ address: noCode })).toBeUndefined(); + for (const notAToken of [factoryAddresses.REGISTRY as Address, noCode]) { + await expect(admin.utils.verifyERC20Transfer(purchaseTx, notAToken, db.deviceWallet, db.eSIMWallet)) + .rejects.toBeInstanceOf(NotAnERC20TokenError); + } + await expect(admin.utils.verifyProtocolPayment(purchaseTx, "USD", db.eSIMWallet)) + .rejects.toThrow(expect.objectContaining({ reason: "NOT_ONCHAIN" })); + await expect(admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset.toLowerCase(), db.eSIMWallet)) + .rejects.toThrow(expect.objectContaining({ reason: "NOT_REGISTERED" })); }, timeout); const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; @@ -234,6 +271,13 @@ export const describeUserFlow = ( args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, }); expect(event.args).toMatchObject({ _asset: other, _token: otherToken, _amountSpent: otherQuote }); + + // Looked up by this symbol, the purchase is there. By the first one, it is not. + const paid = await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, symbol, db.eSIMWallet); + expect(paid.payments.map((p) => p.paymentReference)).toEqual([ref]); + expect(paid.priceUSDCents).toBe(bundle.priceUSDCents); + expect(await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, primaryAsset, db.eSIMWallet)) + .toEqual({ priceUSDCents: 0n, payments: [] }); }, timeout); }); @@ -318,6 +362,25 @@ export const describeUserFlow = ( 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); + + // The protocol was paid the full price, though the device wallet sent only the shortfall. + const hash = receipt.receipt.transactionHash; + expect((await admin.utils.verifyProtocolPayment(hash, primaryAsset, db.eSIMWallet)).priceUSDCents).toBe(bundle.priceUSDCents); + expect((await admin.utils.verifyERC20Transfer(hash, token, db.deviceWallet, db.eSIMWallet)).amount).toBe(quote - held); + }, timeout); + + it("two purchases in one user operation are reported one by one", async () => { + const refs = [testBytes32(`fc1-${Date.now()}`), testBytes32(`fc2-${Date.now()}`)]; + await target.fund(token, db.deviceWallet, quote * 2n); + + // Each call set sends the full quote, since both are built while the eSIM wallet holds nothing. + const calls = (await Promise.all(refs.map((ref) => admin.calls.buyDataBundleWithTransfer(db.eSIMWallet, bundle, asset, quote, ref)))).flat(); + const receipt = await sponsored("two in one", () => session.deviceWallet!.sendUserOperation(calls)); + + const paid = await admin.utils.verifyProtocolPayment(receipt.receipt, primaryAsset, db.eSIMWallet); + log("two in one", `${paid.payments.length} payments, ${paid.priceUSDCents} cents in all`); + expect(paid.payments.map((p) => p.paymentReference)).toEqual(refs); + expect(paid.priceUSDCents).toBe(bundle.priceUSDCents * 2n); }, timeout); it("the eSIM moves to a new device, which signs calls the backend built", async () => { From 67b04e16fb32e04bb18e6ecb704ff314370fdb83 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:48:20 +0530 Subject: [PATCH 06/12] Document admin.utils --- docs/README.md | 1 + docs/admin/utils.md | 77 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/admin/utils.md diff --git a/docs/README.md b/docs/README.md index 3d7927a..1f567ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/admin/utils.md b/docs/admin/utils.md new file mode 100644 index 0000000..1a2ba60 --- /dev/null +++ b/docs/admin/utils.md @@ -0,0 +1,77 @@ +# Utils + +`admin.utils` + +Checks what a mined transaction actually paid, before the backend acts on it. Nothing is signed or sent. Both methods take a transaction hash or a receipt, and every read they need goes out at once, so a check costs one round trip to the node (none for the receipt when you pass one). + +Pass the hash when it came from a user. A receipt is used as given, so only pass one your own node returned. + +Prices come back as `bigint` cents, the same format the contracts use: `123456n` is \$1234.56. + +```ts +// Backend, when the app reports a purchase +const { priceUSDCents, payments } = await admin.utils.verifyProtocolPayment(txHash, "USDC", eSIMWalletAddress); +if (payments.some((p) => p.paymentReference === order.paymentReference)) markPaid(order); +``` + +## verifyProtocolPayment + +Lists every purchase an eSIM wallet paid for through the protocol in one currency, within one transaction. Use it after `buyDataBundleWithToken` or `buyDataBundleWithTransfer`, including a user operation that bought several bundles, or a bundler transaction that also carries other users' purchases. + +The price, amount and reference come from the contracts' own events: the payment adapter's `PaymentSettled` and the eSIM wallet's `DataBundleBoughtWithToken`. Events from any other contract are ignored. + +```ts +const result = await admin.utils.verifyProtocolPayment( + txHash, // or the receipt your node returned + "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 }] } +``` + +The symbol is turned into the `bytes32` the contracts take (`"USDC"` becomes `0x5553444300…`), so pass it as text. A transaction with no matching purchase answers `{ priceUSDCents: 0n, payments: [] }`. + +It throws: + +- `InvalidAddressError` for a malformed or zero address, and `InvalidSymbolError` for an empty symbol or one over 32 bytes. Both are checked before anything is read. +- `UnknownTransactionError` for a malformed hash, or one with no mined transaction yet. Nothing waits for it. +- `TransactionRevertedError` if the transaction reverted. +- `NotAProtocolESIMWalletError` if the registry does not know the address as an eSIM wallet. Passing the device wallet lands here. +- `TokenNotAcceptedError` with `reason: "NOT_REGISTERED"` for a symbol the adapter never had (a typo, or the wrong case), and `"NOT_ONCHAIN"` for one with no token, such as `USD`. A symbol withdrawn after the payment still verifies, since the contract accepted it at the time. +- `UnmatchedPaymentEventsError` if the two events do not pair up, which the current contracts never produce. + +It reads the payment adapter at the address the SDK has for the chain. After `registry.setPaymentAdapter` moves it, update the SDK. + +Returns: `Promise`, `{ priceUSDCents, payments }`, with `priceUSDCents` the total and each payment `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`. + +## verifyERC20Transfer + +Adds up what one address sent another directly in any ERC-20, within one transaction. Use it for a token the protocol does not handle, or to check a single leg of a purchase, such as the device wallet funding its eSIM wallet in `buyDataBundleWithTransfer`. + +```ts +const { priceUSDCents, amount } = await admin.utils.verifyERC20Transfer( + txHash, + tokenAddress, + senderAddress, + destinationAddress, +); +``` + +Only `Transfer` events emitted by the token itself count, and only from `sender` to `destination`. Several transfers are added together, and anything sent back is not subtracted. A transaction with none answers `{ priceUSDCents: 0n, amount: 0n }`. + +`priceUSDCents` counts one token as one dollar, the way the payment adapter prices a dollar currency, and rounds down: `1_234_567n` of a 6-decimal token is `123n`. For a token that is not worth a dollar, such as WETH, ignore it and use `amount`, which is in the token's smallest unit. + +It throws: + +- `InvalidAddressError` for a malformed or zero address, or a sender that is also the destination. +- `UnknownTransactionError` and `TransactionRevertedError`, as above. +- `NotAnERC20TokenError` if the address does not answer `decimals()` and `totalSupply()`. That covers an address with no code and an NFT contract. A network failure is passed on as-is instead. +- `PriceOutOfRangeError` if the cents do not fit the contracts' `uint64`. + +A token that moves balances without emitting `Transfer` reads as `0n`, since there is nothing in the receipt to find. + +Returns: `Promise`, `{ priceUSDCents, amount }`. + +## Fewer requests + +Each check sends two or three reads at the same moment. If your RPC provider limits requests per second, create the client with `http(rpcUrl, { batch: true })` and viem sends them as one HTTP request. From 743dd06249892208581d344deabb858ebd33141a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 27 Sep 2026 23:48:43 +0530 Subject: [PATCH 07/12] Set package version to 3.3.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 bd21af3..6263e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "license": "MIT", "dependencies": { "@noble/curves": "2.3.0", diff --git a/package.json b/package.json index 89ceeb4..9eae2a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "description": "", "type": "module", "main": "./dist/esm/config.js", From 88daf4318867763063f05b9cbbd14d040d90b3a4 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 28 Sep 2026 00:05:35 +0530 Subject: [PATCH 08/12] Add an error for a receipt from a reorged block --- src/admin/config-admin.ts | 1 + src/logic/errors.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 24c7a65..6788e5e 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -34,6 +34,7 @@ export { NotAProtocolESIMWalletError, UnknownTransactionError, TransactionRevertedError, + ReceiptNotCanonicalError, NotAnERC20TokenError, UnmatchedPaymentEventsError, PriceOutOfRangeError, diff --git a/src/logic/errors.ts b/src/logic/errors.ts index a58798d..7c688d0 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -273,6 +273,24 @@ export class TransactionRevertedError extends KokioError { } } +/** + * A receipt passed in is from a block the chain no longer has at that height, + * so it was reorged out. The payment may have landed again elsewhere. + */ +export class ReceiptNotCanonicalError extends KokioError { + readonly hash: Hex; + readonly blockHash: Hex; + + constructor(hash: Hex, blockHash: Hex) { + super( + "RECEIPT_NOT_CANONICAL", + `The receipt for ${hash} is from block ${blockHash}, which is no longer on the chain. Check again by hash.`, + ); + this.hash = hash; + this.blockHash = blockHash; + } +} + /** The address does not answer `decimals()` and `totalSupply()` like an ERC-20. */ export class NotAnERC20TokenError extends KokioError { readonly token: string; From 14a43f232ec307186e6d1f04dfa72bdc25874e96 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 28 Sep 2026 00:07:27 +0530 Subject: [PATCH 09/12] Report how far a checked transaction's block has settled --- src/logic/admin/utils/tokenTransfer.ts | 41 +++++++++++-- src/types-export.ts | 2 + src/types.ts | 17 +++++- tests/logic/admin/tokenTransfer.test.ts | 76 ++++++++++++++++++++++--- tests/utils/mockClient.ts | 8 ++- 5 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/logic/admin/utils/tokenTransfer.ts b/src/logic/admin/utils/tokenTransfer.ts index edb0298..722f368 100644 --- a/src/logic/admin/utils/tokenTransfer.ts +++ b/src/logic/admin/utils/tokenTransfer.ts @@ -27,12 +27,13 @@ import { NotAProtocolESIMWalletError, NotAnERC20TokenError, PriceOutOfRangeError, + ReceiptNotCanonicalError, TokenNotAcceptedError, TransactionRevertedError, UnknownTransactionError, UnmatchedPaymentEventsError, } from "../../errors.js"; -import { ERC20TransferCheck, ProtocolPaymentCheck } from "../../../types.js"; +import { ERC20TransferCheck, Finality, ProtocolPaymentCheck } from "../../../types.js"; // The contracts price everything in uint64 cents. const MAX_UINT64 = 2n ** 64n - 1n; @@ -72,6 +73,31 @@ const _receipt = async (client: WalletClient, transaction: Hash | TransactionRec return receipt; } +// The receipt plus how far its block has settled, read alongside it. A receipt passed in +// is also checked against the chain's block at its height, since it may be from a block +// since reorged out. One fetched by hash comes from the chain as it is now. +const _receiptWithFinality = async (client: WalletClient, transaction: Hash | TransactionReceipt) => { + const publicClient = client.extend(publicActions); + const given = typeof transaction === "string" ? undefined : transaction; + + const [receipt, safe, finalized, canonical] = await Promise.all([ + _receipt(client, transaction), + publicClient.getBlock({ blockTag: "safe" }), + publicClient.getBlock({ blockTag: "finalized" }), + given && publicClient.getBlock({ blockNumber: given.blockNumber }), + ]); + + if (canonical && canonical.hash !== receipt.blockHash) { + throw new ReceiptNotCanonicalError(receipt.transactionHash, receipt.blockHash); + } + + const finality: Finality = receipt.blockNumber <= finalized.number ? "finalized" + : receipt.blockNumber <= safe.number ? "safe" + : "latest"; + + return { receipt, landed: { finality, blockNumber: receipt.blockNumber, blockHash: receipt.blockHash } }; +} + /** * Every purchase `eSIMWallet` paid for in `symbol` within one transaction, read * from the payment adapter's `PaymentSettled` and the eSIM wallet's @@ -97,7 +123,10 @@ export const _verifyProtocolPayment = async ( return { adapter: F.PAYMENT_ADAPTER, deviceWallet, entry }; }; - const [receipt, { adapter, deviceWallet, entry: [, , decimals, token] }] = await Promise.all([_receipt(client, transaction), reads()]); + const [{ receipt, landed }, { adapter, deviceWallet, entry: [, , decimals, token] }] = await Promise.all([ + _receiptWithFinality(client, transaction), + reads(), + ]); if (deviceWallet === zeroAddress) throw new NotAProtocolESIMWalletError(wallet); // A registered symbol always has non-zero decimals, even once withdrawn. @@ -126,7 +155,7 @@ export const _verifyProtocolPayment = async ( }; }); - return { priceUSDCents: payments.reduce((sum, p) => sum + p.priceUSDCents, 0n), payments }; + return { priceUSDCents: payments.reduce((sum, p) => sum + p.priceUSDCents, 0n), payments, ...landed }; } // A revert or an empty answer means the address is not an ERC-20. Anything else, like a @@ -161,8 +190,8 @@ export const _verifyERC20Transfer = async ( const publicClient = client.extend(publicActions); // decimals() is what an ERC-721 lacks, and an address with no code answers neither. - const [receipt, decimals] = await Promise.all([ - _receipt(client, transaction), + const [{ receipt, landed }, decimals] = await Promise.all([ + _receiptWithFinality(client, transaction), _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "decimals" }), tokenAddress), _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply" }), tokenAddress), ]); @@ -178,5 +207,5 @@ export const _verifyERC20Transfer = async ( : amount * 10n ** BigInt(2 - decimals); if (priceUSDCents > MAX_UINT64) throw new PriceOutOfRangeError(priceUSDCents); - return { priceUSDCents, amount }; + return { priceUSDCents, amount, ...landed }; } diff --git a/src/types-export.ts b/src/types-export.ts index 04b5724..f39152a 100644 --- a/src/types-export.ts +++ b/src/types-export.ts @@ -16,6 +16,8 @@ export type { LazyDeployment, LazyHistoryBatch, LazyHistoryCopy, + Finality, + TransactionFinality, ProtocolPayment, ProtocolPaymentCheck, ERC20TransferCheck diff --git a/src/types.ts b/src/types.ts index 9bc4c5d..78d3b18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,15 +186,28 @@ export type ProtocolPayment = { vault: Address; } +/** + * How far a block has settled. `latest` can still be reorged out, `safe` only by + * an L1 reorg, and `finalized` not at all. + */ +export type Finality = "latest" | "safe" | "finalized"; + +/** Where a checked transaction landed, to store and check again later. */ +export type TransactionFinality = { + finality: Finality; + blockNumber: bigint; + blockHash: Hex; +} + /** Every protocol purchase one eSIM wallet made in one transaction and currency. */ -export type ProtocolPaymentCheck = { +export type ProtocolPaymentCheck = TransactionFinality & { /** Total across `payments`, 0n if there were none. */ priceUSDCents: bigint; payments: readonly ProtocolPayment[]; } /** What one address sent another directly in one ERC-20, in one transaction. */ -export type ERC20TransferCheck = { +export type ERC20TransferCheck = TransactionFinality & { /** `amount` as cents, counting one token as one dollar and rounding down. */ priceUSDCents: bigint; /** In the token's smallest unit, 0n if nothing was sent. */ diff --git a/tests/logic/admin/tokenTransfer.test.ts b/tests/logic/admin/tokenTransfer.test.ts index 7bab523..88efc7b 100644 --- a/tests/logic/admin/tokenTransfer.test.ts +++ b/tests/logic/admin/tokenTransfer.test.ts @@ -25,6 +25,7 @@ import { NotAProtocolESIMWalletError, NotAnERC20TokenError, PriceOutOfRangeError, + ReceiptNotCanonicalError, TokenNotAcceptedError, TransactionRevertedError, UnknownTransactionError, @@ -68,7 +69,22 @@ const log = (address: Address, abi: Abi, eventName: string, args: Record ({ status, transactionHash: HASH, logs }) as never; +// Every receipt lands in block 100. The chain below has it finalized unless a test says otherwise. +const BLOCK = 100n; +const BLOCK_HASH = "0x00000000000000000000000000000000000000000000000000000000000b0c64" as Hex; +const LANDED = { finality: "finalized", blockNumber: BLOCK, blockHash: BLOCK_HASH } as const; + +const receipt = (logs: unknown[], status = "success") => + ({ status, transactionHash: HASH, blockNumber: BLOCK, blockHash: BLOCK_HASH, logs }) as never; + +type Chain = { safe: bigint; finalized: bigint; hashAt?: Hex }; +const chainReads = ({ safe, finalized, hashAt = BLOCK_HASH }: Chain) => + ({ blockTag, blockNumber }: { blockTag?: string; blockNumber?: bigint }) => { + if (blockTag === "safe") return { number: safe }; + if (blockTag === "finalized") return { number: finalized }; + return { number: blockNumber, hash: hashAt }; + }; +const FINAL_CHAIN: Chain = { safe: 190n, finalized: 150n }; // One protocol purchase: the adapter settles, then the eSIM wallet records it. const purchase = (opts: { wallet?: Address; asset?: Hex; ref?: Hex; cents?: bigint; spent?: bigint } = {}) => { @@ -86,16 +102,18 @@ const purchase = (opts: { wallet?: Address; asset?: Hex; ref?: Hex; cents?: bigi const transfer = (token: Address, from: Address, to: Address, value: bigint) => log(token, erc20Abi, "Transfer", { from, to, value }); -const protocolClient = (reads: Record = {}, getReceipt?: () => unknown) => makeMockWalletClient({ +const protocolClient = (reads: Record = {}, getReceipt?: () => unknown, chain = FINAL_CHAIN) => makeMockWalletClient({ chainId: CHAIN_ID, reads: { isESIMWalletValid: DEVICE, assets: [true, true, 6, TOKEN], ...reads }, getReceipt, + getBlock: chainReads(chain), }); -const erc20Client = (reads: Record = {}, getReceipt?: () => unknown) => makeMockWalletClient({ +const erc20Client = (reads: Record = {}, getReceipt?: () => unknown, chain = FINAL_CHAIN) => makeMockWalletClient({ chainId: CHAIN_ID, reads: { decimals: 6, totalSupply: 1_000_000n, ...reads }, getReceipt, + getBlock: chainReads(chain), }); // --- verifyProtocolPayment --------------------------------------------------- @@ -106,6 +124,7 @@ describe("_verifyProtocolPayment", () => { expect(result).toEqual({ priceUSDCents: 123_456n, payments: [{ paymentReference: REF_1, dataBundleId: BUNDLE, priceUSDCents: 123_456n, amountSpent: 1_234_560_000n, vault: VAULT }], + ...LANDED, }); }); @@ -143,7 +162,7 @@ describe("_verifyProtocolPayment", () => { it("answers 0 cents and no payments when the wallet bought nothing", async () => { const result = await _verifyProtocolPayment(protocolClient(), receipt([transfer(TOKEN, ESIM, VAULT, 5n)]), SYMBOL, ESIM); - expect(result).toEqual({ priceUSDCents: 0n, payments: [] }); + expect(result).toEqual({ priceUSDCents: 0n, payments: [], ...LANDED }); }); it("refuses events that do not pair up", async () => { @@ -218,6 +237,49 @@ describe("_verifyProtocolPayment", () => { }); }); +// --- Finality, for both checks ----------------------------------------------- +describe("how far the transaction's block has settled", () => { + const checks = [ + // Each check is given a receipt, or a hash when one is passed. + ["_verifyProtocolPayment", (chain: Chain, hash?: Hex) => + _verifyProtocolPayment(protocolClient({}, () => receipt(purchase()), chain), hash ?? receipt(purchase()), SYMBOL, ESIM)], + ["_verifyERC20Transfer", (chain: Chain, hash?: Hex) => + _verifyERC20Transfer(erc20Client({}, () => receipt([]), chain), hash ?? receipt([]), TOKEN, SENDER, DESTINATION)], + ] as const; + + describe.each(checks)("%s", (_, check) => { + it("reports latest, safe or finalized against the chain's own tags, inclusive", async () => { + expect(await check({ safe: 99n, finalized: 90n })).toMatchObject({ finality: "latest", blockNumber: BLOCK, blockHash: BLOCK_HASH }); + expect((await check({ safe: 100n, finalized: 99n })).finality).toBe("safe"); + expect((await check({ safe: 100n, finalized: 100n })).finality).toBe("finalized"); + }); + + it("refuses a receipt passed in from a block since reorged out", async () => { + const reorged = { ...FINAL_CHAIN, hashAt: "0x00000000000000000000000000000000000000000000000000000000000000ff" as Hex }; + await expect(check(reorged)).rejects.toBeInstanceOf(ReceiptNotCanonicalError); + }); + + it("trusts a receipt fetched by hash without reading its block again", async () => { + // A reorged block at that height would be caught if it were read, so passing proves it was not. + const reorged = { ...FINAL_CHAIN, hashAt: "0x00000000000000000000000000000000000000000000000000000000000000ff" as Hex }; + expect((await check(reorged, HASH)).finality).toBe("finalized"); + }); + }); + + it("reads both tags before the receipt answers", async () => { + let answer!: (value: unknown) => void; + const client = erc20Client({}, () => new Promise((resolve) => { answer = resolve; })); + const pending = _verifyERC20Transfer(client, HASH, TOKEN, SENDER, DESTINATION); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.getBlock).toHaveBeenCalledWith({ blockTag: "safe" }); + expect(client.getBlock).toHaveBeenCalledWith({ blockTag: "finalized" }); + answer(receipt([])); + + expect((await pending).finality).toBe("finalized"); + }); +}); + // --- verifyERC20Transfer ----------------------------------------------------- describe("_verifyERC20Transfer", () => { it("totals direct transfers from sender to destination and nothing else", async () => { @@ -232,14 +294,14 @@ describe("_verifyERC20Transfer", () => { ]; const result = await _verifyERC20Transfer(erc20Client(), receipt(logs), TOKEN, SENDER, DESTINATION); - expect(result).toEqual({ priceUSDCents: 150n, amount: 1_500_000n }); + expect(result).toEqual({ priceUSDCents: 150n, amount: 1_500_000n, ...LANDED }); }); it("rounds cents down and scales any number of decimals", async () => { const one = (value: bigint) => receipt([transfer(TOKEN, SENDER, DESTINATION, value)]); expect(await _verifyERC20Transfer(erc20Client(), one(1_234_567n), TOKEN, SENDER, DESTINATION)) - .toEqual({ priceUSDCents: 123n, amount: 1_234_567n }); + .toMatchObject({ priceUSDCents: 123n, amount: 1_234_567n }); expect((await _verifyERC20Transfer(erc20Client({ decimals: 18 }), one(12_345n * 10n ** 16n), TOKEN, SENDER, DESTINATION)).priceUSDCents) .toBe(12_345n); expect((await _verifyERC20Transfer(erc20Client({ decimals: 0 }), one(12n), TOKEN, SENDER, DESTINATION)).priceUSDCents) @@ -248,7 +310,7 @@ describe("_verifyERC20Transfer", () => { it("answers 0 when nothing was sent", async () => { expect(await _verifyERC20Transfer(erc20Client(), receipt([]), TOKEN, SENDER, DESTINATION)) - .toEqual({ priceUSDCents: 0n, amount: 0n }); + .toEqual({ priceUSDCents: 0n, amount: 0n, ...LANDED }); }); it("refuses an address that does not answer like an ERC-20", async () => { diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index 690e43a..da44dd9 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -28,8 +28,10 @@ export const makeMockWalletClient = (opts: { write?: () => unknown; /** What `getTransactionReceipt` does, for logic that looks a mined transaction up by hash. */ getReceipt?: () => unknown; + /** What `getBlock` does, called with its args, for logic that reads a block tag or height. */ + getBlock?: (args: { blockTag?: string; blockNumber?: bigint }) => unknown; }): WalletClient => { - const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write, getReceipt } = opts; + const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write, getReceipt, getBlock } = opts; // Each write gets its own hash so a test driving several batches can tell them // apart and check the order they were sent in. @@ -66,6 +68,10 @@ export const makeMockWalletClient = (opts: { if (!getReceipt) throw new Error("Mock client has no getTransactionReceipt result"); return getReceipt(); }), + getBlock: vi.fn(async (args: { blockTag?: string; blockNumber?: bigint }) => { + if (!getBlock) throw new Error("Mock client has no getBlock result"); + return getBlock(args); + }), }; client.extend = () => client; From ffbcbbac87e912b8dd99d57c39e6f65ff16b6667 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 28 Sep 2026 00:21:00 +0530 Subject: [PATCH 10/12] Check finality and a real reorg in the user flow --- tests/consumer/fixtures/forkFlowTarget.ts | 3 ++ tests/consumer/flows/userFlow.ts | 51 ++++++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tests/consumer/fixtures/forkFlowTarget.ts b/tests/consumer/fixtures/forkFlowTarget.ts index b86ac89..693e219 100644 --- a/tests/consumer/fixtures/forkFlowTarget.ts +++ b/tests/consumer/fixtures/forkFlowTarget.ts @@ -36,6 +36,9 @@ export const startForkFlowTarget = async (): Promise => { fund: (token, to, amount) => setTokenBalance(stack.fork, token, to, amount), priceUSDCents: 500n, confirmations: 1, + mine: async (blocks) => { await stack.fork.testClient.mine({ blocks }); }, + // anvil mines `depth` fresh blocks in place of the old ones, replaying none of their transactions. + reorg: async (depth) => { await stack.fork.testClient.request({ method: "anvil_reorg", params: [depth, []] } as never); }, stop: stack.stop, }; }; diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index 0e06adb..5598c1e 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -1,12 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; import { createWalletClient, encodeAbiParameters, erc20Abi, http, keccak256, stringToHex, - type Address, type Hex, type PublicClient, + type Address, type Hex, type PublicClient, type TransactionReceipt, } from "viem"; import { createBundlerClient } from "viem/account-abstraction"; import { baseSepolia } from "viem/chains"; import { ContractRevertError, Kokio } from "kokio-sdk"; -import { NotAProtocolESIMWalletError, NotAnERC20TokenError, type KokioAdmin } from "kokio-sdk/admin"; +import { + NotAProtocolESIMWalletError, NotAnERC20TokenError, ReceiptNotCanonicalError, UnknownTransactionError, type KokioAdmin, +} from "kokio-sdk/admin"; import { ESIMWallet, ESIMWalletFactory, Registry } from "kokio-sdk/abis"; import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; @@ -33,6 +35,10 @@ export interface FlowTarget { priceUSDCents: bigint; /** Blocks to wait after each write before reading its result. */ confirmations: number; + /** Mines empty blocks, where the chain allows it. Left out on a live chain. */ + mine?: (blocks: number) => Promise; + /** Replaces the last `depth` blocks with empty ones. Left out on a live chain. */ + reorg?: (depth: number) => Promise; /** Block explorer transaction URL prefix, so logged hashes can be opened. */ explorerTx?: string; stop?: () => Promise; @@ -191,28 +197,45 @@ export const describeUserFlow = ( _amountSpent: quote, }); purchaseTx = event.transactionHash; + purchaseBlockHash = event.blockHash; }, timeout); let purchaseTx: Hex; + let purchaseBlockHash: Hex; it("the backend checks what the purchase paid from its transaction hash", async () => { const paid = await admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.eSIMWallet); - log("7", `protocol payment ${paid.priceUSDCents} cents, reference ${paid.payments[0]?.paymentReference}, no transaction`); + log("7", `protocol payment ${paid.priceUSDCents} cents, reference ${paid.payments[0]?.paymentReference}, ${paid.finality}, no transaction`); + // Just landed, so not yet settled on L1: the backend waits before issuing the eSIM. expect(paid).toEqual({ priceUSDCents: bundle.priceUSDCents, payments: [{ paymentReference: REF_1, dataBundleId: bundle.id, priceUSDCents: bundle.priceUSDCents, amountSpent: quote, vault: await admin.registry.vault() }], + finality: "latest", + blockNumber: purchaseBlock, + blockHash: purchaseBlockHash, }); // The same transaction as a plain ERC-20 transfer: the device wallet funding its eSIM wallet. const sent = await admin.utils.verifyERC20Transfer(purchaseTx, token, db.deviceWallet, db.eSIMWallet); - expect(sent).toEqual({ priceUSDCents: bundle.priceUSDCents, amount: quote }); + expect(sent).toEqual({ priceUSDCents: bundle.priceUSDCents, amount: quote, finality: "latest", blockNumber: purchaseBlock, blockHash: purchaseBlockHash }); // The device wallet is not who pays the protocol, so asking with it is refused. await expect(admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.deviceWallet)) .rejects.toBeInstanceOf(NotAProtocolESIMWalletError); }, timeout); + it("once the purchase's block is finalized, the same check says so", async (ctx) => { + // Base Sepolia takes about half an hour to finalize, too long to wait for here. + if (!target.mine) return ctx.skip(); + // anvil finalizes 64 blocks behind the latest. + await target.mine(64); + + const paid = await admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.eSIMWallet); + log("7", `after 64 blocks the payment is ${paid.finality}, no transaction`); + expect(paid).toMatchObject({ finality: "finalized", blockNumber: purchaseBlock, blockHash: purchaseBlockHash }); + }, timeout); + it("the backend's checks refuse a token or symbol the chain does not back", async () => { const { factoryAddresses } = await admin.constants; @@ -277,7 +300,7 @@ export const describeUserFlow = ( expect(paid.payments.map((p) => p.paymentReference)).toEqual([ref]); expect(paid.priceUSDCents).toBe(bundle.priceUSDCents); expect(await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, primaryAsset, db.eSIMWallet)) - .toEqual({ priceUSDCents: 0n, payments: [] }); + .toMatchObject({ priceUSDCents: 0n, payments: [] }); }, timeout); }); @@ -378,6 +401,7 @@ export const describeUserFlow = ( const receipt = await sponsored("two in one", () => session.deviceWallet!.sendUserOperation(calls)); const paid = await admin.utils.verifyProtocolPayment(receipt.receipt, primaryAsset, db.eSIMWallet); + lastPurchase = receipt.receipt; log("two in one", `${paid.payments.length} payments, ${paid.priceUSDCents} cents in all`); expect(paid.payments.map((p) => p.paymentReference)).toEqual(refs); expect(paid.priceUSDCents).toBe(bundle.priceUSDCents * 2n); @@ -414,6 +438,23 @@ export const describeUserFlow = ( expect(await session.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(false); }, timeout); + let lastPurchase: TransactionReceipt; + + // Last, since it rewrites the chain under everything above. + it("a receipt kept from before a reorg is refused once its block is gone", async (ctx) => { + if (!target.reorg) return ctx.skip(); + const latest = await target.publicClient.getBlockNumber(); + await target.reorg(Number(latest - lastPurchase.blockNumber + 1n)); + + await expect(admin.utils.verifyProtocolPayment(lastPurchase, primaryAsset, db.eSIMWallet)) + .rejects.toBeInstanceOf(ReceiptNotCanonicalError); + + // Looked up by hash, the payment is either gone or in a new block, never the old one. + const again = await admin.utils.verifyProtocolPayment(lastPurchase.transactionHash, primaryAsset, db.eSIMWallet).catch((e: unknown) => e); + if (again instanceof UnknownTransactionError) return; + expect((again as { blockHash: Hex }).blockHash).not.toBe(lastPurchase.blockHash); + }, timeout); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); From 6c3daeabc9261b5ab2d6e186ceb7aa06292bf3f8 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 28 Sep 2026 00:21:00 +0530 Subject: [PATCH 11/12] Document the finality the transfer checks report --- docs/admin/utils.md | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/admin/utils.md b/docs/admin/utils.md index 1a2ba60..f2fd526 100644 --- a/docs/admin/utils.md +++ b/docs/admin/utils.md @@ -2,18 +2,37 @@ `admin.utils` -Checks what a mined transaction actually paid, before the backend acts on it. Nothing is signed or sent. Both methods take a transaction hash or a receipt, and every read they need goes out at once, so a check costs one round trip to the node (none for the receipt when you pass one). +Checks what a mined transaction actually paid, and how far its block has settled, before the backend acts on it. Nothing is signed or sent. Both methods take a transaction hash or a receipt, and every read they need goes out at once, so a check costs one round trip to the node. -Pass the hash when it came from a user. A receipt is used as given, so only pass one your own node returned. +Pass the hash when it came from a user. A receipt passed in is checked against the chain's block at its height, but its logs are used as given, so only pass one your own node returned. Prices come back as `bigint` cents, the same format the contracts use: `123456n` is \$1234.56. ```ts // Backend, when the app reports a purchase -const { priceUSDCents, payments } = await admin.utils.verifyProtocolPayment(txHash, "USDC", eSIMWalletAddress); -if (payments.some((p) => p.paymentReference === order.paymentReference)) markPaid(order); +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); // check again later ``` +## Finality + +Base can drop a block it has already shown you, and a payment in that block is gone with it. Both methods say how far the transaction's block has settled: + +| `finality` | Meaning | Behind the latest block on Base mainnet, measured | +|---|---|---| +| `"latest"` | Mined, but can still be reorged out. | 0 | +| `"safe"` | Its batch is posted to L1. Only an L1 reorg can undo it. | about 1 minute | +| `"finalized"` | L1 has finalized the batch. It cannot be undone. | about 19 minutes | + +Issue anything you cannot take back, such as an eSIM, only on `"finalized"`. A check that answers `"latest"` or `"safe"` is not an error: show the user the payment is confirming, keep `blockNumber` and `blockHash`, and check again later. Both come back with every result. + +Waiting for more blocks is not the same thing on an L2. The node's `safe` and `finalized` tags are what count, and both are read alongside the receipt. + +A receipt passed in from a block that has since been reorged out throws `ReceiptNotCanonicalError`. Check again by hash: the payment is either gone (`UnknownTransactionError`) or in a new block. + ## verifyProtocolPayment Lists every purchase an eSIM wallet paid for through the protocol in one currency, within one transaction. Use it after `buyDataBundleWithToken` or `buyDataBundleWithTransfer`, including a user operation that bought several bundles, or a bundler transaction that also carries other users' purchases. @@ -26,30 +45,32 @@ const result = await admin.utils.verifyProtocolPayment( "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 }] } +// { priceUSDCents: 500n, payments: [{ paymentReference, dataBundleId, priceUSDCents: 500n, amountSpent: 5000000n, vault }], +// finality: "finalized", blockNumber, blockHash } ``` -The symbol is turned into the `bytes32` the contracts take (`"USDC"` becomes `0x5553444300…`), so pass it as text. A transaction with no matching purchase answers `{ priceUSDCents: 0n, payments: [] }`. +The symbol is turned into the `bytes32` the contracts take (`"USDC"` becomes `0x5553444300…`), so pass it as text. A transaction with no matching purchase answers `priceUSDCents: 0n` and `payments: []`. It throws: - `InvalidAddressError` for a malformed or zero address, and `InvalidSymbolError` for an empty symbol or one over 32 bytes. Both are checked before anything is read. - `UnknownTransactionError` for a malformed hash, or one with no mined transaction yet. Nothing waits for it. - `TransactionRevertedError` if the transaction reverted. +- `ReceiptNotCanonicalError` for a receipt passed in from a block since reorged out. See [Finality](#finality). - `NotAProtocolESIMWalletError` if the registry does not know the address as an eSIM wallet. Passing the device wallet lands here. - `TokenNotAcceptedError` with `reason: "NOT_REGISTERED"` for a symbol the adapter never had (a typo, or the wrong case), and `"NOT_ONCHAIN"` for one with no token, such as `USD`. A symbol withdrawn after the payment still verifies, since the contract accepted it at the time. - `UnmatchedPaymentEventsError` if the two events do not pair up, which the current contracts never produce. It reads the payment adapter at the address the SDK has for the chain. After `registry.setPaymentAdapter` moves it, update the SDK. -Returns: `Promise`, `{ priceUSDCents, payments }`, with `priceUSDCents` the total and each payment `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`. +Returns: `Promise`, `{ priceUSDCents, payments, finality, blockNumber, blockHash }`, with `priceUSDCents` the total and each payment `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`. ## verifyERC20Transfer Adds up what one address sent another directly in any ERC-20, within one transaction. Use it for a token the protocol does not handle, or to check a single leg of a purchase, such as the device wallet funding its eSIM wallet in `buyDataBundleWithTransfer`. ```ts -const { priceUSDCents, amount } = await admin.utils.verifyERC20Transfer( +const { priceUSDCents, amount, finality } = await admin.utils.verifyERC20Transfer( txHash, tokenAddress, senderAddress, @@ -57,21 +78,21 @@ const { priceUSDCents, amount } = await admin.utils.verifyERC20Transfer( ); ``` -Only `Transfer` events emitted by the token itself count, and only from `sender` to `destination`. Several transfers are added together, and anything sent back is not subtracted. A transaction with none answers `{ priceUSDCents: 0n, amount: 0n }`. +Only `Transfer` events emitted by the token itself count, and only from `sender` to `destination`. Several transfers are added together, and anything sent back is not subtracted. A transaction with none answers `priceUSDCents: 0n` and `amount: 0n`. `priceUSDCents` counts one token as one dollar, the way the payment adapter prices a dollar currency, and rounds down: `1_234_567n` of a 6-decimal token is `123n`. For a token that is not worth a dollar, such as WETH, ignore it and use `amount`, which is in the token's smallest unit. It throws: - `InvalidAddressError` for a malformed or zero address, or a sender that is also the destination. -- `UnknownTransactionError` and `TransactionRevertedError`, as above. +- `UnknownTransactionError`, `TransactionRevertedError` and `ReceiptNotCanonicalError`, as above. - `NotAnERC20TokenError` if the address does not answer `decimals()` and `totalSupply()`. That covers an address with no code and an NFT contract. A network failure is passed on as-is instead. - `PriceOutOfRangeError` if the cents do not fit the contracts' `uint64`. A token that moves balances without emitting `Transfer` reads as `0n`, since there is nothing in the receipt to find. -Returns: `Promise`, `{ priceUSDCents, amount }`. +Returns: `Promise`, `{ priceUSDCents, amount, finality, blockNumber, blockHash }`. ## Fewer requests -Each check sends two or three reads at the same moment. If your RPC provider limits requests per second, create the client with `http(rpcUrl, { batch: true })` and viem sends them as one HTTP request. +Each check sends five reads at the same moment. If your RPC provider limits requests per second, create the client with `http(rpcUrl, { batch: true })` and viem sends them as one HTTP request. From a2f068993aeb3ec8aaaf0d3d94ea2e1231eaee48 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 28 Sep 2026 00:30:23 +0530 Subject: [PATCH 12/12] Reword the utils docs in a plainer tone --- docs/admin/utils.md | 69 ++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/docs/admin/utils.md b/docs/admin/utils.md index f2fd526..29c6d4d 100644 --- a/docs/admin/utils.md +++ b/docs/admin/utils.md @@ -2,46 +2,43 @@ `admin.utils` -Checks what a mined transaction actually paid, and how far its block has settled, before the backend acts on it. Nothing is signed or sent. Both methods take a transaction hash or a receipt, and every read they need goes out at once, so a check costs one round trip to the node. +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. -Pass the hash when it came from a user. A receipt passed in is checked against the chain's block at its height, but its logs are used as given, so only pass one your own node returned. +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 come back as `bigint` cents, the same format the contracts use: `123456n` is \$1234.56. +Prices are `bigint` cents, as in the contracts: `123456n` is \$1234.56. ```ts -// Backend, when the app reports a purchase 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); // check again later +else markConfirming(order, paid.blockNumber, paid.blockHash); // checked again later ``` ## Finality -Base can drop a block it has already shown you, and a payment in that block is gone with it. Both methods say how far the transaction's block has settled: +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 | Behind the latest block on Base mainnet, measured | +| `finality` | Meaning | Time behind the latest block on Base mainnet (measured) | |---|---|---| | `"latest"` | Mined, but can still be reorged out. | 0 | -| `"safe"` | Its batch is posted to L1. Only an L1 reorg can undo it. | about 1 minute | -| `"finalized"` | L1 has finalized the batch. It cannot be undone. | about 19 minutes | +| `"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 | -Issue anything you cannot take back, such as an eSIM, only on `"finalized"`. A check that answers `"latest"` or `"safe"` is not an error: show the user the payment is confirming, keep `blockNumber` and `blockHash`, and check again later. Both come back with every result. +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. -Waiting for more blocks is not the same thing on an L2. The node's `safe` and `finalized` tags are what count, and both are read alongside the receipt. - -A receipt passed in from a block that has since been reorged out throws `ReceiptNotCanonicalError`. Check again by hash: the payment is either gone (`UnknownTransactionError`) or in a new block. +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. Use it after `buyDataBundleWithToken` or `buyDataBundleWithTransfer`, including a user operation that bought several bundles, or a bundler transaction that also carries other users' purchases. +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 price, amount and reference come from the contracts' own events: the payment adapter's `PaymentSettled` and the eSIM wallet's `DataBundleBoughtWithToken`. Events from any other contract are ignored. +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 the receipt your node returned + txHash, // or a receipt "USDCt", // symbol as registered on the payment adapter, case-sensitive eSIMWalletAddress, // the wallet that paid, not its device wallet ); @@ -49,25 +46,25 @@ const result = await admin.utils.verifyProtocolPayment( // finality: "finalized", blockNumber, blockHash } ``` -The symbol is turned into the `bytes32` the contracts take (`"USDC"` becomes `0x5553444300…`), so pass it as text. A transaction with no matching purchase answers `priceUSDCents: 0n` and `payments: []`. +The symbol is converted to the `bytes32` the contracts expect (`"USDC"` becomes `0x5553444300…`). With no matching purchase, `priceUSDCents` is `0n` and `payments` is empty. -It throws: +Errors: -- `InvalidAddressError` for a malformed or zero address, and `InvalidSymbolError` for an empty symbol or one over 32 bytes. Both are checked before anything is read. -- `UnknownTransactionError` for a malformed hash, or one with no mined transaction yet. Nothing waits for it. -- `TransactionRevertedError` if the transaction reverted. -- `ReceiptNotCanonicalError` for a receipt passed in from a block since reorged out. See [Finality](#finality). -- `NotAProtocolESIMWalletError` if the registry does not know the address as an eSIM wallet. Passing the device wallet lands here. -- `TokenNotAcceptedError` with `reason: "NOT_REGISTERED"` for a symbol the adapter never had (a typo, or the wrong case), and `"NOT_ONCHAIN"` for one with no token, such as `USD`. A symbol withdrawn after the payment still verifies, since the contract accepted it at the time. -- `UnmatchedPaymentEventsError` if the two events do not pair up, which the current contracts never produce. +- `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. -It reads the payment adapter at the address the SDK has for the chain. After `registry.setPaymentAdapter` moves it, update the SDK. +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`, `{ priceUSDCents, payments, finality, blockNumber, blockHash }`, with `priceUSDCents` the total and each payment `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`. +Returns: `Promise`, `{ 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. Use it for a token the protocol does not handle, or to check a single leg of a purchase, such as the device wallet funding its eSIM wallet in `buyDataBundleWithTransfer`. +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( @@ -78,21 +75,21 @@ const { priceUSDCents, amount, finality } = await admin.utils.verifyERC20Transfe ); ``` -Only `Transfer` events emitted by the token itself count, and only from `sender` to `destination`. Several transfers are added together, and anything sent back is not subtracted. A transaction with none answers `priceUSDCents: 0n` and `amount: 0n`. +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` counts one token as one dollar, the way the payment adapter prices a dollar currency, and rounds down: `1_234_567n` of a 6-decimal token is `123n`. For a token that is not worth a dollar, such as WETH, ignore it and use `amount`, which is in the token's smallest unit. +`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. -It throws: +Errors: -- `InvalidAddressError` for a malformed or zero address, or a sender that is also the destination. +- `InvalidAddressError`: malformed or zero address, or the same sender and destination. - `UnknownTransactionError`, `TransactionRevertedError` and `ReceiptNotCanonicalError`, as above. -- `NotAnERC20TokenError` if the address does not answer `decimals()` and `totalSupply()`. That covers an address with no code and an NFT contract. A network failure is passed on as-is instead. -- `PriceOutOfRangeError` if the cents do not fit the contracts' `uint64`. +- `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`, since there is nothing in the receipt to find. +A token that moves balances without emitting `Transfer` reads as `0n`. Returns: `Promise`, `{ priceUSDCents, amount, finality, blockNumber, blockHash }`. ## Fewer requests -Each check sends five reads at the same moment. If your RPC provider limits requests per second, create the client with `http(rpcUrl, { batch: true })` and viem sends them as one HTTP request. +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.