diff --git a/tests/consumer/flows/userFlow.ts b/tests/consumer/flows/userFlow.ts new file mode 100644 index 0000000..c8d4adc --- /dev/null +++ b/tests/consumer/flows/userFlow.ts @@ -0,0 +1,328 @@ +import { afterAll, beforeAll, describe, expect, it, type Mock } from "vitest"; +import { + createWalletClient, encodeAbiParameters, erc20Abi, http, keccak256, stringToHex, + type Address, type Hex, type PublicClient, +} 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 { ESIMWallet, ESIMWalletFactory, Registry } from "kokio-sdk/abis"; +import { Settlement, type KokioSmartAccountClient } from "kokio-sdk/types"; + +import { createSoftSigner } from "../../utils/softP256Signer.js"; +import { asPasskey } from "../fixtures/passkeyAuthenticator.js"; +import { expectSponsored } from "../fixtures/sponsorship.js"; +import { CREDENTIAL_ID, RP_ID, TEST_TAG, testBytes32, testDeviceId } from "../fixtures/testLabels.js"; + +/** Where the flow runs, and the few things that differ between a fork and Base Sepolia. */ +export interface FlowTarget { + rpcUrl: string; + publicClient: PublicClient; + pimlicoAPIKey: string; + policyId: string; + /** Left out to send user operations to Pimlico, as an app does. */ + bundlerUrl?: string; + /** Where the backend looks up a user operation receipt. */ + receiptUrl: string; + /** The backend, signing with its own private key as the registry's eSIM wallet admin. */ + admin: KokioAdmin; + /** Puts `amount` of `token` in the user's device wallet. */ + fund: (token: Address, to: Address, amount: bigint) => Promise; + priceUSDCents: bigint; + /** Blocks to wait after each write before reading its result. */ + confirmations: number; + /** Block explorer transaction URL prefix, so logged hashes can be opened. */ + explorerTx?: string; + stop?: () => Promise; +} + +const E_SIM_SALT = 1n; + +// Signup to an eSIM identifier onchain, in the order an app and its backend run +// it. One test per step, and later steps build on earlier ones. +export const describeUserFlow = ( + name: string, + setup: () => Promise, + passkeyGet: Mock, + // The first asset pays for steps 6 and the top-up. Each other one gets its own + // purchase at the end, since the payment adapter accepts several. + { timeout, assets: [primaryAsset, ...otherAssets] }: { timeout: number; assets: [string, ...string[]] }, +) => describe(name, () => { + let target: FlowTarget; + let admin: KokioAdmin; + let asset: Hex; + let bundle: { id: Hex; priceUSDCents: bigint; settlement: Settlement }; + + // What the backend stores against the user at signup. + const signer = createSoftSigner(RP_ID); + const db = { + uid: testDeviceId(), + salt: BigInt(Date.now()), + ownerKey: signer.ownerKey, + deviceWallet: undefined as unknown as Address, + eSIMWallet: undefined as unknown as Address, + }; + + let session: Kokio; + let client: KokioSmartAccountClient; + + beforeAll(async () => { + target = await setup(); + admin = target.admin; + asset = stringToHex(primaryAsset, { size: 32 }); + bundle = { id: testBytes32("flow-bundle"), priceUSDCents: target.priceUSDCents, settlement: Settlement.DeviceWallet }; + passkeyGet.mockImplementation(asPasskey(signer)); + }, 180_000); + + afterAll(async () => { + // Recorded so a live run can be looked up on a block explorer. + console.log(`device wallet ${db.deviceWallet}, eSIM wallet ${db.eSIMWallet}`); + await target?.stop?.(); + }); + + it("0. the backend works out the device wallet address at signup", async () => { + db.deviceWallet = await admin.deviceWalletFactory.getCounterFactualAddress(db.ownerKey, db.uid, db.salt); + log("0", `device wallet ${db.deviceWallet}, salt ${db.salt}, device ${db.uid}`); + + expect(await target.publicClient.getCode({ address: db.deviceWallet })).toBeUndefined(); + }, timeout); + + it("1. the app resolves the same smart account from the passkey", async () => { + const walletClient = createWalletClient({ chain: baseSepolia, transport: http(target.rpcUrl) }); + const kokio = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, target.pimlicoAPIKey, target.policyId); + + const account = await kokio.smartAccount.getSmartWallet(db.uid, db.ownerKey, db.salt); + log("1", `app resolved ${account.address}, no transaction`); + expect(account.address).toBe(db.deviceWallet); + + client = await kokio.smartAccount.getSmartWalletClient(account, { bundlerUrl: target.bundlerUrl }); + session = new Kokio(walletClient, CREDENTIAL_ID, RP_ID, target.pimlicoAPIKey, target.policyId, client, account.address); + }, timeout); + + it("2. the app deploys the device wallet with a sponsored user operation", async () => { + await sponsored("2", () => session.deviceWallet!.sendUserOperation([])); + + expect(await target.publicClient.getCode({ address: db.deviceWallet })).toMatch(/^0x[0-9a-f]+$/i); + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(false); + }, timeout); + + it("3. the backend registers the device wallet from its own records", async () => { + await waitFor("3", await admin.deviceWalletFactory.postCreateAccount(db.deviceWallet, db.uid, db.ownerKey, db.salt)); + + expect(await admin.registry.isDeviceWalletValid(db.deviceWallet)).toBe(true); + }, timeout); + + let bindUserOp: Hex; + + it("4. the app deploys and binds an eSIM wallet without granting fund access", async () => { + await sponsored("4", async () => { + const result = await session.deviceWallet!.deployAndBindESIMWallet(E_SIM_SALT, { grantAccessToFunds: false }); + db.eSIMWallet = result.eSIMWalletAddress; + return (bindUserOp = result.userOpHash); + }); + session.setESIMWalletAddress(db.eSIMWallet); + log("4", `eSIM wallet ${db.eSIMWallet}`); + + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, timeout); + + it("5. the backend verifies the eSIM wallet belongs to this user's device wallet", async () => { + // The app sends only { eSIMWallet, eSIMSalt, userOpHash }. The device wallet + // comes from the backend's own records. + const bundler = createBundlerClient({ transport: http(target.receiptUrl) }); + const receipt = await bundler.waitForUserOperationReceipt({ hash: bindUserOp }); + expect(receipt.success).toBe(true); + expect(receipt.sender).toBe(db.deviceWallet); + + expect(await admin.registry.isESIMWalletValid(db.eSIMWallet)).toBe(db.deviceWallet); + + admin.setDeviceWalletAddress(db.deviceWallet).setESIMWalletAddress(db.eSIMWallet); + expect(await admin.deviceWallet!.isValidESIMWallet(db.eSIMWallet)).toBe(true); + expect(await admin.eSIMWallet!.owner()).toBe(db.deviceWallet); + + const { factoryAddresses } = await admin.constants; + const expected = await target.publicClient.readContract({ + address: factoryAddresses.ESIM_WALLET_FACTORY as Address, + abi: ESIMWalletFactory, + functionName: "getCounterFactualAddress", + args: [db.deviceWallet, E_SIM_SALT], + }); + expect(expected).toBe(db.eSIMWallet); + log("5", `verified from step 4's transaction ${link(receipt.receipt.transactionHash)}, no transaction`); + }, timeout); + + // Unique per run, so a live rerun never collides with an earlier purchase. + const REF_1 = testBytes32(`fo1-${Date.now()}`); + let token: Address; + let quote: bigint; + let purchaseBlock: bigint; + + it("6. the app buys the bundle, the device wallet sending the tokens in the same operation", async () => { + token = (await session.paymentAdapter!.resolveAsset(asset)).token; + quote = await session.paymentAdapter!.quote(asset, bundle.priceUSDCents); + await target.fund(token, db.deviceWallet, quote * 2n); + + const receipt = await sponsored("6", () => session.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, REF_1)); + purchaseBlock = receipt.receipt.blockNumber; + + expect(await balanceOf(token, db.deviceWallet)).toBe(quote); + expect(await balanceOf(token, db.eSIMWallet)).toBe(0n); + expect(await session.deviceWallet!.canPullFunds(db.eSIMWallet)).toBe(false); + }, timeout); + + it("7. the backend finds the payment by its reference", async () => { + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, + abi: ESIMWallet, + eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: REF_1 }, + // A real backend starts from its last scanned block; hosted RPCs cap the range. + fromBlock: purchaseBlock, + }); + log("7", `payment found in ${link(event.transactionHash)}, no transaction`); + + expect(event.args).toMatchObject({ + _dataBundleID: bundle.id, + _priceUSDCents: bundle.priceUSDCents, + _asset: asset, + _token: token, + _amountSpent: quote, + }); + }, timeout); + + const E_SIM_ID = `${TEST_TAG}:esim-${Date.now()}`; + + it("8. the backend records the eSIM's identifier, once", async () => { + await waitFor("8", await admin.registry.assignESIMIdentifier(db.eSIMWallet, E_SIM_ID)); + + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + + // Refused before sending, so this costs nothing even on a live chain. + const again = await admin.registry.assignESIMIdentifier(db.eSIMWallet, `${E_SIM_ID}-2`).catch((e: unknown) => e); + expect(again).toBeInstanceOf(ContractRevertError); + expect((again as ContractRevertError).decoded?.errorName).toBe("ESIMIdentifierAlreadySet"); + }, timeout); + + it("a top-up repeats steps 6 and 7 on the same eSIM wallet", async () => { + const REF_2 = testBytes32(`fo2-${Date.now()}`); + + await sponsored("top-up", () => session.eSIMWallet!.buyDataBundleWithTransfer(bundle, asset, quote, REF_2)); + + expect(await balanceOf(token, db.deviceWallet)).toBe(0n); + expect((await session.eSIMWallet!.transactionHistory(1n)).id).toBe(bundle.id); + expect(await admin.eSIMWallet!.eSIMUniqueIdentifier()).toBe(E_SIM_ID); + }, timeout); + + otherAssets.forEach((symbol, i) => { + it(`a purchase can also pay with ${symbol}`, async () => { + const other = stringToHex(symbol, { size: 32 }); + const otherToken = (await session.paymentAdapter!.resolveAsset(other)).token; + expect(otherToken).not.toBe(token); + const otherQuote = await session.paymentAdapter!.quote(other, bundle.priceUSDCents); + await target.fund(otherToken, db.deviceWallet, otherQuote); + const ref = testBytes32(`fa${i}-${Date.now()}`); + + const receipt = await sponsored(symbol, () => + session.eSIMWallet!.buyDataBundleWithTransfer(bundle, other, otherQuote, ref)); + + expect(await balanceOf(otherToken, db.deviceWallet)).toBe(0n); + expect((await session.eSIMWallet!.transactionHistory(2n + BigInt(i))).id).toBe(bundle.id); + const [event] = await target.publicClient.getContractEvents({ + address: db.eSIMWallet, abi: ESIMWallet, eventName: "DataBundleBoughtWithToken", + args: { _paymentReference: ref }, fromBlock: receipt.receipt.blockNumber, + }); + expect(event.args).toMatchObject({ _asset: other, _token: otherToken, _amountSpent: otherQuote }); + }, timeout); + }); + + // Paid outside the protocol, from an external wallet or by card. The backend + // confirms the payment offchain, then records it. No money moves onchain. + const recorded = [ + { label: "from an external wallet", symbol: "USDC", settlement: Settlement.ExternalWallet }, + { label: "by card", symbol: "USD", settlement: Settlement.Fiat }, + ]; + // Everything bought above comes first in the eSIM wallet's history. + const historyBefore = 2n + BigInt(otherAssets.length); + + recorded.forEach(({ label, symbol, settlement }, i) => { + it(`a top-up paid ${label} is recorded by the backend`, async () => { + const recordedAsset = stringToHex(symbol, { size: 32 }); + const { token: recordedToken } = await admin.paymentAdapter.resolveAsset(recordedAsset); + const tokenAmount = await admin.paymentAdapter.quote(recordedAsset, bundle.priceUSDCents); + const ref = testBytes32(`ro${i}-${Date.now()}`); + const details = { ...bundle, settlement }; + + const vault = await admin.registry.vault(); + const balances = () => Promise.all([db.deviceWallet, db.eSIMWallet, vault].map((holder) => balanceOf(token, holder))); + const before = await balances(); + + const receipt = await waitFor(label, await admin.registry.recordSettledPurchase(db.eSIMWallet, details, recordedAsset, tokenAmount, ref)); + + const [event] = await target.publicClient.getContractEvents({ + address: (await admin.constants).factoryAddresses.REGISTRY as Address, + abi: Registry, + eventName: "DataBundleSettled", + args: { _eSIMWallet: db.eSIMWallet, _paymentReference: ref }, + fromBlock: receipt.blockNumber, + }); + expect(event.args).toMatchObject({ + _dataBundleID: bundle.id, + _priceUSDCents: bundle.priceUSDCents, + _settlement: settlement, + _asset: recordedAsset, + _token: recordedToken, + _tokenAmount: tokenAmount, + }); + expect(await session.eSIMWallet!.transactionHistory(historyBefore + BigInt(i))).toMatchObject({ id: bundle.id, settlement }); + expect(await admin.registry.usedPaymentReferences(scoped(ref))).toBe(true); + // Nothing moved: the payment happened outside the protocol. + expect(await balances()).toEqual(before); + }, timeout); + }); + + it("the backend cannot record a purchase the contract refuses", async () => { + const usd = stringToHex("USD", { size: 32 }); + const fiat = { ...bundle, settlement: Settlement.Fiat }; + const cap = await session.eSIMWallet!.priceCapUSDCents(); + const record = (details: typeof bundle, symbol: Hex, ref: Hex) => + revertOf(admin.registry.recordSettledPurchase(db.eSIMWallet, details, symbol, 0n, ref)); + + // Refused before sending, so none of these costs anything even on a live chain. + expect(await record(bundle, usd, testBytes32(`rx0-${Date.now()}`))).toBe("SettlementNotAsserted"); + expect(await record(fiat, usd, REF_1)).toBe("PaymentReferenceAlreadyUsed"); + expect(await record({ ...fiat, priceUSDCents: cap + 1n }, usd, testBytes32(`rx1-${Date.now()}`))).toBe("DataBundlePriceAboveCap"); + expect(await record(fiat, testBytes32("coin"), testBytes32(`rx2-${Date.now()}`))).toBe("AssetNotAllowed"); + }, timeout); + + const link = (hash: Hex) => (target.explorerTx ? `${target.explorerTx}${hash}` : hash); + const log = (step: string, message: string) => console.log(`[step ${step}] ${message}`); + + const sponsored = async (step: string, send: () => Promise) => { + const receipt = await expectSponsored(client, 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; + }; + + // Registry.usedPaymentReferences is keyed per eSIM wallet. + const scoped = (ref: Hex) => + keccak256(encodeAbiParameters([{ type: "address" }, { type: "bytes32" }], [db.eSIMWallet, ref])); + + // The custom error name a refused write carries, as the SDK reports it. + const revertOf = async (pending: Promise): Promise => { + const err = await pending.then(() => undefined, (e: unknown) => e); + if (err === undefined) return "did not revert"; + if (!(err instanceof ContractRevertError)) throw err; + return err.decoded?.errorName ?? `undecoded ${err.data}`; + }; + + const balanceOf = (tokenAddress: Address, holder: Address) => + target.publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: "balanceOf", args: [holder] }); +}); diff --git a/tests/consumer/userFlow.fork.test.ts b/tests/consumer/userFlow.fork.test.ts new file mode 100644 index 0000000..40d5aab --- /dev/null +++ b/tests/consumer/userFlow.fork.test.ts @@ -0,0 +1,44 @@ +import { vi } from "vitest"; +import { createWalletClient, http, parseEther, type Hex } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { KokioAdmin } from "kokio-sdk/admin"; + +import { impersonateRegistryOwner } from "../utils/forkChain.js"; +import { describeUserFlow } from "./flows/userFlow.js"; +import { startForkStack } from "./fixtures/forkStack.js"; +import { setTokenBalance } from "./fixtures/tokens.js"; +import { FORK_POLICY_ID } from "./fixtures/testLabels.js"; + +describeUserFlow("user flow on a Base Sepolia fork", async () => { + const stack = await startForkStack(); + const waitFor = async (hash: Hex) => { await stack.fork.publicClient.waitForTransactionReceipt({ hash }); }; + + // The backend signs with its own private key, as it would against a hosted RPC. + // The registry owner hands it the admin role first. + const account = privateKeyToAccount(generatePrivateKey()); + await stack.fork.testClient.setBalance({ address: account.address, value: parseEther("1") }); + const admin = new KokioAdmin(createWalletClient({ account, chain: baseSepolia, transport: http(stack.fork.rpcUrl) })); + + const { client: owner } = await impersonateRegistryOwner(stack.fork); + await waitFor(await new KokioAdmin(owner).registry.requestAdminUpdate(account.address)); + await waitFor(await admin.registry.acceptAdminUpdate()); + + return { + rpcUrl: stack.fork.rpcUrl, + publicClient: stack.fork.publicClient, + pimlicoAPIKey: "unused-on-fork", + policyId: FORK_POLICY_ID, + bundlerUrl: stack.bundlerUrl, + receiptUrl: stack.bundlerUrl, + admin, + fund: (token, to, amount) => setTokenBalance(stack.fork, token, to, amount), + priceUSDCents: 500n, + confirmations: 1, + stop: stack.stop, + }; +}, passkeyGet, { timeout: 120_000, assets: ["USDC", "USDCt"] }); diff --git a/tests/consumer/userFlow.live.test.ts b/tests/consumer/userFlow.live.test.ts new file mode 100644 index 0000000..9e47e35 --- /dev/null +++ b/tests/consumer/userFlow.live.test.ts @@ -0,0 +1,30 @@ +import { vi } from "vitest"; + +const passkeyGet = vi.hoisted(() => vi.fn()); +vi.mock("react-native-passkey", () => ({ Passkey: { get: passkeyGet } })); + +import { describeUserFlow } from "./flows/userFlow.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 four and sends 2 USDCt and 1 USDC to the +// new device wallet. Run with `npm run test:consumer:live`. +describeUserFlow("user flow 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/", + }; + // Mostly the internal test token; one purchase in Circle's Base Sepolia USDC. +}, passkeyGet, { timeout: 180_000, assets: ["USDCt", "USDC"] });