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", diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 6788e5e..c27d4e5 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -38,6 +38,8 @@ export { NotAnERC20TokenError, UnmatchedPaymentEventsError, PriceOutOfRangeError, + InvalidPaymentReferenceError, + CouponSplitOutOfRangeError, ContractRevertError, decodeContractRevert, } from "../logic/errors.js"; @@ -47,6 +49,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/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/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/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, + }, + ]; } /** 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/src/logic/errors.ts b/src/logic/errors.ts index 7c688d0..b86ddf7 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -324,6 +324,34 @@ 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; + } +} + +/** + * 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. diff --git a/tests/README.md b/tests/README.md index 7047070..3c18ae7 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. 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 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..e523228 --- /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 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(); + + 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..66c8945 --- /dev/null +++ b/tests/consumer/flows/couponPaymentFlow.ts @@ -0,0 +1,255 @@ +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); + + // 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, _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, 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 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); + expect(couponRef.startsWith("0xfee0ffc0de")).toBe(true); + expect(remainderRef.startsWith("0xfee0ffba1a5ce0")).toBe(true); + + const tokenAmount = await quote(paidIn, remainderCents); + 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 () => { + 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 }]); + + // 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([ + { 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), couponCents, 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)); + + // 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, + }); + + // 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; + }; +}); diff --git a/tests/consumer/packageExports.test.ts b/tests/consumer/packageExports.test.ts index f3de09a..35a543d 100644 --- a/tests/consumer/packageExports.test.ts +++ b/tests/consumer/packageExports.test.ts @@ -42,6 +42,9 @@ 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(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); diff --git a/tests/logic/admin/couponSplit.test.ts b/tests/logic/admin/couponSplit.test.ts new file mode 100644 index 0000000..8fe50da --- /dev/null +++ b/tests/logic/admin/couponSplit.test.ts @@ -0,0 +1,135 @@ +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"; +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 }], +}); + +// 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) => + 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 () => { + const client = clientWith(); + + const hash = await _recordSettledPurchase(client, ESIM, BUNDLE, USDC, PAID, REFS, COUPON); + + expect(writes(client)).toEqual([REMAINDER_ARGS, COUPON_ARGS]); + 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")}`); + }); + + 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(spies(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(spies(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(spies(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(spies(client).readContract).not.toHaveBeenCalled(); + }); +}); 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); + }); +}); 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. */