Skip to content

Getting started

Requirements

  • Node.js 20 or newer.
  • An EntryPoint v0.7-compatible Viem smart account on Ink Mainnet.
  • Native Ink USDC in the smart account.
  • A small amount of native ETH in the smart account for its initial approval.

The ERC-20 is reimbursement, not Ink's protocol gas token. The paymaster deposits native ETH into EntryPoint, pays the operation's native gas, and separately charges the smart account in USDC.

Install

Once the package is published:

bash
pnpm add @hellomoon/ink-paymaster viem

Inside this repository, install dependencies and type-check the canonical examples with:

bash
pnpm --dir sdk install
pnpm --dir sdk typecheck:examples

Fund the smart account

Send native Ink USDC to the smart-account address. Also send it enough native ETH for one owner-paid UserOperation. The ETH is only needed to establish or replenish the paymaster's USDC allowance; subsequent eligible operations can use the prepaid paymaster.

Do not approve from the owner EOA. The USDC belongs to the smart account, so the smart account must be the caller of USDC.approve.

Approve the paymaster

This is a required first-use transaction. The public paymaster cannot pay for its own approval: before the allowance exists, it has no way to collect reimbursement from the smart account.

Create a Bundler Client without a paymaster and send the approval as an owner-paid UserOperation:

ts
import {createPublicClient, defineChain, http} from "viem";
import {createBundlerClient} from "viem/account-abstraction";
import {
  createInkPublicPaymasterClient,
  encodePublicPaymasterApproval,
  INK_PUBLIC_PAYMASTER,
} from "@hellomoon/ink-paymaster";

const ink = defineChain({
  id: INK_PUBLIC_PAYMASTER.chainId,
  name: INK_PUBLIC_PAYMASTER.chainName,
  nativeCurrency: {name: "Ether", symbol: "ETH", decimals: 18},
  rpcUrls: {default: {http: ["https://rpc-qnd.inkonchain.com"]}},
});

const publicClient = createPublicClient({chain: ink, transport: http()});
const service = createInkPublicPaymasterClient();
const ownerPaidBundlerClient = createBundlerClient({
  account,
  chain: ink,
  client: publicClient,
  transport: http(service.bundlerUrl),
  // Deliberately no `paymaster` here.
});

const approvalHash = await ownerPaidBundlerClient.sendUserOperation({
  calls: [{
    to: INK_PUBLIC_PAYMASTER.token.address,
    value: 0n,
    data: encodePublicPaymasterApproval(10_000_000n), // finite 10 USDC allowance
  }],
});

await ownerPaidBundlerClient.waitForUserOperationReceipt({hash: approvalHash});

The SDK helper encodes the inner USDC.approve(paymaster, amount) call. Viem wraps it in the smart account's execution format and signs the UserOperation. The complete type-checked version is sdk/examples/encode-approval.ts.

Use a finite allowance appropriate for expected usage. Paymaster precharges consume allowance, so monitor and replenish it before it drops below an operation's maxTokenCost.

Check readiness

ts
import {createPublicClient, http} from "viem";
import {
  getPublicPaymasterAccountState,
  INK_PUBLIC_PAYMASTER,
} from "@hellomoon/ink-paymaster";

const publicClient = createPublicClient({transport: http("https://rpc-qnd.inkonchain.com")});
const state = await getPublicPaymasterAccountState(publicClient, account.address);

console.log({
  usdcBalance: state.usdcBalance,
  allowance: state.paymasterAllowance,
  refundCredit: state.refundCredit,
  paymaster: INK_PUBLIC_PAYMASTER.paymaster,
});

Do not continue until both usdcBalance and paymasterAllowance cover the maxTokenCost ceiling selected for the operation. USDC uses six decimal places: 100_000n means 0.1 USDC.

Send an operation

The complete, type-checked implementation lives in sdk/examples/happy-path.ts. Its core submission is intentionally small:

ts
const bundlerClient = createBundlerClient({
  account,
  chain: ink,
  client: publicClient,
  transport: http(service.bundlerUrl),
  paymaster: createInkViemPaymaster(service),
  paymasterContext: {
    maxTokenCost: 100_000n,
    idempotencyKey: `checkout-${orderId}`,
  },
});

const hash = await bundlerClient.sendUserOperation({
  calls: [{to: destination, data: "0x", value: 0n}],
});
const receipt = await bundlerClient.waitForUserOperationReceipt({hash});

Viem asks the smart-account implementation to convert calls into account-specific callData, obtains the prepaid quote, estimates gas, requests the account signature, and submits the final UserOperation to the public Alto route.

Record the result

Persist the business operation ID, idempotency key, UserOperation hash, and EntryPoint transaction hash. Those fields make retries and support investigations much easier.

Example: transfer USDC with paymaster gas

This example transfers 1.25 native Ink USDC. The smart account calls the USDC contract, while the public paymaster supplies native gas through EntryPoint and separately receives a bounded USDC reimbursement.

ts
import {encodeFunctionData, parseUnits} from "viem";
import {INK_PUBLIC_PAYMASTER} from "@hellomoon/ink-paymaster";

const amount = parseUnits("1.25", INK_PUBLIC_PAYMASTER.token.decimals);
const transferData = encodeFunctionData({
  abi: [{
    type: "function",
    name: "transfer",
    stateMutability: "nonpayable",
    inputs: [
      {name: "recipient", type: "address"},
      {name: "amount", type: "uint256"},
    ],
    outputs: [{type: "bool"}],
  }],
  functionName: "transfer",
  args: [recipient, amount],
});

const hash = await bundlerClient.sendUserOperation({
  calls: [{
    to: INK_PUBLIC_PAYMASTER.token.address,
    value: 0n,
    data: transferData,
  }],
});

const receipt = await bundlerClient.waitForUserOperationReceipt({hash});
console.log(receipt.receipt.transactionHash);

The account needs enough USDC for both the 1.25 transfer and the selected maxTokenCost ceiling; its paymaster allowance only needs to cover maxTokenCost. The complete type-checked version, including these readiness checks, is sdk/examples/usdc-transfer.ts.

Public prepaid USDC paymaster for Ink Mainnet