From 1826d9e63e7df060d4db405695c122441c67f41c Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:40:18 +0530 Subject: [PATCH 01/14] Add an error for malformed payment references --- src/logic/errors.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/logic/errors.ts b/src/logic/errors.ts index 7c688d0..de7b7bd 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -324,6 +324,19 @@ export class PriceOutOfRangeError extends KokioError { } } +/** + * A payment reference that is not 32 bytes, has an empty order part, or + * carries a tag this SDK cannot write over or read back. + */ +export class InvalidPaymentReferenceError extends KokioError { + readonly reference: string; + + constructor(reference: string, reason: string) { + super("INVALID_PAYMENT_REFERENCE", `Payment reference "${reference}" ${reason}.`); + this.reference = reference; + } +} + // 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 feaf615da4c4498135a6748f793b8210eeb8b303 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:40:51 +0530 Subject: [PATCH 02/14] Tag payment references for coupon purchases Coupon tags go in the high 20 bytes the order id leaves free, so a split purchase gets two references that still point at the same order. --- src/logic/admin/utils/paymentReference.ts | 63 ++++++++++++++++++++++ tests/logic/admin/paymentReference.test.ts | 57 ++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/logic/admin/utils/paymentReference.ts create mode 100644 tests/logic/admin/paymentReference.test.ts diff --git a/src/logic/admin/utils/paymentReference.ts b/src/logic/admin/utils/paymentReference.ts new file mode 100644 index 0000000..5461d96 --- /dev/null +++ b/src/logic/admin/utils/paymentReference.ts @@ -0,0 +1,63 @@ +import { Hex, concat, isHex, pad, size, slice } from "viem"; +import { InvalidPaymentReferenceError } from "../../errors.js"; + +/** How a purchase was paid for, as written into its payment reference. */ +export enum PaymentReferenceKind { + /** Paid in full without a coupon. Carries no tag. */ + Standard = "Standard", + /** Paid in full by a coupon. */ + Coupon = "Coupon", + /** The coupon's share of a purchase split between a coupon and another payment. */ + CouponPart = "CouponPart", + /** What the user paid on top of the coupon in a split purchase. */ + Remainder = "Remainder", +} + +// The backend puts the order id in the low 12 bytes, leaving the high 20 free for a tag. +// Every coupon tag starts with 0xfee0ff ("fee off") so it stands out on an explorer. +const TAG_SIZE = 20; +const TAGS: Record = { + [PaymentReferenceKind.Standard]: pad("0x", { size: TAG_SIZE }), + [PaymentReferenceKind.Coupon]: pad("0xfee0ff", { size: TAG_SIZE, dir: "right" }), + [PaymentReferenceKind.CouponPart]: pad("0xfee0ffc0de", { size: TAG_SIZE, dir: "right" }), + [PaymentReferenceKind.Remainder]: pad("0xfee0ffba1a5ce0", { size: TAG_SIZE, dir: "right" }), +}; + +// Lowercased so a checksum-style or uppercase input compares equal to the tags above. +const _splitReference = (reference: Hex): { tag: Hex; order: Hex } => { + if (!isHex(reference, { strict: true }) || size(reference) !== 32) { + throw new InvalidPaymentReferenceError(reference, "is not 32 bytes of hex"); + } + const lower = reference.toLowerCase() as Hex; + const order = slice(lower, TAG_SIZE); + // The contracts refuse a zero reference, and a tag would hide an empty order id from that check. + if (BigInt(order) === 0n) throw new InvalidPaymentReferenceError(reference, "has an empty order part"); + + return { tag: slice(lower, 0, TAG_SIZE), order }; +}; + +/** + * Writes `kind`'s tag into the high 20 bytes of `reference`, which must be zero + * there. Tag both halves of a split purchase from the same reference, so each + * spends once onchain and both still point at the same order. + */ +export const _tagPaymentReference = (reference: Hex, kind: PaymentReferenceKind): Hex => { + const { tag, order } = _splitReference(reference); + if (tag !== TAGS[PaymentReferenceKind.Standard]) { + throw new InvalidPaymentReferenceError(reference, "is already tagged"); + } + + return concat([TAGS[kind], order]); +}; + +/** + * Reads back how a purchase was paid for, and the untagged reference it was + * built from. Throws on a tag this SDK did not write. + */ +export const _parsePaymentReference = (reference: Hex): { kind: PaymentReferenceKind; reference: Hex } => { + const { tag, order } = _splitReference(reference); + const kind = (Object.keys(TAGS) as PaymentReferenceKind[]).find((k) => TAGS[k] === tag); + if (!kind) throw new InvalidPaymentReferenceError(reference, "has an unknown tag"); + + return { kind, reference: pad(order, { size: 32 }) }; +}; diff --git a/tests/logic/admin/paymentReference.test.ts b/tests/logic/admin/paymentReference.test.ts new file mode 100644 index 0000000..bfe99a6 --- /dev/null +++ b/tests/logic/admin/paymentReference.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { Hex, pad } from "viem"; +import { + PaymentReferenceKind, + _parsePaymentReference, + _tagPaymentReference, +} from "../../../src/logic/admin/utils/paymentReference.js"; +import { InvalidPaymentReferenceError } from "../../../src/logic/errors.js"; + +// What the backend sends: a 12-byte Mongo ObjectId left-padded to 32 bytes. +const ORDER = "65f1a2b3c4d5e6f708192a3b"; +const BASE = pad(`0x${ORDER}`, { size: 32 }); + +describe("_tagPaymentReference", () => { + it("writes each kind's tag in front of the order id", () => { + const zeros = (n: number) => "0".repeat(n); + expect(_tagPaymentReference(BASE, PaymentReferenceKind.Standard)).toBe(BASE); + expect(_tagPaymentReference(BASE, PaymentReferenceKind.Coupon)).toBe(`0xfee0ff${zeros(34)}${ORDER}`); + expect(_tagPaymentReference(BASE, PaymentReferenceKind.CouponPart)).toBe(`0xfee0ffc0de${zeros(30)}${ORDER}`); + expect(_tagPaymentReference(BASE, PaymentReferenceKind.Remainder)).toBe(`0xfee0ffba1a5ce0${zeros(26)}${ORDER}`); + }); + + it("gives the two halves of a split purchase different references", () => { + expect(_tagPaymentReference(BASE, PaymentReferenceKind.CouponPart)) + .not.toBe(_tagPaymentReference(BASE, PaymentReferenceKind.Remainder)); + }); + + it("refuses a reference that is already tagged", () => { + const tagged = _tagPaymentReference(BASE, PaymentReferenceKind.Coupon); + expect(() => _tagPaymentReference(tagged, PaymentReferenceKind.Remainder)).toThrow(InvalidPaymentReferenceError); + }); + + it("refuses an empty order part, a short reference and non-hex", () => { + expect(() => _tagPaymentReference(pad("0x", { size: 32 }), PaymentReferenceKind.Coupon)).toThrow(InvalidPaymentReferenceError); + expect(() => _tagPaymentReference(`0x${ORDER}`, PaymentReferenceKind.Coupon)).toThrow(InvalidPaymentReferenceError); + expect(() => _tagPaymentReference("0xzz" as Hex, PaymentReferenceKind.Coupon)).toThrow(InvalidPaymentReferenceError); + }); +}); + +describe("_parsePaymentReference", () => { + it("reads back every kind and the untagged reference", () => { + for (const kind of Object.values(PaymentReferenceKind)) { + expect(_parsePaymentReference(_tagPaymentReference(BASE, kind))).toEqual({ kind, reference: BASE }); + } + }); + + it("accepts uppercase hex", () => { + const tagged = _tagPaymentReference(BASE, PaymentReferenceKind.Remainder); + expect(_parsePaymentReference(`0x${tagged.slice(2).toUpperCase()}`)) + .toEqual({ kind: PaymentReferenceKind.Remainder, reference: BASE }); + }); + + it("refuses a tag it did not write", () => { + const unknown = `0xfee0ff01${"0".repeat(32)}${ORDER}` as Hex; + expect(() => _parsePaymentReference(unknown)).toThrow(InvalidPaymentReferenceError); + }); +}); From 0cf5936bb5d0292e6fc0435fed603ae5c44d4078 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:41:24 +0530 Subject: [PATCH 03/14] Expose payment reference tagging on KokioAdmin.utils --- src/admin/config-admin.ts | 4 ++++ src/admin/interface/utilsClass.ts | 30 +++++++++++++++++++++++---- tests/consumer/packageExports.test.ts | 1 + 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 6788e5e..8c38c3b 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -38,6 +38,7 @@ export { NotAnERC20TokenError, UnmatchedPaymentEventsError, PriceOutOfRangeError, + InvalidPaymentReferenceError, ContractRevertError, decodeContractRevert, } from "../logic/errors.js"; @@ -47,6 +48,9 @@ export type { DecodedRevert } from "../logic/errors.js"; // at runtime rather than only in the types. export { OperationState } from "../logic/admin/reads/protocolAdmin.reads.js"; +// Passed to `utils.tagPaymentReference` and returned by `utils.parsePaymentReference`. +export { PaymentReferenceKind } from "../logic/admin/utils/paymentReference.js"; + /** * EOA-only entry point for the NodeJS backend. * diff --git a/src/admin/interface/utilsClass.ts b/src/admin/interface/utilsClass.ts index b322ba7..2233a5b 100644 --- a/src/admin/interface/utilsClass.ts +++ b/src/admin/interface/utilsClass.ts @@ -1,10 +1,15 @@ -import { Address, Hash, TransactionReceipt, WalletClient } from "viem"; +import { Address, Hash, Hex, TransactionReceipt, WalletClient } from "viem"; import { _verifyERC20Transfer, _verifyProtocolPayment } from "../../logic/admin/utils/tokenTransfer.js"; +import { + PaymentReferenceKind, + _parsePaymentReference, + _tagPaymentReference, +} from "../../logic/admin/utils/paymentReference.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. + * Checks what a mined transaction actually paid, and builds and reads payment + * references. 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 { @@ -30,4 +35,21 @@ export class AdminUtilsSubPackage { verifyERC20Transfer(transaction: Hash | TransactionReceipt, token: Address, sender: Address, destination: Address) { return _verifyERC20Transfer(this.walletClient, transaction, token, sender, destination); } + + /** + * Marks `reference` with how the purchase was paid for. Its high 20 bytes + * must be zero. For a split purchase, tag the same reference once as + * `CouponPart` and once as `Remainder`. + */ + tagPaymentReference(reference: Hex, kind: PaymentReferenceKind) { + return _tagPaymentReference(reference, kind); + } + + /** + * How a purchase was paid for, and the untagged reference it was built + * from. Throws on a tag this SDK did not write. + */ + parsePaymentReference(reference: Hex) { + return _parsePaymentReference(reference); + } } diff --git a/tests/consumer/packageExports.test.ts b/tests/consumer/packageExports.test.ts index f3de09a..ecd8730 100644 --- a/tests/consumer/packageExports.test.ts +++ b/tests/consumer/packageExports.test.ts @@ -42,6 +42,7 @@ describe("package entry points", () => { it("kokio-sdk/admin exports KokioAdmin, OperationState and the same error classes", () => { expect(typeof admin.KokioAdmin).toBe("function"); expect(admin.OperationState).toBeDefined(); + expect(admin.PaymentReferenceKind).toBeDefined(); expect(typeof admin.decodeContractRevert).toBe("function"); for (const name of ERROR_CLASSES) { expect(admin, name).toHaveProperty(name); From 81028c5b300bc8d7dc70d86c978a30b2b81e4e6f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:49:56 +0530 Subject: [PATCH 04/14] Test every coupon payment the backend records, on a fork and live Covers card, full coupon, and a coupon split with card, an external wallet or the device wallet, reading each reference back to its order. --- tests/consumer/couponPayment.fork.test.ts | 9 + tests/consumer/couponPayment.live.test.ts | 29 +++ tests/consumer/flows/couponPaymentFlow.ts | 213 ++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 tests/consumer/couponPayment.fork.test.ts create mode 100644 tests/consumer/couponPayment.live.test.ts create mode 100644 tests/consumer/flows/couponPaymentFlow.ts diff --git a/tests/consumer/couponPayment.fork.test.ts b/tests/consumer/couponPayment.fork.test.ts new file mode 100644 index 0000000..18d2bcd --- /dev/null +++ b/tests/consumer/couponPayment.fork.test.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { describeCouponPaymentFlow } from "./flows/couponPaymentFlow.js"; +import { startForkFlowTarget } from "./fixtures/forkFlowTarget.js"; + +describeCouponPaymentFlow("coupon payments on a Base Sepolia fork", startForkFlowTarget, passkeyGet, { timeout: 120_000, asset: "USDC" }); diff --git a/tests/consumer/couponPayment.live.test.ts b/tests/consumer/couponPayment.live.test.ts new file mode 100644 index 0000000..ec4b15b --- /dev/null +++ b/tests/consumer/couponPayment.live.test.ts @@ -0,0 +1,29 @@ +import { vi } from "vitest"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { describeCouponPaymentFlow } from "./flows/couponPaymentFlow.js"; +import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; + +// The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and +// the registry's real eSIM wallet admin as the backend. Sends real testnet +// transactions: the admin pays gas for eight and sends 0.40 USDCt to the device +// wallet. Run with `npm run test:consumer:live`. +describeCouponPaymentFlow("coupon payments on Base Sepolia with Pimlico", async () => { + const live = await startLiveStack(); + + return { + rpcUrl: live.target.rpcUrl, + publicClient: live.publicClient, + pimlicoAPIKey: live.target.pimlicoAPIKey, + policyId: live.target.policyId, + receiptUrl: `https://api.pimlico.io/v2/84532/rpc?apikey=${live.target.pimlicoAPIKey}`, + admin: live.admin, + fund: (token, to, amount) => fundFromAdmin(live, token, to, amount), + priceUSDCents: 100n, + // Hosted RPCs can answer from a node a block behind, so let each write settle. + confirmations: 3, + explorerTx: "https://sepolia.basescan.org/tx/", + }; +}, passkeyGet, { timeout: 180_000, asset: "USDCt" }); diff --git a/tests/consumer/flows/couponPaymentFlow.ts b/tests/consumer/flows/couponPaymentFlow.ts new file mode 100644 index 0000000..5204bc8 --- /dev/null +++ b/tests/consumer/flows/couponPaymentFlow.ts @@ -0,0 +1,213 @@ +import { randomBytes } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; +import { pad, stringToHex, toHex, type Address, type Hex } from "viem"; +import { ContractRevertError } from "kokio-sdk"; +import { InvalidPaymentReferenceError, PaymentReferenceKind, type KokioAdmin } from "kokio-sdk/admin"; +import { Registry } from "kokio-sdk/abis"; +import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; + +import { expectSponsored } from "../fixtures/sponsorship.js"; +import { createTestUser, type TestUser } from "../fixtures/user.js"; +import { testBytes32 } from "../fixtures/testLabels.js"; +import type { FlowTarget } from "./userFlow.js"; + +const E_SIM_SALT = 1n; +const USD = "USD"; + +// Every way the backend can be paid for one data bundle, each recorded onchain +// under a payment reference built from the order id. A coupon that covers only +// part of the price splits the order into two lines, whose references differ +// in their tag but share the order id. Tests run in order, and each one appends +// to the same eSIM wallet's history. +export const describeCouponPaymentFlow = ( + name: string, + setup: () => Promise, + passkeyGet: Mock, + // `asset` is what the device wallet pays the remainder in. + { timeout, asset }: { timeout: number; asset: string }, +) => describe(name, () => { + let target: FlowTarget; + let admin: KokioAdmin; + let user: TestUser; + let eSIMWallet: Address; + let registry: Address; + + const bundleId = testBytes32("coupon-bundle"); + let price: bigint; + let couponCents: bigint; + let remainderCents: bigint; + // Next unread index in the eSIM wallet's history. + let historyIndex = 0n; + + beforeAll(async () => { + target = await setup(); + admin = target.admin; + price = target.priceUSDCents; + couponCents = (price * 3n) / 5n; + remainderCents = price - couponCents; + registry = (await admin.constants).factoryAddresses.REGISTRY as Address; + + // A user set up as in the user flow: device wallet deployed and registered, one eSIM wallet bound. + user = await createTestUser(target, passkeyGet); + await sponsored("deploy device wallet", () => user.kokio.deviceWallet!.sendUserOperation([])); + await waitFor("register device wallet", + await admin.deviceWalletFactory.postCreateAccount(user.deviceWallet, user.uid, user.signer.ownerKey, user.salt)); + await sponsored("bind eSIM wallet", async () => { + const result = await user.kokio.deviceWallet!.deployAndBindESIMWallet(E_SIM_SALT, { grantAccessToFunds: false }); + eSIMWallet = result.eSIMWalletAddress; + return result.userOpHash; + }); + admin.setDeviceWalletAddress(user.deviceWallet).setESIMWalletAddress(eSIMWallet); + }, 300_000); + + afterAll(async () => { + // Recorded so a live run can be looked up on a block explorer. + console.log(`device wallet ${user?.deviceWallet}, eSIM wallet ${eSIMWallet}`); + await target?.stop?.(); + }); + + it("an order paid in full by card keeps its reference untagged", async () => { + const order = newOrder(); + const ref = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Standard); + expect(ref).toBe(order); + + const tokenAmount = await quote(USD, price); + const receipt = await record("card", ref, price, Settlement.Fiat, USD, tokenAmount); + + const [event] = await settledEvents([ref], receipt.blockNumber); + expect(event.args).toMatchObject({ _priceUSDCents: price, _settlement: Settlement.Fiat, _asset: symbol(USD), _tokenAmount: tokenAmount }); + expect(admin.utils.parsePaymentReference(event.args._paymentReference!)).toEqual({ kind: PaymentReferenceKind.Standard, reference: order }); + await expectHistory([{ priceUSDCents: price, settlement: Settlement.Fiat }]); + }, timeout); + + it("an order paid in full by a coupon is tagged 0xfee0ff", async () => { + const order = newOrder(); + const ref = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Coupon); + expect(ref.startsWith("0xfee0ff")).toBe(true); + + // No money moved, so nothing was paid in the recorded currency. + const receipt = await record("coupon", ref, price, Settlement.Fiat, USD, 0n); + + const [event] = await settledEvents([ref], receipt.blockNumber); + expect(event.args).toMatchObject({ _priceUSDCents: price, _settlement: Settlement.Fiat, _tokenAmount: 0n }); + expect(admin.utils.parsePaymentReference(event.args._paymentReference!)).toEqual({ kind: PaymentReferenceKind.Coupon, reference: order }); + await expectHistory([{ priceUSDCents: price, settlement: Settlement.Fiat }]); + }, timeout); + + // Paid outside the protocol: the backend confirms the user's share offchain, records it, + // then records the coupon's share. + const recordedSplits = [ + { label: "card", settlement: Settlement.Fiat, paidIn: USD }, + { label: "an external wallet", settlement: Settlement.ExternalWallet, paidIn: "USDC" }, + ]; + + recordedSplits.forEach(({ label, settlement, paidIn }) => { + it(`an order split between a coupon and ${label} is recorded as two lines of one order`, async () => { + const order = newOrder(); + const couponRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.CouponPart); + const remainderRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder); + expect(couponRef.startsWith("0xfee0ffc0de")).toBe(true); + expect(remainderRef.startsWith("0xfee0ffba1a5ce0")).toBe(true); + + const tokenAmount = await quote(paidIn, remainderCents); + const first = await record(`coupon + ${label}, user's share`, remainderRef, remainderCents, settlement, paidIn, tokenAmount); + await record(`coupon + ${label}, coupon's share`, couponRef, couponCents, Settlement.Fiat, USD, 0n); + + // The order id gives both references, so one query finds both lines. + const events = await settledEvents([couponRef, remainderRef], first.blockNumber); + expect(events.map((e) => admin.utils.parsePaymentReference(e.args._paymentReference!))).toEqual([ + { kind: PaymentReferenceKind.Remainder, reference: order }, + { kind: PaymentReferenceKind.CouponPart, reference: order }, + ]); + expect(events[0].args).toMatchObject({ _priceUSDCents: remainderCents, _settlement: settlement, _asset: symbol(paidIn), _tokenAmount: tokenAmount }); + expect(events[1].args).toMatchObject({ _priceUSDCents: couponCents, _settlement: Settlement.Fiat, _tokenAmount: 0n }); + expect(events[0].args._priceUSDCents! + events[1].args._priceUSDCents!).toBe(price); + + await expectHistory([ + { priceUSDCents: remainderCents, settlement }, + { priceUSDCents: couponCents, settlement: Settlement.Fiat }, + ]); + }, timeout); + }); + + let spentCouponRef: Hex; + + it("an order split between a coupon and the device wallet: the app pays its share, then the backend records the coupon's", async () => { + const order = newOrder(); + const couponRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.CouponPart); + const remainderRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder); + + const { token } = await admin.paymentAdapter.resolveAsset(symbol(asset)); + const amountIn = await quote(asset, remainderCents); + await target.fund(token, user.deviceWallet, amountIn); + + // Backend: builds the calls for the user's share only. App: signs them as given. + const calls = await admin.calls.buyDataBundleWithTransfer( + eSIMWallet, { id: bundleId, priceUSDCents: remainderCents, settlement: Settlement.DeviceWallet }, symbol(asset), amountIn, remainderRef); + const receipt = await sponsored("coupon + device wallet, user's share", () => user.kokio.deviceWallet!.sendUserOperation(calls)); + + // Backend: its webhook reads the reference back to the order before recording the coupon. + const paid = await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, asset, eSIMWallet); + expect(paid.priceUSDCents).toBe(remainderCents); + expect(paid.payments.map((p) => admin.utils.parsePaymentReference(p.paymentReference))) + .toEqual([{ kind: PaymentReferenceKind.Remainder, reference: order }]); + + await record("coupon + device wallet, coupon's share", couponRef, couponCents, Settlement.Fiat, USD, 0n); + spentCouponRef = couponRef; + + await expectHistory([ + { priceUSDCents: remainderCents, settlement: Settlement.DeviceWallet }, + { priceUSDCents: couponCents, settlement: Settlement.Fiat }, + ]); + }, timeout); + + it("a retried write is refused, and a reference cannot be tagged twice", async () => { + // Refused before sending, so this costs nothing even on a live chain. + const again = await admin.registry + .recordSettledPurchase(eSIMWallet, { id: bundleId, priceUSDCents: couponCents, settlement: Settlement.Fiat }, symbol(USD), 0n, spentCouponRef) + .catch((e: unknown) => e); + expect(again).toBeInstanceOf(ContractRevertError); + expect((again as ContractRevertError).decoded?.errorName).toBe("PaymentReferenceAlreadyUsed"); + + expect(() => admin.utils.tagPaymentReference(spentCouponRef, PaymentReferenceKind.Remainder)).toThrow(InvalidPaymentReferenceError); + const unknownTag: Hex = `0xfee0ff01${"0".repeat(32)}${spentCouponRef.slice(-24)}`; + expect(() => admin.utils.parsePaymentReference(unknownTag)).toThrow(InvalidPaymentReferenceError); + }, timeout); + + // What the backend sends to the SDK: a 12-byte Mongo ObjectId left-padded to 32 bytes. + const newOrder = (): Hex => pad(toHex(randomBytes(12)), { size: 32 }); + const symbol = (name: string): Hex => stringToHex(name, { size: 32 }); + const quote = (name: string, cents: bigint) => admin.paymentAdapter.quote(symbol(name), cents); + + const record = async (step: string, ref: Hex, priceUSDCents: bigint, settlement: Settlement, paidIn: string, tokenAmount: bigint) => + waitFor(step, await admin.registry.recordSettledPurchase( + eSIMWallet, { id: bundleId, priceUSDCents, settlement }, symbol(paidIn), tokenAmount, ref)); + + const settledEvents = (refs: Hex[], fromBlock: bigint) => target.publicClient.getContractEvents({ + address: registry, abi: Registry, eventName: "DataBundleSettled", + args: { _eSIMWallet: eSIMWallet, _paymentReference: refs }, fromBlock, + }); + + // Reads the next entries of the eSIM wallet's history and checks them against `entries`. + const expectHistory = async (entries: { priceUSDCents: bigint; settlement: Settlement }[]) => { + for (const entry of entries) { + expect(await admin.eSIMWallet!.transactionHistory(historyIndex++)).toEqual({ id: bundleId, ...entry }); + } + }; + + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); + const log = (step: string, message: string) => console.log(`[${step}] ${message}`); + + const sponsored = async (step: string, send: () => Promise, sender: KokioSmartAccountClient = user.client) => { + const receipt = await expectSponsored(sender, target.publicClient, send, { confirmations: target.confirmations }); + log(step, `user operation ${receipt.userOpHash}, transaction ${link(receipt.receipt.transactionHash)}`); + return receipt; + }; + + const waitFor = async (step: string, hash: Hex) => { + const receipt = await target.publicClient.waitForTransactionReceipt({ hash, confirmations: target.confirmations }); + log(step, `transaction ${link(hash)}`); + expect(receipt.status).toBe("success"); + return receipt; + }; +}); From 607a9ba348838b6089bba012135d0f42e81d50c6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:49:56 +0530 Subject: [PATCH 05/14] Check the admin entry point exports the payment reference error --- tests/consumer/packageExports.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/consumer/packageExports.test.ts b/tests/consumer/packageExports.test.ts index ecd8730..f9b7962 100644 --- a/tests/consumer/packageExports.test.ts +++ b/tests/consumer/packageExports.test.ts @@ -43,6 +43,7 @@ describe("package entry points", () => { expect(typeof admin.KokioAdmin).toBe("function"); expect(admin.OperationState).toBeDefined(); expect(admin.PaymentReferenceKind).toBeDefined(); + expect(new admin.InvalidPaymentReferenceError("0x", "is empty")).toBeInstanceOf(admin.KokioError); expect(typeof admin.decodeContractRevert).toBe("function"); for (const name of ERROR_CLASSES) { expect(admin, name).toHaveProperty(name); From 04a2a4daf3cac73c1ffc96692f3aa2f3ad9ca735 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 15:50:10 +0530 Subject: [PATCH 06/14] List the coupon payment suites in the tests README --- tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/README.md b/tests/README.md index 7047070..cb00687 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,6 +30,7 @@ The [consumer/](consumer/) folder uses the SDK the way an app or backend would: - `*.fork.test.ts` runs the whole user journey on an anvil fork of Base Sepolia, through a local Alto bundler and mock paymaster: deploy a device wallet, register it, deploy and bind an eSIM wallet, grant and revoke access, buy with `USDC` and `USDCt`, and the refusals. Every user operation must be sponsored. Needs Foundry's `anvil`. - `lazyWalletDeploy.fork.test.ts` covers a user who bought 88 bundles by card or external wallet before installing the app. The backend deploys the device wallet and 20 eSIM wallets, copies each eSIM's history in, and checks every entry. The user then buys 2 more eSIMs and moves two eSIMs to another device, one of them the lazy eSIM with the longest history. Fork only for now. +- `couponPayment.fork.test.ts` and `couponPayment.live.test.ts` record one bundle each way the backend can be paid: card, a full coupon, and a coupon split with card, an external wallet or the device wallet. Each payment reference is tagged from a fake order id, and the backend reads every onchain line back to that order. A split order has two lines, found together in one event query. - `*.live.test.ts` runs the same journey on Base Sepolia with Pimlico. It sends real testnet transactions and reads `BASE_SEPOLIA_RPC_URL`, `PIMLICO_API_SECRET` and `ESIM_WALLET_ADMIN_PK` (the registry's eSIM wallet admin) from `.env`. `PIMLICO_POLICY_ID` is optional. The admin account needs a little ETH and at least 1 USDCt. ```sh From fb60c9428d47d0fabcffa1f887ddf6c28d9a63a2 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:06:32 +0530 Subject: [PATCH 07/14] Add an error for a coupon that does not split the price --- src/admin/config-admin.ts | 1 + src/logic/errors.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 8c38c3b..c27d4e5 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -39,6 +39,7 @@ export { UnmatchedPaymentEventsError, PriceOutOfRangeError, InvalidPaymentReferenceError, + CouponSplitOutOfRangeError, ContractRevertError, decodeContractRevert, } from "../logic/errors.js"; diff --git a/src/logic/errors.ts b/src/logic/errors.ts index de7b7bd..b86ddf7 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -337,6 +337,21 @@ export class InvalidPaymentReferenceError extends KokioError { } } +/** + * A coupon that covers none or all of the price. Neither is a split: a coupon + * covering the whole price is a full-coupon order with a single reference. + */ +export class CouponSplitOutOfRangeError extends KokioError { + readonly couponUSDCents?: bigint; + readonly priceUSDCents: bigint; + + constructor(couponUSDCents: bigint | undefined, priceUSDCents: bigint) { + super("COUPON_SPLIT_OUT_OF_RANGE", `A coupon of ${couponUSDCents} cents does not split a price of ${priceUSDCents} cents.`); + this.couponUSDCents = couponUSDCents; + 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 49dba0f0c5c55e1ddbb8da2ddc9d2e2b18fce398 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:07:28 +0530 Subject: [PATCH 08/14] Record a split coupon purchase in one recordSettledPurchase call Given the coupon and remainder references and the coupon's cents, the SDK records the remainder line, then the coupon line, skipping any line already spent so a retry after a partial failure is safe. --- src/admin/interface/registryClass.ts | 31 ++++++- src/logic/admin/registry.eoa.ts | 121 +++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/src/admin/interface/registryClass.ts b/src/admin/interface/registryClass.ts index 34ef03b..3601b3b 100644 --- a/src/admin/interface/registryClass.ts +++ b/src/admin/interface/registryClass.ts @@ -82,14 +82,41 @@ export class AdminRegistrySubPackage { return _acceptAdminUpdate(this.walletClient); } + /** + * Records a purchase paid for by card or an external wallet, or one line of + * a coupon purchase. One transaction. + */ recordSettledPurchase( eSIMWalletAddress: Address, dataBundleDetail: DataBundleDetails, asset: Hex, tokenAmount: bigint, paymentReference: Hex - ) { - return _recordSettledPurchase(this.walletClient, eSIMWalletAddress, dataBundleDetail, asset, tokenAmount, paymentReference); + ): Promise; + /** + * Records a purchase split between a coupon and a card or external wallet + * payment, as two lines. `dataBundleDetail` carries the full price and how + * the remainder was paid; `asset` and `tokenAmount` are what the user paid + * for the remainder. Lines already recorded are skipped. Returns the hash + * of the last transaction sent; any earlier one is already mined. + */ + recordSettledPurchase( + eSIMWalletAddress: Address, + dataBundleDetail: DataBundleDetails, + asset: Hex, + tokenAmount: bigint, + paymentReferences: readonly [couponRef: Hex, remainderRef: Hex], + couponUSDCents: bigint + ): Promise; + recordSettledPurchase( + eSIMWalletAddress: Address, + dataBundleDetail: DataBundleDetails, + asset: Hex, + tokenAmount: bigint, + paymentReference: Hex | readonly [Hex, Hex], + couponUSDCents?: bigint + ): Promise { + return _recordSettledPurchase(this.walletClient, eSIMWalletAddress, dataBundleDetail, asset, tokenAmount, paymentReference, couponUSDCents); } assignESIMIdentifier(eSIMWalletAddress: Address, eSIMUniqueIdentifier: string) { diff --git a/src/logic/admin/registry.eoa.ts b/src/logic/admin/registry.eoa.ts index b5632c7..da865db 100644 --- a/src/logic/admin/registry.eoa.ts +++ b/src/logic/admin/registry.eoa.ts @@ -1,8 +1,15 @@ -import { Address, Hex, WalletClient } from "viem"; +import { Address, Hex, WalletClient, encodeAbiParameters, keccak256, publicActions, stringToHex } from "viem"; import { _chainId, _getChainSpecificConstants } from "../constants.js"; -import { MissingEOAWalletError, writeContractOrThrow } from "../errors.js"; +import { + CouponSplitOutOfRangeError, + InvalidPaymentReferenceError, + MissingEOAWalletError, + TransactionRevertedError, + writeContractOrThrow, +} from "../errors.js"; import { Registry } from "../../abis/index.js"; -import type { DataBundleDetails, OwnerCall } from "../../types.js"; +import { Settlement, type DataBundleDetails, type OwnerCall } from "../../types.js"; +import { PaymentReferenceKind, _parsePaymentReference } from "./utils/paymentReference.js"; // Admin-EOA logic for `Registry`. Most of this is `onlyOwner`, so the `client` // must carry the owner EOA. `_acceptAdminUpdate` is the nominee's own call and @@ -242,6 +249,12 @@ export const _assignESIMIdentifier = async ( * never sees a transfer to prove it), and `_tokenAmount` is recorded for * offchain matching but never checked against `_dataBundleDetail.priceUSDCents`. * `_paymentReference` is spendable once per eSIM wallet. + * + * Given `[couponRef, remainderRef]` and `couponUSDCents`, records a purchase + * split between a coupon and another payment as two lines, see + * {@link _couponSplitLines}. Lines already recorded are skipped, so a retry + * after a partial failure sends only what is missing. Returns the last hash + * sent; any earlier one is already mined. */ export const _recordSettledPurchase = async ( client: WalletClient, @@ -249,23 +262,115 @@ export const _recordSettledPurchase = async ( dataBundleDetail: DataBundleDetails, asset: Hex, tokenAmount: bigint, - paymentReference: Hex -) => { + paymentReference: Hex | readonly [Hex, Hex], + couponUSDCents?: bigint +): Promise => { const chainID = await _chainId(client); const rpcURL = client.transport.url; const values = _getChainSpecificConstants(chainID, rpcURL); if (!client.account) throw new MissingEOAWalletError(); + const account = client.account; - return writeContractOrThrow(client, { + const record = (line: SettledLine) => writeContractOrThrow(client, { address: values.factoryAddresses.REGISTRY, chain: values.chain, - account: client.account, + account, abi: Registry, functionName: 'recordSettledPurchase', - args: [eSIMWalletAddress, dataBundleDetail, asset, tokenAmount, paymentReference] + args: [eSIMWalletAddress, line.dataBundleDetail, line.asset, line.tokenAmount, line.paymentReference] }); + + if (typeof paymentReference === "string") { + return record({ dataBundleDetail, asset, tokenAmount, paymentReference }); + } + + const lines = _couponSplitLines(dataBundleDetail, asset, tokenAmount, paymentReference, couponUSDCents); + const publicClient = client.extend(publicActions); + + const spent = await Promise.all(lines.map((line) => publicClient.readContract({ + address: values.factoryAddresses.REGISTRY, + abi: Registry, + functionName: "usedPaymentReferences", + args: [keccak256(encodeAbiParameters( + [{ type: "address" }, { type: "bytes32" }], + [eSIMWalletAddress, line.paymentReference] + ))] + }))); + const missing = lines.filter((_, i) => !spent[i]); + + // With both lines recorded, the remainder is sent anyway so the contract refuses it with + // `PaymentReferenceAlreadyUsed`, the same answer a retried single reference gets. + let hash: Hex | undefined; + for (const line of missing.length > 0 ? missing : [lines[0]]) { + if (hash) { + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") throw new TransactionRevertedError(hash); + } + hash = await record(line); + } + + return hash!; +} + +/** One `recordSettledPurchase` call's worth of arguments, past the eSIM wallet. */ +export interface SettledLine { + dataBundleDetail: DataBundleDetails; + asset: Hex; + tokenAmount: bigint; + paymentReference: Hex; +} + +// Coupons are recorded as card payments in USD, whose smallest unit is a cent. +const COUPON_ASSET = stringToHex("USD", { size: 32 }); + +/** + * Splits one purchase into its remainder line and its coupon line, in that + * order. `dataBundleDetail` carries the full price and how the remainder was + * paid; `asset` and `tokenAmount` are what the user paid for the remainder. + * The coupon line is always `Fiat` in `USD`, with the coupon's cents as its + * token amount. + */ +export const _couponSplitLines = ( + dataBundleDetail: DataBundleDetails, + asset: Hex, + tokenAmount: bigint, + [couponRef, remainderRef]: readonly [Hex, Hex], + couponUSDCents: bigint | undefined +): [remainder: SettledLine, coupon: SettledLine] => { + + const coupon = _parsePaymentReference(couponRef); + const remainder = _parsePaymentReference(remainderRef); + if (coupon.kind !== PaymentReferenceKind.CouponPart) { + throw new InvalidPaymentReferenceError(couponRef, "is not tagged as a coupon part"); + } + if (remainder.kind !== PaymentReferenceKind.Remainder) { + throw new InvalidPaymentReferenceError(remainderRef, "is not tagged as a remainder"); + } + if (coupon.reference !== remainder.reference) { + throw new InvalidPaymentReferenceError(remainderRef, `belongs to a different order than ${couponRef}`); + } + + const price = dataBundleDetail.priceUSDCents; + if (couponUSDCents === undefined || couponUSDCents <= 0n || couponUSDCents >= price) { + throw new CouponSplitOutOfRangeError(couponUSDCents, price); + } + + return [ + { + dataBundleDetail: { ...dataBundleDetail, priceUSDCents: price - couponUSDCents }, + asset, + tokenAmount, + paymentReference: remainderRef, + }, + { + dataBundleDetail: { id: dataBundleDetail.id, priceUSDCents: couponUSDCents, settlement: Settlement.Fiat }, + asset: COUPON_ASSET, + tokenAmount: couponUSDCents, + paymentReference: couponRef, + }, + ]; } /** From bc79d9c82d578bc9f211021cf5fa41af59529483 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:08:34 +0530 Subject: [PATCH 09/14] Test recording a coupon purchase split in two --- tests/logic/admin/couponSplit.test.ts | 132 ++++++++++++++++++++++++++ tests/utils/mockClient.ts | 2 +- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/logic/admin/couponSplit.test.ts diff --git a/tests/logic/admin/couponSplit.test.ts b/tests/logic/admin/couponSplit.test.ts new file mode 100644 index 0000000..1992153 --- /dev/null +++ b/tests/logic/admin/couponSplit.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { encodeAbiParameters, keccak256, pad, stringToHex, type Address, type Hex } from "viem"; + +import { makeMockWalletClient } from "../../utils/mockClient.js"; +import { baseSepoliaFactoryAddresses } from "../../../src/logic/constants.js"; +import { + CouponSplitOutOfRangeError, + InvalidPaymentReferenceError, + TransactionRevertedError, +} from "../../../src/logic/errors.js"; +import { _recordSettledPurchase } from "../../../src/logic/admin/registry.eoa.js"; +import { PaymentReferenceKind, _tagPaymentReference } from "../../../src/logic/admin/utils/paymentReference.js"; +import { Settlement, type DataBundleDetails } from "../../../src/types.js"; + +// --- Fixtures --------------------------------------------------------------- +const F = baseSepoliaFactoryAddresses; +const CHAIN_ID = 84532; +const EOA = "0x00000000000000000000000000000000000e0a01" as Address; +const ESIM = "0x00000000000000000000000000000000000e51a1" as Address; + +const ORDER = pad("0x65f1a2b3c4d5e6f708192a3b", { size: 32 }); +const COUPON_REF = _tagPaymentReference(ORDER, PaymentReferenceKind.CouponPart); +const REMAINDER_REF = _tagPaymentReference(ORDER, PaymentReferenceKind.Remainder); +const REFS = [COUPON_REF, REMAINDER_REF] as const; + +// A $10.00 bundle, $6.00 of it paid by coupon and $4.00 in USDC from an external wallet. +const BUNDLE: DataBundleDetails = { + id: "0x0000000000000000000000000000000000000000000000000000000000000001", + priceUSDCents: 1000n, + settlement: Settlement.ExternalWallet, +}; +const USDC = stringToHex("USDC", { size: 32 }); +const USD = stringToHex("USD", { size: 32 }); +const PAID = 4_000_000n; +const COUPON = 600n; + +const REMAINDER_ARGS = [ESIM, { ...BUNDLE, priceUSDCents: 400n }, USDC, PAID, REMAINDER_REF]; +const COUPON_ARGS = [ESIM, { id: BUNDLE.id, priceUSDCents: COUPON, settlement: Settlement.Fiat }, USD, COUPON, COUPON_REF]; + +const scoped = (ref: Hex) => + keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes32" }], [ESIM, ref])); + +// A client whose registry reports `spent` references as already used for ESIM. +const clientWith = (spent: Hex[] = [], receiptStatus: "success" | "reverted" = "success") => makeMockWalletClient({ + chainId: CHAIN_ID, + account: EOA, + reads: { usedPaymentReferences: ([key]: [Hex]) => spent.some((ref) => scoped(ref) === key) }, + receipts: [{ logs: [], status: receiptStatus }], +}); + +const writes = (client: ReturnType) => + (client.writeContract as unknown as { mock: { calls: Array<[{ address: Address; functionName: string; args: unknown[] }]> } }) + .mock.calls.map(([call]) => { + expect(call.address).toBe(F.REGISTRY); + expect(call.functionName).toBe("recordSettledPurchase"); + return call.args; + }); + +describe("_recordSettledPurchase with a coupon split", () => { + it("records the remainder line, waits for it, then records the coupon line", async () => { + const client = clientWith(); + + const hash = await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); + + expect(writes(client)).toEqual([REMAINDER_ARGS, COUPON_ARGS]); + expect(client.waitForTransactionReceipt).toHaveBeenCalledTimes(1); + // The second hash the mock hands out, the coupon line's. + expect(hash).toBe(`0x${"2".padStart(64, "0")}`); + }); + + it("sends only the coupon line when the remainder was recorded on an earlier try", async () => { + const client = clientWith([REMAINDER_REF]); + + await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); + + expect(writes(client)).toEqual([COUPON_ARGS]); + expect(client.waitForTransactionReceipt).not.toHaveBeenCalled(); + }); + + it("sends the remainder line again when both are recorded, for the contract to refuse", async () => { + const client = clientWith([REMAINDER_REF, COUPON_REF]); + + await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); + + expect(writes(client)).toEqual([REMAINDER_ARGS]); + }); + + it("stops before the coupon line when the remainder line reverts", async () => { + const client = clientWith([], "reverted"); + + await expect(_recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON)) + .rejects.toBeInstanceOf(TransactionRevertedError); + expect(writes(client)).toEqual([REMAINDER_ARGS]); + }); + + it("refuses references that are not one order's coupon part and remainder, in that order", async () => { + const otherOrder = pad("0x65f1a2b3c4d5e6f708192a3c", { size: 32 }); + const cases: Array = [ + [REMAINDER_REF, COUPON_REF], + [COUPON_REF, ORDER], + [COUPON_REF, _tagPaymentReference(otherOrder, PaymentReferenceKind.Remainder)], + ]; + + for (const refs of cases) { + const client = clientWith(); + await expect(_recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, refs, COUPON)) + .rejects.toBeInstanceOf(InvalidPaymentReferenceError); + expect(client.writeContract).not.toHaveBeenCalled(); + } + }); + + it("refuses a coupon that covers none or all of the price", async () => { + for (const coupon of [undefined, 0n, -1n, 1000n, 1001n]) { + const client = clientWith(); + await expect(_recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, coupon)) + .rejects.toBeInstanceOf(CouponSplitOutOfRangeError); + expect(client.writeContract).not.toHaveBeenCalled(); + } + }); +}); + +describe("_recordSettledPurchase with a single reference", () => { + it("sends it as given, without reading or checking its tag", async () => { + const client = clientWith(); + const untagged = stringToHex("any-reference", { size: 32 }); + + await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, untagged); + + expect(writes(client)).toEqual([[ESIM, BUNDLE, USDC, PAID, untagged]]); + expect(client.readContract).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index da44dd9..17221ed 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -21,7 +21,7 @@ export const makeMockWalletClient = (opts: { * Receipts `waitForTransactionReceipt` hands back, one per call in order. Supply * this to exercise logic that reads a batch's outcome off its own event. */ - receipts?: Array<{ logs: unknown[] }>; + receipts?: Array<{ logs: unknown[]; status?: "success" | "reverted" }>; /** What `simulateContract` does. Throw from here to exercise a revert path. */ simulate?: () => unknown; /** What `writeContract` does. Throw from here to exercise a revert path. */ From 6505232112a5fa2cfe9293c71b65746b417447a5 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:10:51 +0530 Subject: [PATCH 10/14] Type the mock client's spies in the coupon split tests --- tests/logic/admin/couponSplit.test.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/logic/admin/couponSplit.test.ts b/tests/logic/admin/couponSplit.test.ts index 1992153..8fe50da 100644 --- a/tests/logic/admin/couponSplit.test.ts +++ b/tests/logic/admin/couponSplit.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, type Mock } from "vitest"; import { encodeAbiParameters, keccak256, pad, stringToHex, type Address, type Hex } from "viem"; import { makeMockWalletClient } from "../../utils/mockClient.js"; @@ -48,13 +48,16 @@ const clientWith = (spent: Hex[] = [], receiptStatus: "success" | "reverted" = " receipts: [{ logs: [], status: receiptStatus }], }); +// The mock client's methods are spies, which the WalletClient type does not show. +const spies = (client: ReturnType) => + client as unknown as Record<"writeContract" | "readContract" | "waitForTransactionReceipt", Mock>; + const writes = (client: ReturnType) => - (client.writeContract as unknown as { mock: { calls: Array<[{ address: Address; functionName: string; args: unknown[] }]> } }) - .mock.calls.map(([call]) => { - expect(call.address).toBe(F.REGISTRY); - expect(call.functionName).toBe("recordSettledPurchase"); - return call.args; - }); + spies(client).writeContract.mock.calls.map(([call]: Array<{ address: Address; functionName: string; args: unknown[] }>) => { + expect(call.address).toBe(F.REGISTRY); + expect(call.functionName).toBe("recordSettledPurchase"); + return call.args; + }); describe("_recordSettledPurchase with a coupon split", () => { it("records the remainder line, waits for it, then records the coupon line", async () => { @@ -63,7 +66,7 @@ describe("_recordSettledPurchase with a coupon split", () => { const hash = await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); expect(writes(client)).toEqual([REMAINDER_ARGS, COUPON_ARGS]); - expect(client.waitForTransactionReceipt).toHaveBeenCalledTimes(1); + expect(spies(client).waitForTransactionReceipt).toHaveBeenCalledTimes(1); // The second hash the mock hands out, the coupon line's. expect(hash).toBe(`0x${"2".padStart(64, "0")}`); }); @@ -74,7 +77,7 @@ describe("_recordSettledPurchase with a coupon split", () => { await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); expect(writes(client)).toEqual([COUPON_ARGS]); - expect(client.waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(spies(client).waitForTransactionReceipt).not.toHaveBeenCalled(); }); it("sends the remainder line again when both are recorded, for the contract to refuse", async () => { @@ -105,7 +108,7 @@ describe("_recordSettledPurchase with a coupon split", () => { const client = clientWith(); await expect(_recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, refs, COUPON)) .rejects.toBeInstanceOf(InvalidPaymentReferenceError); - expect(client.writeContract).not.toHaveBeenCalled(); + expect(spies(client).writeContract).not.toHaveBeenCalled(); } }); @@ -114,7 +117,7 @@ describe("_recordSettledPurchase with a coupon split", () => { const client = clientWith(); await expect(_recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, coupon)) .rejects.toBeInstanceOf(CouponSplitOutOfRangeError); - expect(client.writeContract).not.toHaveBeenCalled(); + expect(spies(client).writeContract).not.toHaveBeenCalled(); } }); }); @@ -127,6 +130,6 @@ describe("_recordSettledPurchase with a single reference", () => { await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, untagged); expect(writes(client)).toEqual([[ESIM, BUNDLE, USDC, PAID, untagged]]); - expect(client.readContract).not.toHaveBeenCalled(); + expect(spies(client).readContract).not.toHaveBeenCalled(); }); }); From f38819eafc79521678c960c27fd518f3c57a5ed0 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:10:51 +0530 Subject: [PATCH 11/14] Record split coupon orders in one call in the coupon flow Coupon lines now record their cents as the token amount, and a new case retries a split whose first line already landed. --- tests/consumer/couponPayment.live.test.ts | 2 +- tests/consumer/flows/couponPaymentFlow.ts | 92 +++++++++++++++++------ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/tests/consumer/couponPayment.live.test.ts b/tests/consumer/couponPayment.live.test.ts index ec4b15b..e523228 100644 --- a/tests/consumer/couponPayment.live.test.ts +++ b/tests/consumer/couponPayment.live.test.ts @@ -8,7 +8,7 @@ import { fundFromAdmin, startLiveStack } from "./fixtures/liveStack.js"; // The same flow on Base Sepolia with the real Pimlico bundler and paymaster, and // the registry's real eSIM wallet admin as the backend. Sends real testnet -// transactions: the admin pays gas for eight and sends 0.40 USDCt to the device +// transactions: the admin pays gas for ten and sends 0.40 USDCt to the device // wallet. Run with `npm run test:consumer:live`. describeCouponPaymentFlow("coupon payments on Base Sepolia with Pimlico", async () => { const live = await startLiveStack(); diff --git a/tests/consumer/flows/couponPaymentFlow.ts b/tests/consumer/flows/couponPaymentFlow.ts index 5204bc8..66c8945 100644 --- a/tests/consumer/flows/couponPaymentFlow.ts +++ b/tests/consumer/flows/couponPaymentFlow.ts @@ -85,24 +85,24 @@ export const describeCouponPaymentFlow = ( const ref = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Coupon); expect(ref.startsWith("0xfee0ff")).toBe(true); - // No money moved, so nothing was paid in the recorded currency. - const receipt = await record("coupon", ref, price, Settlement.Fiat, USD, 0n); + // The coupon's cents are its amount in USD, whose smallest unit is a cent. + const receipt = await record("coupon", ref, price, Settlement.Fiat, USD, price); const [event] = await settledEvents([ref], receipt.blockNumber); - expect(event.args).toMatchObject({ _priceUSDCents: price, _settlement: Settlement.Fiat, _tokenAmount: 0n }); + expect(event.args).toMatchObject({ _priceUSDCents: price, _settlement: Settlement.Fiat, _asset: symbol(USD), _tokenAmount: price }); expect(admin.utils.parsePaymentReference(event.args._paymentReference!)).toEqual({ kind: PaymentReferenceKind.Coupon, reference: order }); await expectHistory([{ priceUSDCents: price, settlement: Settlement.Fiat }]); }, timeout); - // Paid outside the protocol: the backend confirms the user's share offchain, records it, - // then records the coupon's share. + // Paid outside the protocol: the backend confirms the user's share offchain, then records + // the whole order in one call. The SDK sends the user's share first, then the coupon's. const recordedSplits = [ { label: "card", settlement: Settlement.Fiat, paidIn: USD }, { label: "an external wallet", settlement: Settlement.ExternalWallet, paidIn: "USDC" }, ]; recordedSplits.forEach(({ label, settlement, paidIn }) => { - it(`an order split between a coupon and ${label} is recorded as two lines of one order`, async () => { + it(`an order split between a coupon and ${label} is recorded in one call as two lines of one order`, async () => { const order = newOrder(); const couponRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.CouponPart); const remainderRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder); @@ -110,26 +110,35 @@ export const describeCouponPaymentFlow = ( expect(remainderRef.startsWith("0xfee0ffba1a5ce0")).toBe(true); const tokenAmount = await quote(paidIn, remainderCents); - const first = await record(`coupon + ${label}, user's share`, remainderRef, remainderCents, settlement, paidIn, tokenAmount); - await record(`coupon + ${label}, coupon's share`, couponRef, couponCents, Settlement.Fiat, USD, 0n); - - // The order id gives both references, so one query finds both lines. - const events = await settledEvents([couponRef, remainderRef], first.blockNumber); - expect(events.map((e) => admin.utils.parsePaymentReference(e.args._paymentReference!))).toEqual([ - { kind: PaymentReferenceKind.Remainder, reference: order }, - { kind: PaymentReferenceKind.CouponPart, reference: order }, - ]); - expect(events[0].args).toMatchObject({ _priceUSDCents: remainderCents, _settlement: settlement, _asset: symbol(paidIn), _tokenAmount: tokenAmount }); - expect(events[1].args).toMatchObject({ _priceUSDCents: couponCents, _settlement: Settlement.Fiat, _tokenAmount: 0n }); - expect(events[0].args._priceUSDCents! + events[1].args._priceUSDCents!).toBe(price); - - await expectHistory([ - { priceUSDCents: remainderCents, settlement }, - { priceUSDCents: couponCents, settlement: Settlement.Fiat }, - ]); + const fromBlock = await target.publicClient.getBlockNumber(); + await recordSplit(`coupon + ${label}`, order, settlement, paidIn, tokenAmount); + + await expectSplitRecorded(order, fromBlock, settlement, paidIn, tokenAmount); }, timeout); }); + it("a split whose first line landed before a failure records only the coupon line on retry", async () => { + const order = newOrder(); + const remainderRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder); + const tokenAmount = await quote(USD, remainderCents); + const fromBlock = await target.publicClient.getBlockNumber(); + + // The first try got as far as the user's share. + await record("retry, first try", remainderRef, remainderCents, Settlement.Fiat, USD, tokenAmount); + const sent = await target.publicClient.getTransactionCount({ address: adminAddress() }); + + await recordSplit("retry", order, Settlement.Fiat, USD, tokenAmount); + + // One transaction, not two: the user's share was not sent again. + expect(await target.publicClient.getTransactionCount({ address: adminAddress() })).toBe(sent + 1); + await expectSplitRecorded(order, fromBlock, Settlement.Fiat, USD, tokenAmount); + + // A third try finds both lines spent and is refused, which the backend reads as already done. + const again = await recordSplit("retry, both spent", order, Settlement.Fiat, USD, tokenAmount).catch((e: unknown) => e); + expect(again).toBeInstanceOf(ContractRevertError); + expect((again as ContractRevertError).decoded?.errorName).toBe("PaymentReferenceAlreadyUsed"); + }, timeout); + let spentCouponRef: Hex; it("an order split between a coupon and the device wallet: the app pays its share, then the backend records the coupon's", async () => { @@ -152,7 +161,8 @@ export const describeCouponPaymentFlow = ( expect(paid.payments.map((p) => admin.utils.parsePaymentReference(p.paymentReference))) .toEqual([{ kind: PaymentReferenceKind.Remainder, reference: order }]); - await record("coupon + device wallet, coupon's share", couponRef, couponCents, Settlement.Fiat, USD, 0n); + // A single reference: the user's own transaction already recorded the other line. + await record("coupon + device wallet, coupon's share", couponRef, couponCents, Settlement.Fiat, USD, couponCents); spentCouponRef = couponRef; await expectHistory([ @@ -164,7 +174,7 @@ export const describeCouponPaymentFlow = ( it("a retried write is refused, and a reference cannot be tagged twice", async () => { // Refused before sending, so this costs nothing even on a live chain. const again = await admin.registry - .recordSettledPurchase(eSIMWallet, { id: bundleId, priceUSDCents: couponCents, settlement: Settlement.Fiat }, symbol(USD), 0n, spentCouponRef) + .recordSettledPurchase(eSIMWallet, { id: bundleId, priceUSDCents: couponCents, settlement: Settlement.Fiat }, symbol(USD), couponCents, spentCouponRef) .catch((e: unknown) => e); expect(again).toBeInstanceOf(ContractRevertError); expect((again as ContractRevertError).decoded?.errorName).toBe("PaymentReferenceAlreadyUsed"); @@ -183,6 +193,38 @@ export const describeCouponPaymentFlow = ( waitFor(step, await admin.registry.recordSettledPurchase( eSIMWallet, { id: bundleId, priceUSDCents, settlement }, symbol(paidIn), tokenAmount, ref)); + // The whole order: full price and how the user paid the rest, plus the coupon's cents. + const recordSplit = async (step: string, order: Hex, settlement: Settlement, paidIn: string, tokenAmount: bigint) => + waitFor(step, await admin.registry.recordSettledPurchase( + eSIMWallet, { id: bundleId, priceUSDCents: price, settlement }, symbol(paidIn), tokenAmount, + [ + admin.utils.tagPaymentReference(order, PaymentReferenceKind.CouponPart), + admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder), + ], + couponCents)); + + // The order id gives both references, so one query finds both lines, user's share first. + const expectSplitRecorded = async (order: Hex, fromBlock: bigint, settlement: Settlement, paidIn: string, tokenAmount: bigint) => { + const couponRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.CouponPart); + const remainderRef = admin.utils.tagPaymentReference(order, PaymentReferenceKind.Remainder); + const events = await settledEvents([couponRef, remainderRef], fromBlock); + + expect(events.map((e) => admin.utils.parsePaymentReference(e.args._paymentReference!))).toEqual([ + { kind: PaymentReferenceKind.Remainder, reference: order }, + { kind: PaymentReferenceKind.CouponPart, reference: order }, + ]); + expect(events[0].args).toMatchObject({ _priceUSDCents: remainderCents, _settlement: settlement, _asset: symbol(paidIn), _tokenAmount: tokenAmount }); + expect(events[1].args).toMatchObject({ _priceUSDCents: couponCents, _settlement: Settlement.Fiat, _asset: symbol(USD), _tokenAmount: couponCents }); + expect(events[0].args._priceUSDCents! + events[1].args._priceUSDCents!).toBe(price); + + await expectHistory([ + { priceUSDCents: remainderCents, settlement }, + { priceUSDCents: couponCents, settlement: Settlement.Fiat }, + ]); + }; + + const adminAddress = () => admin.walletClient.account!.address; + const settledEvents = (refs: Hex[], fromBlock: bigint) => target.publicClient.getContractEvents({ address: registry, abi: Registry, eventName: "DataBundleSettled", args: { _eSIMWallet: eSIMWallet, _paymentReference: refs }, fromBlock, From 20abb4fa41713e0718af671c3de396452428e21e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:10:51 +0530 Subject: [PATCH 12/14] Check the admin entry point exports the coupon split error --- tests/consumer/packageExports.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/consumer/packageExports.test.ts b/tests/consumer/packageExports.test.ts index f9b7962..35a543d 100644 --- a/tests/consumer/packageExports.test.ts +++ b/tests/consumer/packageExports.test.ts @@ -44,6 +44,7 @@ describe("package entry points", () => { expect(admin.OperationState).toBeDefined(); expect(admin.PaymentReferenceKind).toBeDefined(); expect(new admin.InvalidPaymentReferenceError("0x", "is empty")).toBeInstanceOf(admin.KokioError); + expect(new admin.CouponSplitOutOfRangeError(0n, 1000n)).toBeInstanceOf(admin.KokioError); expect(typeof admin.decodeContractRevert).toBe("function"); for (const name of ERROR_CLASSES) { expect(admin, name).toHaveProperty(name); From 94071fdc254a47143a9149a7fc921931c4743ffd Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:10:57 +0530 Subject: [PATCH 13/14] Mention the one-call split and its retry in the tests README --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index cb00687..3c18ae7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,7 +30,7 @@ The [consumer/](consumer/) folder uses the SDK the way an app or backend would: - `*.fork.test.ts` runs the whole user journey on an anvil fork of Base Sepolia, through a local Alto bundler and mock paymaster: deploy a device wallet, register it, deploy and bind an eSIM wallet, grant and revoke access, buy with `USDC` and `USDCt`, and the refusals. Every user operation must be sponsored. Needs Foundry's `anvil`. - `lazyWalletDeploy.fork.test.ts` covers a user who bought 88 bundles by card or external wallet before installing the app. The backend deploys the device wallet and 20 eSIM wallets, copies each eSIM's history in, and checks every entry. The user then buys 2 more eSIMs and moves two eSIMs to another device, one of them the lazy eSIM with the longest history. Fork only for now. -- `couponPayment.fork.test.ts` and `couponPayment.live.test.ts` record one bundle each way the backend can be paid: card, a full coupon, and a coupon split with card, an external wallet or the device wallet. Each payment reference is tagged from a fake order id, and the backend reads every onchain line back to that order. A split order has two lines, found together in one event query. +- `couponPayment.fork.test.ts` and `couponPayment.live.test.ts` record one bundle each way the backend can be paid: card, a full coupon, and a coupon split with card, an external wallet or the device wallet. Each payment reference is tagged from a fake order id, and the backend reads every onchain line back to that order. A split order has two lines, found together in one event query. A split paid by card or external wallet is recorded in one call, including a retry after its first line already landed. - `*.live.test.ts` runs the same journey on Base Sepolia with Pimlico. It sends real testnet transactions and reads `BASE_SEPOLIA_RPC_URL`, `PIMLICO_API_SECRET` and `ESIM_WALLET_ADMIN_PK` (the registry's eSIM wallet admin) from `.env`. `PIMLICO_POLICY_ID` is optional. The admin account needs a little ETH and at least 1 USDCt. ```sh From 9c267f768c7fd51326271374ae69e43f23e0ef82 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sun, 4 Oct 2026 18:27:00 +0530 Subject: [PATCH 14/14] Set package version to 3.4.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 6263e70..eac3302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio-sdk", - "version": "3.3.0", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio-sdk", - "version": "3.3.0", + "version": "3.4.0", "license": "MIT", "dependencies": { "@noble/curves": "2.3.0", diff --git a/package.json b/package.json index 9eae2a5..d48f228 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kokio-sdk", - "version": "3.3.0", + "version": "3.4.0", "description": "", "type": "module", "main": "./dist/esm/config.js",