Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kokio-sdk",
"version": "3.3.0",
"version": "3.4.0",
"description": "",
"type": "module",
"main": "./dist/esm/config.js",
Expand Down
5 changes: 5 additions & 0 deletions src/admin/config-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export {
NotAnERC20TokenError,
UnmatchedPaymentEventsError,
PriceOutOfRangeError,
InvalidPaymentReferenceError,
CouponSplitOutOfRangeError,
ContractRevertError,
decodeContractRevert,
} from "../logic/errors.js";
Expand All @@ -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.
*
Expand Down
31 changes: 29 additions & 2 deletions src/admin/interface/registryClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hex>;
/**
* 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<Hex>;
recordSettledPurchase(
eSIMWalletAddress: Address,
dataBundleDetail: DataBundleDetails,
asset: Hex,
tokenAmount: bigint,
paymentReference: Hex | readonly [Hex, Hex],
couponUSDCents?: bigint
): Promise<Hex> {
return _recordSettledPurchase(this.walletClient, eSIMWalletAddress, dataBundleDetail, asset, tokenAmount, paymentReference, couponUSDCents);
}

assignESIMIdentifier(eSIMWalletAddress: Address, eSIMUniqueIdentifier: string) {
Expand Down
30 changes: 26 additions & 4 deletions src/admin/interface/utilsClass.ts
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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);
}
}
121 changes: 113 additions & 8 deletions src/logic/admin/registry.eoa.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -242,30 +249,128 @@ 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,
eSIMWalletAddress: Address,
dataBundleDetail: DataBundleDetails,
asset: Hex,
tokenAmount: bigint,
paymentReference: Hex
) => {
paymentReference: Hex | readonly [Hex, Hex],
couponUSDCents?: bigint
): Promise<Hex> => {

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

/**
Expand Down
63 changes: 63 additions & 0 deletions src/logic/admin/utils/paymentReference.ts
Original file line number Diff line number Diff line change
@@ -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, Hex> = {
[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 }) };
};
28 changes: 28 additions & 0 deletions src/logic/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/consumer/couponPayment.fork.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
Loading
Loading