diff --git a/docs/README.md b/docs/README.md index 3d7927a..1f567ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,7 @@ ordinary transaction, no bundler or passkey involved. the currencies data bundle purchases can be paid in. - [Calls](admin/calls.md), `admin.calls`. Build the calls a device wallet signs as one user operation, for the app to sign. +- [Utils](admin/utils.md), `admin.utils`. Check what a mined transaction paid, with its payment reference and price in cents. - [Protocol admin](admin/protocol-admin.md), `admin.protocolAdmin`. The timelock that owns the contracts above. Schedule, execute, and cancel delayed admin calls. diff --git a/docs/admin/utils.md b/docs/admin/utils.md new file mode 100644 index 0000000..29c6d4d --- /dev/null +++ b/docs/admin/utils.md @@ -0,0 +1,95 @@ +# Utils + +`admin.utils` + +Checks what a mined transaction paid and how far its block has settled. Nothing is signed or sent. Both methods accept a transaction hash or a receipt, and all reads are sent together, so each check is one round trip. + +A hash is the safer input when it comes from a user. A receipt is checked against the chain's block at its height, but its logs are used as given, so it is best taken from the backend's own node. + +Prices are `bigint` cents, as in the contracts: `123456n` is \$1234.56. + +```ts +const paid = await admin.utils.verifyProtocolPayment(txHash, "USDC", eSIMWalletAddress); +if (!paid.payments.some((p) => p.paymentReference === order.paymentReference)) return; + +if (paid.finality === "finalized") issueESIM(order); +else markConfirming(order, paid.blockNumber, paid.blockHash); // checked again later +``` + +## Finality + +A block on Base can be reorged out after it is mined, taking its payment with it. Both methods report how far the transaction's block has settled: + +| `finality` | Meaning | Time behind the latest block on Base mainnet (measured) | +|---|---|---| +| `"latest"` | Mined, but can still be reorged out. | 0 | +| `"safe"` | Batch posted to L1. Only an L1 reorg can undo it. | about 1 minute | +| `"finalized"` | Batch finalized on L1. It cannot be undone. | about 19 minutes | + +Anything that cannot be taken back, such as an eSIM, is best issued only on `"finalized"`. `"latest"` and `"safe"` are not errors. They can be used to show users that the payment is confirming. `blockNumber` and `blockHash` should be stored and checked again later for finality. + +A receipt from a block that has since been reorged out throws `ReceiptNotCanonicalError`. Checking again by hash shows whether the payment is gone (`UnknownTransactionError`) or landed in a new block. + +## verifyProtocolPayment + +Lists every purchase an eSIM wallet paid for through the protocol in one currency, within one transaction. It works for `buyDataBundleWithToken` and `buyDataBundleWithTransfer`, including several purchases in one user operation and bundler transactions that carry other users' purchases. + +The values come from the contracts' own events, the payment adapter's `PaymentSettled` and the eSIM wallet's `DataBundleBoughtWithToken`. Events from other contracts are ignored. + +```ts +const result = await admin.utils.verifyProtocolPayment( + txHash, // or a receipt + "USDCt", // symbol as registered on the payment adapter, case-sensitive + eSIMWalletAddress, // the wallet that paid, not its device wallet +); +// { priceUSDCents: 500n, payments: [{ paymentReference, dataBundleId, priceUSDCents: 500n, amountSpent: 5000000n, vault }], +// finality: "finalized", blockNumber, blockHash } +``` + +The symbol is converted to the `bytes32` the contracts expect (`"USDC"` becomes `0x5553444300…`). With no matching purchase, `priceUSDCents` is `0n` and `payments` is empty. + +Errors: + +- `InvalidAddressError`: malformed or zero address. `InvalidSymbolError`: empty symbol, or one over 32 bytes. Both are checked before any read. +- `UnknownTransactionError`: malformed hash, or no mined transaction yet. +- `TransactionRevertedError`: the transaction reverted. +- `ReceiptNotCanonicalError`: the receipt's block was reorged out. +- `NotAProtocolESIMWalletError`: the registry does not know the address as an eSIM wallet, for example when a device wallet is passed. +- `TokenNotAcceptedError`: `reason` is `"NOT_REGISTERED"` for a symbol the adapter never had (a typo or the wrong case), or `"NOT_ONCHAIN"` for one with no token, such as `USD`. A symbol withdrawn after the payment still verifies. +- `UnmatchedPaymentEventsError`: the two events do not pair up. The current contracts never produce this. + +The payment adapter address comes from the SDK's configuration for the chain, so a move with `registry.setPaymentAdapter` needs an SDK update. + +Returns: `Promise`, `{ priceUSDCents, payments, finality, blockNumber, blockHash }`. `priceUSDCents` is the total, and each payment is `{ paymentReference, dataBundleId, priceUSDCents, amountSpent, vault }`. + +## verifyERC20Transfer + +Adds up what one address sent another directly in any ERC-20, within one transaction. It suits tokens the protocol does not handle, or a single leg of a purchase, such as a device wallet funding its eSIM wallet. + +```ts +const { priceUSDCents, amount, finality } = await admin.utils.verifyERC20Transfer( + txHash, + tokenAddress, + senderAddress, + destinationAddress, +); +``` + +Only `Transfer` events from the token itself, from `sender` to `destination`, are counted. Several transfers are added together, and amounts sent back are not subtracted. With none, both values are `0n`. + +`priceUSDCents` treats one token as one dollar and rounds down, so `1_234_567n` of a 6-decimal token is `123n`. For tokens not worth a dollar, such as WETH, `amount` (in the token's smallest unit) is the meaningful value. + +Errors: + +- `InvalidAddressError`: malformed or zero address, or the same sender and destination. +- `UnknownTransactionError`, `TransactionRevertedError` and `ReceiptNotCanonicalError`, as above. +- `NotAnERC20TokenError`: the address does not answer `decimals()` and `totalSupply()`, such as an address with no code or an NFT contract. Network failures are passed on unchanged. +- `PriceOutOfRangeError`: the cents do not fit the contracts' `uint64`. + +A token that moves balances without emitting `Transfer` reads as `0n`. + +Returns: `Promise`, `{ priceUSDCents, amount, finality, blockNumber, blockHash }`. + +## Fewer requests + +Each check sends five reads at once. For RPC providers with request limits, a client created with `http(rpcUrl, { batch: true })` sends them as one HTTP request. diff --git a/package-lock.json b/package-lock.json index bd21af3..6263e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "license": "MIT", "dependencies": { "@noble/curves": "2.3.0", diff --git a/package.json b/package.json index 89ceeb4..9eae2a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kokio-sdk", - "version": "3.2.1", + "version": "3.3.0", "description": "", "type": "module", "main": "./dist/esm/config.js", diff --git a/src/admin/config-admin.ts b/src/admin/config-admin.ts index 1bdfb35..6788e5e 100644 --- a/src/admin/config-admin.ts +++ b/src/admin/config-admin.ts @@ -9,6 +9,7 @@ import { AdminDeviceWalletSubPackage } from "./interface/deviceWalletClass.js"; import { AdminESIMWalletSubPackage } from "./interface/eSIMWalletClass.js"; import { AdminPaymentAdapterSubPackage } from "./interface/paymentAdapterClass.js"; import { AdminCallsSubPackage } from "./interface/callsClass.js"; +import { AdminUtilsSubPackage } from "./interface/utilsClass.js"; // Re-export the typed error surface so backend consumers can `instanceof // KokioError` (or a subclass) and decode reverts without reaching into internal @@ -27,6 +28,16 @@ export { ESIMWalletNotLazyDeployedError, MissingBatchEventError, StalledBatchError, + InvalidAddressError, + InvalidSymbolError, + TokenNotAcceptedError, + NotAProtocolESIMWalletError, + UnknownTransactionError, + TransactionRevertedError, + ReceiptNotCanonicalError, + NotAnERC20TokenError, + UnmatchedPaymentEventsError, + PriceOutOfRangeError, ContractRevertError, decodeContractRevert, } from "../logic/errors.js"; @@ -69,6 +80,8 @@ export class KokioAdmin { paymentAdapter: AdminPaymentAdapterSubPackage; /** Calls for the app to sign as a user operation, built here so the backend holds the purchase logic. */ calls: AdminCallsSubPackage; + /** Checks what a mined transaction paid, before the backend acts on it. */ + utils: AdminUtilsSubPackage; // Instance-scoped surfaces - undefined until their address is known. deviceWallet?: AdminDeviceWalletSubPackage; @@ -88,6 +101,7 @@ export class KokioAdmin { this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient); this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient); this.calls = new AdminCallsSubPackage(walletClient); + this.utils = new AdminUtilsSubPackage(walletClient); this.deviceWallet = deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, deviceWalletAddress) : undefined; this.eSIMWallet = eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, eSIMWalletAddress) : undefined; @@ -146,6 +160,7 @@ export class KokioAdmin { this.protocolAdmin = new AdminProtocolAdminSubPackage(walletClient); this.paymentAdapter = new AdminPaymentAdapterSubPackage(walletClient); this.calls = new AdminCallsSubPackage(walletClient); + this.utils = new AdminUtilsSubPackage(walletClient); this.deviceWallet = this.deviceWalletAddress ? new AdminDeviceWalletSubPackage(walletClient, this.deviceWalletAddress) : undefined; this.eSIMWallet = this.eSIMWalletAddress ? new AdminESIMWalletSubPackage(walletClient, this.eSIMWalletAddress) : undefined; diff --git a/src/admin/interface/utilsClass.ts b/src/admin/interface/utilsClass.ts new file mode 100644 index 0000000..b322ba7 --- /dev/null +++ b/src/admin/interface/utilsClass.ts @@ -0,0 +1,33 @@ +import { Address, Hash, TransactionReceipt, WalletClient } from "viem"; +import { _verifyERC20Transfer, _verifyProtocolPayment } from "../../logic/admin/utils/tokenTransfer.js"; + +/** + * Checks what a mined transaction actually paid. Nothing is sent. Pass a hash + * when it came from a user: a receipt is used as given, so it must come from + * your own node. + */ +export class AdminUtilsSubPackage { + + walletClient: WalletClient; + + constructor(walletClient: WalletClient) { + this.walletClient = walletClient; + } + + /** + * The purchases `eSIMWallet` paid for through the protocol in `symbol` + * ("USDC", "USDCt"), with each one's `paymentReference` and price in cents. + * Throws if the symbol is not an onchain currency on the payment adapter. + */ + verifyProtocolPayment(transaction: Hash | TransactionReceipt, symbol: string, eSIMWallet: Address) { + return _verifyProtocolPayment(this.walletClient, transaction, symbol, eSIMWallet); + } + + /** + * What `sender` sent `destination` directly in any ERC-20. The cents count + * one token as one dollar, so they mean nothing for a token that is not. + */ + verifyERC20Transfer(transaction: Hash | TransactionReceipt, token: Address, sender: Address, destination: Address) { + return _verifyERC20Transfer(this.walletClient, transaction, token, sender, destination); + } +} diff --git a/src/logic/admin/utils/tokenTransfer.ts b/src/logic/admin/utils/tokenTransfer.ts new file mode 100644 index 0000000..722f368 --- /dev/null +++ b/src/logic/admin/utils/tokenTransfer.ts @@ -0,0 +1,211 @@ +import { + Address, + BaseError, + ContractFunctionRevertedError, + ContractFunctionZeroDataError, + AbiDecodingDataSizeTooSmallError, + AbiDecodingZeroDataError, + Hash, + Hex, + TransactionReceipt, + TransactionReceiptNotFoundError, + WalletClient, + erc20Abi, + isAddress, + isAddressEqual, + isHash, + parseEventLogs, + publicActions, + stringToHex, + zeroAddress, +} from "viem"; +import { _chainId, _getChainSpecificConstants } from "../../constants.js"; +import { ESIMWallet, PaymentAdapter, Registry } from "../../../abis/index.js"; +import { + InvalidAddressError, + InvalidSymbolError, + NotAProtocolESIMWalletError, + NotAnERC20TokenError, + PriceOutOfRangeError, + ReceiptNotCanonicalError, + TokenNotAcceptedError, + TransactionRevertedError, + UnknownTransactionError, + UnmatchedPaymentEventsError, +} from "../../errors.js"; +import { ERC20TransferCheck, Finality, ProtocolPaymentCheck } from "../../../types.js"; + +// The contracts price everything in uint64 cents. +const MAX_UINT64 = 2n ** 64n - 1n; + +const _checkAddress = (what: string, value: string): Address => { + if (!isAddress(value)) throw new InvalidAddressError(what, value, "is not a valid address"); + if (isAddressEqual(value, zeroAddress)) throw new InvalidAddressError(what, value, "is the zero address"); + return value; +} + +// Same bytes as Solidity's `bytes32("USDC")`: the text on the left, zeros after. +const _symbolToBytes32 = (symbol: string): Hex => { + if (!symbol) throw new InvalidSymbolError(symbol); + try { + return stringToHex(symbol, { size: 32 }); + } catch { + throw new InvalidSymbolError(symbol); + } +} + +const _checkTransaction = (transaction: Hash | TransactionReceipt) => { + if (typeof transaction === "string" && !isHash(transaction)) throw new UnknownTransactionError(transaction); +} + +// A receipt passed in is used as given, so it must come from the caller's own node. +const _receipt = async (client: WalletClient, transaction: Hash | TransactionReceipt): Promise => { + let receipt = transaction as TransactionReceipt; + if (typeof transaction === "string") { + try { + receipt = await client.extend(publicActions).getTransactionReceipt({ hash: transaction }); + } catch (err) { + if (err instanceof TransactionReceiptNotFoundError) throw new UnknownTransactionError(transaction); + throw err; + } + } + if (receipt.status !== "success") throw new TransactionRevertedError(receipt.transactionHash); + return receipt; +} + +// The receipt plus how far its block has settled, read alongside it. A receipt passed in +// is also checked against the chain's block at its height, since it may be from a block +// since reorged out. One fetched by hash comes from the chain as it is now. +const _receiptWithFinality = async (client: WalletClient, transaction: Hash | TransactionReceipt) => { + const publicClient = client.extend(publicActions); + const given = typeof transaction === "string" ? undefined : transaction; + + const [receipt, safe, finalized, canonical] = await Promise.all([ + _receipt(client, transaction), + publicClient.getBlock({ blockTag: "safe" }), + publicClient.getBlock({ blockTag: "finalized" }), + given && publicClient.getBlock({ blockNumber: given.blockNumber }), + ]); + + if (canonical && canonical.hash !== receipt.blockHash) { + throw new ReceiptNotCanonicalError(receipt.transactionHash, receipt.blockHash); + } + + const finality: Finality = receipt.blockNumber <= finalized.number ? "finalized" + : receipt.blockNumber <= safe.number ? "safe" + : "latest"; + + return { receipt, landed: { finality, blockNumber: receipt.blockNumber, blockHash: receipt.blockHash } }; +} + +/** + * Every purchase `eSIMWallet` paid for in `symbol` within one transaction, read + * from the payment adapter's `PaymentSettled` and the eSIM wallet's + * `DataBundleBoughtWithToken`. Cents and references are the contracts' own. + */ +export const _verifyProtocolPayment = async ( + client: WalletClient, + transaction: Hash | TransactionReceipt, + symbol: string, + eSIMWallet: Address, +): Promise => { + const wallet = _checkAddress("eSIM wallet", eSIMWallet); + const asset = _symbolToBytes32(symbol); + _checkTransaction(transaction); + + const publicClient = client.extend(publicActions); + const reads = async () => { + const F = _getChainSpecificConstants(await _chainId(client), client.transport.url).factoryAddresses; + const [deviceWallet, entry] = await Promise.all([ + publicClient.readContract({ address: F.REGISTRY, abi: Registry, functionName: "isESIMWalletValid", args: [wallet] }), + publicClient.readContract({ address: F.PAYMENT_ADAPTER, abi: PaymentAdapter, functionName: "assets", args: [asset] }), + ]); + return { adapter: F.PAYMENT_ADAPTER, deviceWallet, entry }; + }; + + const [{ receipt, landed }, { adapter, deviceWallet, entry: [, , decimals, token] }] = await Promise.all([ + _receiptWithFinality(client, transaction), + reads(), + ]); + + if (deviceWallet === zeroAddress) throw new NotAProtocolESIMWalletError(wallet); + // A registered symbol always has non-zero decimals, even once withdrawn. + if (decimals === 0) throw new TokenNotAcceptedError(symbol, "NOT_REGISTERED"); + if (token === zeroAddress) throw new TokenNotAcceptedError(symbol, "NOT_ONCHAIN"); + + // Filtered on the emitting address, so a look-alike event from any other contract is ignored. + const settled = parseEventLogs({ abi: PaymentAdapter, eventName: "PaymentSettled", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, adapter) && log.args._symbol === asset && isAddressEqual(log.args._eSIMWallet, wallet)); + const bought = parseEventLogs({ abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, wallet) && log.args._asset === asset); + + // The adapter settles first and the wallet emits after, once per purchase. + if (settled.length !== bought.length) throw new UnmatchedPaymentEventsError(receipt.transactionHash); + const payments = settled.map((s, i) => { + const b = bought[i]; + if (s.logIndex > b.logIndex || s.args._priceUSDCents !== b.args._priceUSDCents || s.args._spent !== b.args._amountSpent) { + throw new UnmatchedPaymentEventsError(receipt.transactionHash); + } + return { + paymentReference: b.args._paymentReference, + dataBundleId: b.args._dataBundleID, + priceUSDCents: b.args._priceUSDCents, + amountSpent: b.args._amountSpent, + vault: s.args._vault, + }; + }); + + return { priceUSDCents: payments.reduce((sum, p) => sum + p.priceUSDCents, 0n), payments, ...landed }; +} + +// A revert or an empty answer means the address is not an ERC-20. Anything else, like a +// network failure, is passed on as-is. +const _erc20Read = (read: Promise, token: Address): Promise => read.catch((err) => { + const notAToken = err instanceof BaseError && err.walk((e) => + e instanceof ContractFunctionRevertedError || + e instanceof ContractFunctionZeroDataError || + e instanceof AbiDecodingZeroDataError || + e instanceof AbiDecodingDataSizeTooSmallError); + throw notAToken ? new NotAnERC20TokenError(token) : err; +}); + +/** + * What `sender` sent `destination` directly in `token` within one transaction. + * Cents treat one token as one dollar, the way the payment adapter prices a + * dollar currency, so they mean nothing for a token that is not. + */ +export const _verifyERC20Transfer = async ( + client: WalletClient, + transaction: Hash | TransactionReceipt, + token: Address, + sender: Address, + destination: Address, +): Promise => { + const tokenAddress = _checkAddress("Token", token); + const from = _checkAddress("Sender", sender); + const to = _checkAddress("Destination", destination); + if (isAddressEqual(from, to)) throw new InvalidAddressError("Destination", to, "is the same as the sender"); + _checkTransaction(transaction); + + const publicClient = client.extend(publicActions); + + // decimals() is what an ERC-721 lacks, and an address with no code answers neither. + const [{ receipt, landed }, decimals] = await Promise.all([ + _receiptWithFinality(client, transaction), + _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "decimals" }), tokenAddress), + _erc20Read(publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "totalSupply" }), tokenAddress), + ]); + + // Strict parsing skips an ERC-721 Transfer, whose third topic is the token id. + const amount = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }) + .filter((log) => isAddressEqual(log.address, tokenAddress) && isAddressEqual(log.args.from, from) && isAddressEqual(log.args.to, to)) + .reduce((sum, log) => sum + log.args.value, 0n); + + // amount * 100 / 10^decimals, without the large intermediate. + const priceUSDCents = decimals >= 2 + ? amount / 10n ** BigInt(decimals - 2) + : amount * 10n ** BigInt(2 - decimals); + if (priceUSDCents > MAX_UINT64) throw new PriceOutOfRangeError(priceUSDCents); + + return { priceUSDCents, amount, ...landed }; +} diff --git a/src/logic/errors.ts b/src/logic/errors.ts index 389807a..7c688d0 100644 --- a/src/logic/errors.ts +++ b/src/logic/errors.ts @@ -202,6 +202,128 @@ export class StalledBatchError extends KokioError { } } +/** An address argument is malformed, zero, or the same as the other side of the transfer. */ +export class InvalidAddressError extends KokioError { + readonly value: string; + + constructor(what: string, value: string, reason: string) { + super("INVALID_ADDRESS", `${what} ${value} ${reason}.`); + this.value = value; + } +} + +/** A currency symbol is empty or does not fit the contracts' 32-byte symbol. */ +export class InvalidSymbolError extends KokioError { + readonly symbol: string; + + constructor(symbol: string) { + super("INVALID_SYMBOL", `Symbol "${symbol}" must be 1 to 32 bytes long.`); + this.symbol = symbol; + } +} + +/** + * The payment adapter cannot have taken this symbol as an onchain payment: + * `NOT_REGISTERED` if it was never added, `NOT_ONCHAIN` for fiat and non-EVM + * entries, which have no token address. + */ +export class TokenNotAcceptedError extends KokioError { + readonly symbol: string; + readonly reason: "NOT_REGISTERED" | "NOT_ONCHAIN"; + + constructor(symbol: string, reason: "NOT_REGISTERED" | "NOT_ONCHAIN") { + super( + "TOKEN_NOT_ACCEPTED", + reason === "NOT_REGISTERED" + ? `Symbol "${symbol}" is not registered on the payment adapter.` + : `Symbol "${symbol}" has no token address on the payment adapter, so it cannot be paid onchain.`, + ); + this.symbol = symbol; + this.reason = reason; + } +} + +/** The registry has no record of this address as an eSIM wallet. */ +export class NotAProtocolESIMWalletError extends KokioError { + readonly address: string; + + constructor(address: string) { + super("NOT_A_PROTOCOL_ESIM_WALLET", `${address} is not an eSIM wallet the registry knows.`); + this.address = address; + } +} + +/** No mined transaction with this hash on the client's chain. */ +export class UnknownTransactionError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("UNKNOWN_TRANSACTION", `No mined transaction ${hash} on this chain.`); + this.hash = hash; + } +} + +/** The transaction was mined but reverted, so nothing in it moved. */ +export class TransactionRevertedError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("TRANSACTION_REVERTED", `Transaction ${hash} reverted.`); + this.hash = hash; + } +} + +/** + * A receipt passed in is from a block the chain no longer has at that height, + * so it was reorged out. The payment may have landed again elsewhere. + */ +export class ReceiptNotCanonicalError extends KokioError { + readonly hash: Hex; + readonly blockHash: Hex; + + constructor(hash: Hex, blockHash: Hex) { + super( + "RECEIPT_NOT_CANONICAL", + `The receipt for ${hash} is from block ${blockHash}, which is no longer on the chain. Check again by hash.`, + ); + this.hash = hash; + this.blockHash = blockHash; + } +} + +/** The address does not answer `decimals()` and `totalSupply()` like an ERC-20. */ +export class NotAnERC20TokenError extends KokioError { + readonly token: string; + + constructor(token: string) { + super("NOT_AN_ERC20_TOKEN", `${token} is not an ERC-20 token on this chain.`); + this.token = token; + } +} + +/** + * A purchase's adapter event and eSIM wallet event disagree or do not pair up. + * The current contracts always emit them together, so this means they changed. + */ +export class UnmatchedPaymentEventsError extends KokioError { + readonly hash: Hex; + + constructor(hash: Hex) { + super("UNMATCHED_PAYMENT_EVENTS", `Transaction ${hash} has payment events that do not pair up.`); + this.hash = hash; + } +} + +/** A cent figure too large for the uint64 the contracts price in. */ +export class PriceOutOfRangeError extends KokioError { + readonly priceUSDCents: bigint; + + constructor(priceUSDCents: bigint) { + super("PRICE_OUT_OF_RANGE", `${priceUSDCents} cents does not fit the contracts' uint64 price.`); + this.priceUSDCents = priceUSDCents; + } +} + // Every ABI that can surface a custom error from an on-chain revert. viem's // `decodeErrorResult` walks each ABI's `error` fragments to match the 4-byte // selector in the revert data. diff --git a/src/types-export.ts b/src/types-export.ts index eb1ba92..f39152a 100644 --- a/src/types-export.ts +++ b/src/types-export.ts @@ -15,7 +15,12 @@ export type { LazyDeploymentBatch, LazyDeployment, LazyHistoryBatch, - LazyHistoryCopy + LazyHistoryCopy, + Finality, + TransactionFinality, + ProtocolPayment, + ProtocolPaymentCheck, + ERC20TransferCheck } from './types.js'; export type { KokioConstants } from './interface/constantsClass.js'; diff --git a/src/types.ts b/src/types.ts index a701490..78d3b18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,6 +174,46 @@ export type LazyHistoryCopy = { alreadyComplete: boolean; } +/** One data bundle an eSIM wallet paid for through the protocol. */ +export type ProtocolPayment = { + /** The offchain order id the purchase spent. */ + paymentReference: Hex; + dataBundleId: Hex; + /** 123456n is $1234.56. */ + priceUSDCents: bigint; + /** What reached the vault, in the token's smallest unit. */ + amountSpent: bigint; + vault: Address; +} + +/** + * How far a block has settled. `latest` can still be reorged out, `safe` only by + * an L1 reorg, and `finalized` not at all. + */ +export type Finality = "latest" | "safe" | "finalized"; + +/** Where a checked transaction landed, to store and check again later. */ +export type TransactionFinality = { + finality: Finality; + blockNumber: bigint; + blockHash: Hex; +} + +/** Every protocol purchase one eSIM wallet made in one transaction and currency. */ +export type ProtocolPaymentCheck = TransactionFinality & { + /** Total across `payments`, 0n if there were none. */ + priceUSDCents: bigint; + payments: readonly ProtocolPayment[]; +} + +/** What one address sent another directly in one ERC-20, in one transaction. */ +export type ERC20TransferCheck = TransactionFinality & { + /** `amount` as cents, counting one token as one dollar and rounding down. */ + priceUSDCents: bigint; + /** In the token's smallest unit, 0n if nothing was sent. */ + amount: bigint; +} + export type SignedRequest = { body: string; stamp : { diff --git a/tests/consumer/fixtures/forkFlowTarget.ts b/tests/consumer/fixtures/forkFlowTarget.ts index b86ac89..693e219 100644 --- a/tests/consumer/fixtures/forkFlowTarget.ts +++ b/tests/consumer/fixtures/forkFlowTarget.ts @@ -36,6 +36,9 @@ export const startForkFlowTarget = async (): Promise => { fund: (token, to, amount) => setTokenBalance(stack.fork, token, to, amount), priceUSDCents: 500n, confirmations: 1, + mine: async (blocks) => { await stack.fork.testClient.mine({ blocks }); }, + // anvil mines `depth` fresh blocks in place of the old ones, replaying none of their transactions. + reorg: async (depth) => { await stack.fork.testClient.request({ method: "anvil_reorg", params: [depth, []] } as never); }, stop: stack.stop, }; }; diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts index b7a757b..5598c1e 100644 --- a/tests/consumer/flows/userFlow.ts +++ b/tests/consumer/flows/userFlow.ts @@ -1,12 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; import { createWalletClient, encodeAbiParameters, erc20Abi, http, keccak256, stringToHex, - type Address, type Hex, type PublicClient, + type Address, type Hex, type PublicClient, type TransactionReceipt, } from "viem"; import { createBundlerClient } from "viem/account-abstraction"; import { baseSepolia } from "viem/chains"; import { ContractRevertError, Kokio } from "kokio-sdk"; -import type { KokioAdmin } from "kokio-sdk/admin"; +import { + NotAProtocolESIMWalletError, NotAnERC20TokenError, ReceiptNotCanonicalError, UnknownTransactionError, type KokioAdmin, +} from "kokio-sdk/admin"; import { ESIMWallet, ESIMWalletFactory, Registry } from "kokio-sdk/abis"; import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; @@ -33,6 +35,10 @@ export interface FlowTarget { priceUSDCents: bigint; /** Blocks to wait after each write before reading its result. */ confirmations: number; + /** Mines empty blocks, where the chain allows it. Left out on a live chain. */ + mine?: (blocks: number) => Promise; + /** Replaces the last `depth` blocks with empty ones. Left out on a live chain. */ + reorg?: (depth: number) => Promise; /** Block explorer transaction URL prefix, so logged hashes can be opened. */ explorerTx?: string; stop?: () => Promise; @@ -190,6 +196,60 @@ export const describeUserFlow = ( _token: token, _amountSpent: quote, }); + purchaseTx = event.transactionHash; + purchaseBlockHash = event.blockHash; + }, timeout); + + let purchaseTx: Hex; + let purchaseBlockHash: Hex; + + it("the backend checks what the purchase paid from its transaction hash", async () => { + const paid = await admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.eSIMWallet); + log("7", `protocol payment ${paid.priceUSDCents} cents, reference ${paid.payments[0]?.paymentReference}, ${paid.finality}, no transaction`); + + // Just landed, so not yet settled on L1: the backend waits before issuing the eSIM. + expect(paid).toEqual({ + priceUSDCents: bundle.priceUSDCents, + payments: [{ paymentReference: REF_1, dataBundleId: bundle.id, priceUSDCents: bundle.priceUSDCents, amountSpent: quote, vault: await admin.registry.vault() }], + finality: "latest", + blockNumber: purchaseBlock, + blockHash: purchaseBlockHash, + }); + + // The same transaction as a plain ERC-20 transfer: the device wallet funding its eSIM wallet. + const sent = await admin.utils.verifyERC20Transfer(purchaseTx, token, db.deviceWallet, db.eSIMWallet); + expect(sent).toEqual({ priceUSDCents: bundle.priceUSDCents, amount: quote, finality: "latest", blockNumber: purchaseBlock, blockHash: purchaseBlockHash }); + + // The device wallet is not who pays the protocol, so asking with it is refused. + await expect(admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.deviceWallet)) + .rejects.toBeInstanceOf(NotAProtocolESIMWalletError); + }, timeout); + + it("once the purchase's block is finalized, the same check says so", async (ctx) => { + // Base Sepolia takes about half an hour to finalize, too long to wait for here. + if (!target.mine) return ctx.skip(); + // anvil finalizes 64 blocks behind the latest. + await target.mine(64); + + const paid = await admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset, db.eSIMWallet); + log("7", `after 64 blocks the payment is ${paid.finality}, no transaction`); + expect(paid).toMatchObject({ finality: "finalized", blockNumber: purchaseBlock, blockHash: purchaseBlockHash }); + }, timeout); + + it("the backend's checks refuse a token or symbol the chain does not back", async () => { + const { factoryAddresses } = await admin.constants; + + // A contract that is not a token, and an address with no code at all. + const noCode = testBytes32("no-code").slice(0, 42) as Address; + expect(await target.publicClient.getCode({ address: noCode })).toBeUndefined(); + for (const notAToken of [factoryAddresses.REGISTRY as Address, noCode]) { + await expect(admin.utils.verifyERC20Transfer(purchaseTx, notAToken, db.deviceWallet, db.eSIMWallet)) + .rejects.toBeInstanceOf(NotAnERC20TokenError); + } + await expect(admin.utils.verifyProtocolPayment(purchaseTx, "USD", db.eSIMWallet)) + .rejects.toThrow(expect.objectContaining({ reason: "NOT_ONCHAIN" })); + await expect(admin.utils.verifyProtocolPayment(purchaseTx, primaryAsset.toLowerCase(), db.eSIMWallet)) + .rejects.toThrow(expect.objectContaining({ reason: "NOT_REGISTERED" })); }, timeout); const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; @@ -234,6 +294,13 @@ export const describeUserFlow = ( args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, }); expect(event.args).toMatchObject({ _asset: other, _token: otherToken, _amountSpent: otherQuote }); + + // Looked up by this symbol, the purchase is there. By the first one, it is not. + const paid = await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, symbol, db.eSIMWallet); + expect(paid.payments.map((p) => p.paymentReference)).toEqual([ref]); + expect(paid.priceUSDCents).toBe(bundle.priceUSDCents); + expect(await admin.utils.verifyProtocolPayment(receipt.receipt.transactionHash, primaryAsset, db.eSIMWallet)) + .toMatchObject({ priceUSDCents: 0n, payments: [] }); }, timeout); }); @@ -318,6 +385,26 @@ export const describeUserFlow = ( expect(event.args).toMatchObject({ _dataBundleID: bundle.id, _asset: asset, _token: token, _amountSpent: quote }); expect(await balanceOf(token, db.deviceWallet)).toBe(0n); expect(await balanceOf(token, db.eSIMWallet)).toBe(0n); + + // The protocol was paid the full price, though the device wallet sent only the shortfall. + const hash = receipt.receipt.transactionHash; + expect((await admin.utils.verifyProtocolPayment(hash, primaryAsset, db.eSIMWallet)).priceUSDCents).toBe(bundle.priceUSDCents); + expect((await admin.utils.verifyERC20Transfer(hash, token, db.deviceWallet, db.eSIMWallet)).amount).toBe(quote - held); + }, timeout); + + it("two purchases in one user operation are reported one by one", async () => { + const refs = [testBytes32(`fc1-${Date.now()}`), testBytes32(`fc2-${Date.now()}`)]; + await target.fund(token, db.deviceWallet, quote * 2n); + + // Each call set sends the full quote, since both are built while the eSIM wallet holds nothing. + const calls = (await Promise.all(refs.map((ref) => admin.calls.buyDataBundleWithTransfer(db.eSIMWallet, bundle, asset, quote, ref)))).flat(); + const receipt = await sponsored("two in one", () => session.deviceWallet!.sendUserOperation(calls)); + + const paid = await admin.utils.verifyProtocolPayment(receipt.receipt, primaryAsset, db.eSIMWallet); + lastPurchase = receipt.receipt; + log("two in one", `${paid.payments.length} payments, ${paid.priceUSDCents} cents in all`); + expect(paid.payments.map((p) => p.paymentReference)).toEqual(refs); + expect(paid.priceUSDCents).toBe(bundle.priceUSDCents * 2n); }, timeout); it("the eSIM moves to a new device, which signs calls the backend built", async () => { @@ -351,6 +438,23 @@ export const describeUserFlow = ( expect(await session.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(false); }, timeout); + let lastPurchase: TransactionReceipt; + + // Last, since it rewrites the chain under everything above. + it("a receipt kept from before a reorg is refused once its block is gone", async (ctx) => { + if (!target.reorg) return ctx.skip(); + const latest = await target.publicClient.getBlockNumber(); + await target.reorg(Number(latest - lastPurchase.blockNumber + 1n)); + + await expect(admin.utils.verifyProtocolPayment(lastPurchase, primaryAsset, db.eSIMWallet)) + .rejects.toBeInstanceOf(ReceiptNotCanonicalError); + + // Looked up by hash, the payment is either gone or in a new block, never the old one. + const again = await admin.utils.verifyProtocolPayment(lastPurchase.transactionHash, primaryAsset, db.eSIMWallet).catch((e: unknown) => e); + if (again instanceof UnknownTransactionError) return; + expect((again as { blockHash: Hex }).blockHash).not.toBe(lastPurchase.blockHash); + }, timeout); + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); diff --git a/tests/logic/admin/tokenTransfer.test.ts b/tests/logic/admin/tokenTransfer.test.ts new file mode 100644 index 0000000..88efc7b --- /dev/null +++ b/tests/logic/admin/tokenTransfer.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect } from "vitest"; +import { + type Abi, + type Address, + type Hex, + ContractFunctionZeroDataError, + HttpRequestError, + TransactionReceiptNotFoundError, + encodeAbiParameters, + encodeEventTopics, + erc20Abi, + getAddress, + parseAbi, + stringToHex, + zeroAddress, +} from "viem"; + +import { makeMockWalletClient } from "../../utils/mockClient.js"; +import { baseSepoliaFactoryAddresses } from "../../../src/logic/constants.js"; +import { ESIMWallet, PaymentAdapter } from "../../../src/abis/index.js"; +import { _verifyERC20Transfer, _verifyProtocolPayment } from "../../../src/logic/admin/utils/tokenTransfer.js"; +import { + InvalidAddressError, + InvalidSymbolError, + NotAProtocolESIMWalletError, + NotAnERC20TokenError, + PriceOutOfRangeError, + ReceiptNotCanonicalError, + TokenNotAcceptedError, + TransactionRevertedError, + UnknownTransactionError, + UnmatchedPaymentEventsError, +} from "../../../src/logic/errors.js"; + +// --- Fixtures --------------------------------------------------------------- +const F = baseSepoliaFactoryAddresses; +const CHAIN_ID = 84532; +const HASH = "0x00000000000000000000000000000000000000000000000000000000000000a1" as Hex; + +const ESIM = "0x00000000000000000000000000000000000e51a1" as Address; +const OTHER_ESIM = "0x00000000000000000000000000000000000e51a2" as Address; +const DEVICE = "0x00000000000000000000000000000000000dead1" as Address; +// Checksummed, the way viem decodes an address out of an event. +const VAULT = getAddress("0x000000000000000000000000000000000000a017"); +const TOKEN = "0x0000000000000000000000000000000000706b31" as Address; +const SENDER = "0x0000000000000000000000000000000000005e0d" as Address; +const DESTINATION = "0x000000000000000000000000000000000000de57" as Address; +const STRANGER = "0x0000000000000000000000000000000000000bad" as Address; + +const SYMBOL = "USDCt"; +const ASSET = stringToHex(SYMBOL, { size: 32 }); +const REF_1 = stringToHex("order-1", { size: 32 }); +const REF_2 = stringToHex("order-2", { size: 32 }); +const BUNDLE = stringToHex("bundle", { size: 32 }); + +const erc721Abi = parseAbi(["event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"]); + +// Encodes a real log, so the SDK's parsing runs exactly as it does against a node. +let logIndex = 0; +const log = (address: Address, abi: Abi, eventName: string, args: Record) => { + const event = abi.find((item) => item.type === "event" && item.name === eventName) as { inputs: { name: string; type: string; indexed?: boolean }[] }; + const nonIndexed = event.inputs.filter((input) => !input.indexed); + return { + address, + topics: encodeEventTopics({ abi, eventName, args } as never), + data: encodeAbiParameters(nonIndexed, nonIndexed.map((input) => args[input.name])), + logIndex: logIndex++, + blockHash: HASH, blockNumber: 1n, transactionHash: HASH, transactionIndex: 0, removed: false, + }; +}; + +// Every receipt lands in block 100. The chain below has it finalized unless a test says otherwise. +const BLOCK = 100n; +const BLOCK_HASH = "0x00000000000000000000000000000000000000000000000000000000000b0c64" as Hex; +const LANDED = { finality: "finalized", blockNumber: BLOCK, blockHash: BLOCK_HASH } as const; + +const receipt = (logs: unknown[], status = "success") => + ({ status, transactionHash: HASH, blockNumber: BLOCK, blockHash: BLOCK_HASH, logs }) as never; + +type Chain = { safe: bigint; finalized: bigint; hashAt?: Hex }; +const chainReads = ({ safe, finalized, hashAt = BLOCK_HASH }: Chain) => + ({ blockTag, blockNumber }: { blockTag?: string; blockNumber?: bigint }) => { + if (blockTag === "safe") return { number: safe }; + if (blockTag === "finalized") return { number: finalized }; + return { number: blockNumber, hash: hashAt }; + }; +const FINAL_CHAIN: Chain = { safe: 190n, finalized: 150n }; + +// One protocol purchase: the adapter settles, then the eSIM wallet records it. +const purchase = (opts: { wallet?: Address; asset?: Hex; ref?: Hex; cents?: bigint; spent?: bigint } = {}) => { + const { wallet = ESIM, asset = ASSET, ref = REF_1, cents = 500n, spent = cents * 10_000n } = opts; + return [ + log(F.PAYMENT_ADAPTER, PaymentAdapter, "PaymentSettled", { + _symbol: asset, _eSIMWallet: wallet, _vault: VAULT, _priceUSDCents: cents, _spent: spent, _refunded: 0n, + }), + log(wallet, ESIMWallet, "DataBundleBoughtWithToken", { + _dataBundleID: BUNDLE, _priceUSDCents: cents, _asset: asset, _token: TOKEN, _amountSpent: spent, _paymentReference: ref, + }), + ]; +}; + +const transfer = (token: Address, from: Address, to: Address, value: bigint) => + log(token, erc20Abi, "Transfer", { from, to, value }); + +const protocolClient = (reads: Record = {}, getReceipt?: () => unknown, chain = FINAL_CHAIN) => makeMockWalletClient({ + chainId: CHAIN_ID, + reads: { isESIMWalletValid: DEVICE, assets: [true, true, 6, TOKEN], ...reads }, + getReceipt, + getBlock: chainReads(chain), +}); + +const erc20Client = (reads: Record = {}, getReceipt?: () => unknown, chain = FINAL_CHAIN) => makeMockWalletClient({ + chainId: CHAIN_ID, + reads: { decimals: 6, totalSupply: 1_000_000n, ...reads }, + getReceipt, + getBlock: chainReads(chain), +}); + +// --- verifyProtocolPayment --------------------------------------------------- +describe("_verifyProtocolPayment", () => { + it("returns the contracts' cents, reference and vault for one purchase", async () => { + const result = await _verifyProtocolPayment(protocolClient(), receipt(purchase({ cents: 123_456n })), SYMBOL, ESIM); + + expect(result).toEqual({ + priceUSDCents: 123_456n, + payments: [{ paymentReference: REF_1, dataBundleId: BUNDLE, priceUSDCents: 123_456n, amountSpent: 1_234_560_000n, vault: VAULT }], + ...LANDED, + }); + }); + + it("passes the symbol to the adapter as the contracts' bytes32", async () => { + const client = protocolClient(); + await _verifyProtocolPayment(client, receipt(purchase()), SYMBOL, ESIM); + + expect(client.readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: F.PAYMENT_ADAPTER, functionName: "assets", args: ["0x5553444374000000000000000000000000000000000000000000000000000000"], + })); + }); + + it("totals several purchases by the wallet and skips other wallets and currencies", async () => { + const logs = [ + ...purchase({ ref: REF_1, cents: 500n }), + ...purchase({ wallet: OTHER_ESIM, cents: 900n }), + ...purchase({ asset: stringToHex("USDC", { size: 32 }), cents: 700n }), + ...purchase({ ref: REF_2, cents: 250n }), + ]; + const result = await _verifyProtocolPayment(protocolClient(), receipt(logs), SYMBOL, ESIM); + + expect(result.priceUSDCents).toBe(750n); + expect(result.payments.map((p) => p.paymentReference)).toEqual([REF_1, REF_2]); + }); + + it("ignores a look-alike settlement from a contract that is not the adapter", async () => { + const fake = log(STRANGER, PaymentAdapter, "PaymentSettled", { + _symbol: ASSET, _eSIMWallet: ESIM, _vault: STRANGER, _priceUSDCents: 99_999n, _spent: 1n, _refunded: 0n, + }); + const result = await _verifyProtocolPayment(protocolClient(), receipt([fake, ...purchase()]), SYMBOL, ESIM); + + expect(result.payments).toHaveLength(1); + expect(result.payments[0].vault).toBe(VAULT); + }); + + it("answers 0 cents and no payments when the wallet bought nothing", async () => { + const result = await _verifyProtocolPayment(protocolClient(), receipt([transfer(TOKEN, ESIM, VAULT, 5n)]), SYMBOL, ESIM); + expect(result).toEqual({ priceUSDCents: 0n, payments: [], ...LANDED }); + }); + + it("refuses events that do not pair up", async () => { + const [settled, bought] = purchase(); + await expect(_verifyProtocolPayment(protocolClient(), receipt([settled]), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + // The wallet's event landing before the adapter's. + const swapped = [{ ...bought, logIndex: settled.logIndex }, { ...settled, logIndex: bought.logIndex }]; + await expect(_verifyProtocolPayment(protocolClient(), receipt(swapped), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + + const [, otherPrice] = purchase({ cents: 501n, spent: 5_000_000n }); + await expect(_verifyProtocolPayment(protocolClient(), receipt([settled, otherPrice]), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(UnmatchedPaymentEventsError); + }); + + it("refuses an address the registry does not know as an eSIM wallet", async () => { + await expect(_verifyProtocolPayment(protocolClient({ isESIMWalletValid: zeroAddress }), receipt(purchase()), SYMBOL, DEVICE)) + .rejects.toBeInstanceOf(NotAProtocolESIMWalletError); + }); + + it("refuses a symbol never registered, and one with no token", async () => { + await expect(_verifyProtocolPayment(protocolClient({ assets: [false, false, 0, zeroAddress] }), receipt([]), "usdct", ESIM)) + .rejects.toThrow(expect.objectContaining({ constructor: TokenNotAcceptedError, reason: "NOT_REGISTERED" })); + await expect(_verifyProtocolPayment(protocolClient({ assets: [true, true, 2, zeroAddress] }), receipt([]), "USD", ESIM)) + .rejects.toThrow(expect.objectContaining({ constructor: TokenNotAcceptedError, reason: "NOT_ONCHAIN" })); + }); + + it("still verifies a symbol withdrawn after the payment", async () => { + const result = await _verifyProtocolPayment(protocolClient({ assets: [false, true, 6, TOKEN] }), receipt(purchase()), SYMBOL, ESIM); + expect(result.payments).toHaveLength(1); + }); + + it("refuses an empty symbol and one longer than 32 bytes before any read", async () => { + const client = protocolClient(); + await expect(_verifyProtocolPayment(client, receipt([]), "", ESIM)).rejects.toBeInstanceOf(InvalidSymbolError); + await expect(_verifyProtocolPayment(client, receipt([]), "X".repeat(33), ESIM)).rejects.toBeInstanceOf(InvalidSymbolError); + expect(client.readContract).not.toHaveBeenCalled(); + }); + + it("refuses a malformed or zero eSIM wallet", async () => { + await expect(_verifyProtocolPayment(protocolClient(), receipt([]), SYMBOL, "0x1234" as Address)) + .rejects.toBeInstanceOf(InvalidAddressError); + await expect(_verifyProtocolPayment(protocolClient(), receipt([]), SYMBOL, zeroAddress)) + .rejects.toBeInstanceOf(InvalidAddressError); + }); + + it("refuses a reverted transaction", async () => { + await expect(_verifyProtocolPayment(protocolClient(), receipt(purchase(), "reverted"), SYMBOL, ESIM)) + .rejects.toBeInstanceOf(TransactionRevertedError); + }); + + it("looks a hash up, and sends every read before the receipt answers", async () => { + let answer!: (value: unknown) => void; + const client = protocolClient({}, () => new Promise((resolve) => { answer = resolve; })); + const pending = _verifyProtocolPayment(client, HASH, SYMBOL, ESIM); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.readContract).toHaveBeenCalledTimes(2); + answer(receipt(purchase())); + + expect((await pending).payments).toHaveLength(1); + }); + + it("reports a hash with no mined transaction, and a malformed hash without asking the node", async () => { + const client = protocolClient({}, () => { throw new TransactionReceiptNotFoundError({ hash: HASH }); }); + await expect(_verifyProtocolPayment(client, HASH, SYMBOL, ESIM)).rejects.toBeInstanceOf(UnknownTransactionError); + + const untouched = protocolClient(); + await expect(_verifyProtocolPayment(untouched, "0x1234" as Hex, SYMBOL, ESIM)).rejects.toBeInstanceOf(UnknownTransactionError); + expect(untouched.readContract).not.toHaveBeenCalled(); + }); +}); + +// --- Finality, for both checks ----------------------------------------------- +describe("how far the transaction's block has settled", () => { + const checks = [ + // Each check is given a receipt, or a hash when one is passed. + ["_verifyProtocolPayment", (chain: Chain, hash?: Hex) => + _verifyProtocolPayment(protocolClient({}, () => receipt(purchase()), chain), hash ?? receipt(purchase()), SYMBOL, ESIM)], + ["_verifyERC20Transfer", (chain: Chain, hash?: Hex) => + _verifyERC20Transfer(erc20Client({}, () => receipt([]), chain), hash ?? receipt([]), TOKEN, SENDER, DESTINATION)], + ] as const; + + describe.each(checks)("%s", (_, check) => { + it("reports latest, safe or finalized against the chain's own tags, inclusive", async () => { + expect(await check({ safe: 99n, finalized: 90n })).toMatchObject({ finality: "latest", blockNumber: BLOCK, blockHash: BLOCK_HASH }); + expect((await check({ safe: 100n, finalized: 99n })).finality).toBe("safe"); + expect((await check({ safe: 100n, finalized: 100n })).finality).toBe("finalized"); + }); + + it("refuses a receipt passed in from a block since reorged out", async () => { + const reorged = { ...FINAL_CHAIN, hashAt: "0x00000000000000000000000000000000000000000000000000000000000000ff" as Hex }; + await expect(check(reorged)).rejects.toBeInstanceOf(ReceiptNotCanonicalError); + }); + + it("trusts a receipt fetched by hash without reading its block again", async () => { + // A reorged block at that height would be caught if it were read, so passing proves it was not. + const reorged = { ...FINAL_CHAIN, hashAt: "0x00000000000000000000000000000000000000000000000000000000000000ff" as Hex }; + expect((await check(reorged, HASH)).finality).toBe("finalized"); + }); + }); + + it("reads both tags before the receipt answers", async () => { + let answer!: (value: unknown) => void; + const client = erc20Client({}, () => new Promise((resolve) => { answer = resolve; })); + const pending = _verifyERC20Transfer(client, HASH, TOKEN, SENDER, DESTINATION); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.getBlock).toHaveBeenCalledWith({ blockTag: "safe" }); + expect(client.getBlock).toHaveBeenCalledWith({ blockTag: "finalized" }); + answer(receipt([])); + + expect((await pending).finality).toBe("finalized"); + }); +}); + +// --- verifyERC20Transfer ----------------------------------------------------- +describe("_verifyERC20Transfer", () => { + it("totals direct transfers from sender to destination and nothing else", async () => { + const logs = [ + transfer(TOKEN, SENDER, DESTINATION, 1_000_000n), + transfer(TOKEN, SENDER, DESTINATION, 500_000n), + transfer(TOKEN, STRANGER, DESTINATION, 7n), // someone else paying + transfer(TOKEN, SENDER, STRANGER, 7n), // sender paying someone else + transfer(TOKEN, DESTINATION, SENDER, 200_000n), // sent back, not subtracted + transfer(STRANGER, SENDER, DESTINATION, 9n), // look-alike from another contract + log(TOKEN, erc721Abi, "Transfer", { from: SENDER, to: DESTINATION, tokenId: 3n }), + ]; + const result = await _verifyERC20Transfer(erc20Client(), receipt(logs), TOKEN, SENDER, DESTINATION); + + expect(result).toEqual({ priceUSDCents: 150n, amount: 1_500_000n, ...LANDED }); + }); + + it("rounds cents down and scales any number of decimals", async () => { + const one = (value: bigint) => receipt([transfer(TOKEN, SENDER, DESTINATION, value)]); + + expect(await _verifyERC20Transfer(erc20Client(), one(1_234_567n), TOKEN, SENDER, DESTINATION)) + .toMatchObject({ priceUSDCents: 123n, amount: 1_234_567n }); + expect((await _verifyERC20Transfer(erc20Client({ decimals: 18 }), one(12_345n * 10n ** 16n), TOKEN, SENDER, DESTINATION)).priceUSDCents) + .toBe(12_345n); + expect((await _verifyERC20Transfer(erc20Client({ decimals: 0 }), one(12n), TOKEN, SENDER, DESTINATION)).priceUSDCents) + .toBe(1_200n); + }); + + it("answers 0 when nothing was sent", async () => { + expect(await _verifyERC20Transfer(erc20Client(), receipt([]), TOKEN, SENDER, DESTINATION)) + .toEqual({ priceUSDCents: 0n, amount: 0n, ...LANDED }); + }); + + it("refuses an address that does not answer like an ERC-20", async () => { + const empty = erc20Client({ decimals: () => { throw new ContractFunctionZeroDataError({ functionName: "decimals" }); } }); + await expect(_verifyERC20Transfer(empty, receipt([]), TOKEN, SENDER, DESTINATION)).rejects.toBeInstanceOf(NotAnERC20TokenError); + }); + + it("passes a network failure on rather than calling the address a non-token", async () => { + const down = erc20Client({ totalSupply: () => { throw new HttpRequestError({ url: "https://rpc.test.invalid" }); } }); + await expect(_verifyERC20Transfer(down, receipt([]), TOKEN, SENDER, DESTINATION)).rejects.toBeInstanceOf(HttpRequestError); + }); + + it("refuses a total too large for the contracts' uint64 cents", async () => { + const huge = receipt([transfer(TOKEN, SENDER, DESTINATION, 2n ** 64n)]); + await expect(_verifyERC20Transfer(erc20Client({ decimals: 2 }), huge, TOKEN, SENDER, DESTINATION)) + .rejects.toBeInstanceOf(PriceOutOfRangeError); + }); + + it("refuses zero, malformed and matching addresses before any read", async () => { + const client = erc20Client(); + const cases: [Address, Address, Address][] = [ + [zeroAddress, SENDER, DESTINATION], + [TOKEN, zeroAddress, DESTINATION], + [TOKEN, SENDER, zeroAddress], + [TOKEN, "0xnothex" as Address, DESTINATION], + [TOKEN, SENDER, SENDER], + ]; + for (const [token, from, to] of cases) { + await expect(_verifyERC20Transfer(client, receipt([]), token, from, to)).rejects.toBeInstanceOf(InvalidAddressError); + } + expect(client.readContract).not.toHaveBeenCalled(); + }); + + it("refuses a reverted transaction", async () => { + await expect(_verifyERC20Transfer(erc20Client(), receipt([], "reverted"), TOKEN, SENDER, DESTINATION)) + .rejects.toBeInstanceOf(TransactionRevertedError); + }); + + it("looks a hash up alongside the token reads", async () => { + const client = erc20Client({}, () => receipt([transfer(TOKEN, SENDER, DESTINATION, 10_000n)])); + expect((await _verifyERC20Transfer(client, HASH, TOKEN, SENDER, DESTINATION)).priceUSDCents).toBe(1n); + }); +}); diff --git a/tests/utils/mockClient.ts b/tests/utils/mockClient.ts index 6058a7b..da44dd9 100644 --- a/tests/utils/mockClient.ts +++ b/tests/utils/mockClient.ts @@ -26,8 +26,12 @@ export const makeMockWalletClient = (opts: { simulate?: () => unknown; /** What `writeContract` does. Throw from here to exercise a revert path. */ write?: () => unknown; + /** What `getTransactionReceipt` does, for logic that looks a mined transaction up by hash. */ + getReceipt?: () => unknown; + /** What `getBlock` does, called with its args, for logic that reads a block tag or height. */ + getBlock?: (args: { blockTag?: string; blockNumber?: bigint }) => unknown; }): WalletClient => { - const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write } = opts; + const { chainId, url = "https://rpc.test.invalid", account, readResult = "0xreadresult", reads, receipts, simulate, write, getReceipt, getBlock } = opts; // Each write gets its own hash so a test driving several batches can tell them // apart and check the order they were sent in. @@ -60,6 +64,14 @@ export const makeMockWalletClient = (opts: { return receipt; }), simulateContract: vi.fn(async () => (simulate ? simulate() : { result: undefined })), + getTransactionReceipt: vi.fn(async () => { + if (!getReceipt) throw new Error("Mock client has no getTransactionReceipt result"); + return getReceipt(); + }), + getBlock: vi.fn(async (args: { blockTag?: string; blockNumber?: bigint }) => { + if (!getBlock) throw new Error("Mock client has no getBlock result"); + return getBlock(args); + }), }; client.extend = () => client;