diff --git a/README.md b/README.md index 14ff000..89bc21c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,56 @@ const result = await client.llm.completion({ console.log(result.completionOutput); ``` +## OPG Token Approval + +Before making LLM requests, your wallet must approve OPG token spending via the [Permit2](https://github.com/Uniswap/permit2) protocol. `ensureOpgApproval` only sends an on-chain transaction when the current allowance drops below the threshold, so it's safe to call on every server startup: + +```typescript +import { privateKeyToAccount } from "viem/accounts"; +import { ensureOpgApproval } from "opengradient-sdk"; + +const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); + +// Only sends a tx when allowance < 5 OPG, then approves 100 OPG so +// subsequent restarts are free. Defaults approveAmount to 2 * minAllowance. +const result = await ensureOpgApproval(account, 5, 100); +console.log("allowance after:", result.allowanceAfter, "tx:", result.txHash); +``` + +The wallet must hold OPG on Base mainnet. Override the RPC with the `BASE_MAINNET_RPC` environment variable if you don't want to use the default public node. + +### End-to-end example + +```typescript +import { privateKeyToAccount } from "viem/accounts"; +import { Client, TEE_LLM, ensureOpgApproval } from "opengradient-sdk"; + +async function main() { + const privateKey = process.env.PRIVATE_KEY as `0x${string}`; + + // 1. Make sure the wallet has approved Permit2 to spend OPG. + // No-op when the allowance is already above the threshold. + const account = privateKeyToAccount(privateKey); + await ensureOpgApproval(account, 5, 100); + + // 2. Run a TEE-secured chat completion settled in OPG via x402. + const client = new Client({ privateKey }); + try { + const result = await client.llm.chat({ + model: TEE_LLM.CLAUDE_3_5_HAIKU, + messages: [{ role: "user", content: "Hello!" }], + maxTokens: 100, + }); + console.log(result.chatOutput?.content); + console.log("payment hash:", result.paymentHash); + } finally { + await client.close(); + } +} + +main(); +``` + ## x402 Settlement Modes ```typescript diff --git a/src/index.ts b/src/index.ts index 4986103..545c41c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,3 +42,10 @@ export { getExplorerUrl, getFaucetUrl, } from "./defaults"; + +export { + ensureOpgApproval, + BASE_OPG_ADDRESS, + BASE_MAINNET_RPC, +} from "./opgToken"; +export type { Permit2ApprovalResult } from "./opgToken"; diff --git a/src/opgToken.ts b/src/opgToken.ts new file mode 100644 index 0000000..06d67c2 --- /dev/null +++ b/src/opgToken.ts @@ -0,0 +1,266 @@ +/** OPG token Permit2 approval utilities for x402 payments. */ + +import { + createPublicClient, + createWalletClient, + http, + getAddress, + type Account, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from "viem"; +import { PERMIT2_ADDRESS } from "@x402/evm"; + +export const BASE_OPG_ADDRESS: Address = getAddress( + "0xFbC2051AE2265686a469421b2C5A2D5462FbF5eB", +); +export const BASE_MAINNET_RPC = + process.env.BASE_MAINNET_RPC ?? "https://base-rpc.publicnode.com"; + +const APPROVAL_TX_TIMEOUT_MS = 120_000; +const ALLOWANCE_CONFIRMATION_TIMEOUT_MS = 120_000; +const ALLOWANCE_POLL_INTERVAL_MS = 1_000; + +const ERC20_ABI = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + name: "approve", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const OPG_DECIMALS = 18n; +const OPG_SCALE = 10n ** OPG_DECIMALS; + +/** + * Result of a Permit2 allowance check / approval. + * + * - `allowanceBefore`: The Permit2 allowance before the method ran. + * - `allowanceAfter`: The Permit2 allowance after the method ran. + * - `txHash`: Transaction hash of the approval, or `null` if no transaction was needed. + */ +export interface Permit2ApprovalResult { + allowanceBefore: bigint; + allowanceAfter: bigint; + txHash: Hex | null; +} + +function toBaseUnits(amountOpg: number): bigint { + if (!Number.isFinite(amountOpg) || amountOpg < 0) { + throw new Error(`Invalid OPG amount: ${amountOpg}`); + } + // Match Python's int(amount * 10**18). Use string arithmetic to avoid + // float precision loss for typical decimal inputs. + const [whole, frac = ""] = amountOpg.toString().split("."); + const fracPadded = (frac + "0".repeat(Number(OPG_DECIMALS))).slice( + 0, + Number(OPG_DECIMALS), + ); + return BigInt(whole) * OPG_SCALE + BigInt(fracPadded || "0"); +} + +function formatOpg(base: bigint): string { + const whole = base / OPG_SCALE; + const frac = base % OPG_SCALE; + const fracStr = frac.toString().padStart(Number(OPG_DECIMALS), "0").slice(0, 6); + return `${whole}.${fracStr}`; +} + +async function readAllowance( + publicClient: PublicClient, + owner: Address, + spender: Address, +): Promise { + return (await publicClient.readContract({ + address: BASE_OPG_ADDRESS, + abi: ERC20_ABI, + functionName: "allowance", + args: [owner, spender], + })) as bigint; +} + +async function readBalance( + publicClient: PublicClient, + owner: Address, +): Promise { + return (await publicClient.readContract({ + address: BASE_OPG_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [owner], + })) as bigint; +} + +async function sendApproveTx( + publicClient: PublicClient, + walletClient: WalletClient, + account: Account, + owner: Address, + spender: Address, + amountBase: bigint, +): Promise { + const allowanceBefore = await readAllowance(publicClient, owner, spender); + + let txHash: Hex; + try { + txHash = await walletClient.writeContract({ + account, + chain: null, + address: BASE_OPG_ADDRESS, + abi: ERC20_ABI, + functionName: "approve", + args: [spender, amountBase], + }); + } catch (e) { + throw new Error(`Failed to approve Permit2 for OPG: ${String(e)}`); + } + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + timeout: APPROVAL_TX_TIMEOUT_MS, + }); + + if (receipt.status !== "success") { + throw new Error(`Permit2 approval transaction reverted: ${txHash}`); + } + + const deadline = Date.now() + ALLOWANCE_CONFIRMATION_TIMEOUT_MS; + let allowanceAfter = allowanceBefore; + while (allowanceAfter < amountBase) { + allowanceAfter = await readAllowance(publicClient, owner, spender); + if (allowanceAfter >= amountBase) break; + if (Date.now() >= deadline) { + throw new Error( + `Permit2 approval transaction was mined, but the updated allowance ` + + `was not visible within ${ALLOWANCE_CONFIRMATION_TIMEOUT_MS / 1000} seconds: ${txHash}`, + ); + } + await new Promise((resolve) => + setTimeout(resolve, ALLOWANCE_POLL_INTERVAL_MS), + ); + } + + return { allowanceBefore, allowanceAfter, txHash }; +} + +/** + * Ensure the Permit2 allowance stays above a minimum threshold. + * + * Only sends an approval transaction when the current allowance drops + * below `minAllowance`. When approval is needed, approves `approveAmount` + * (defaults to `2 * minAllowance`) to create a buffer that survives + * multiple service restarts without re-approving. + * + * Best for backend servers that call this on startup: + * + * ```ts + * import { privateKeyToAccount } from "viem/accounts"; + * import { ensureOpgApproval } from "opengradient-sdk"; + * + * const account = privateKeyToAccount("0x..."); + * // On startup — only sends a tx when allowance < 5 OPG, + * // then approves 100 OPG so subsequent restarts are free. + * const result = await ensureOpgApproval(account, 5, 100); + * ``` + * + * @param account - The viem account to check and approve from. + * @param minAllowance - Minimum acceptable allowance in OPG. A transaction + * is only sent when the current allowance is strictly below this value. + * @param approveAmount - Amount of OPG to approve when a transaction is + * needed. Defaults to `2 * minAllowance`. Must be `>= minAllowance`. + * @returns A {@link Permit2ApprovalResult} with the before/after allowance + * and `txHash` (`null` when no approval was needed). + */ +export async function ensureOpgApproval( + account: Account, + minAllowance: number, + approveAmount?: number, +): Promise { + const effectiveApprove = approveAmount ?? minAllowance * 2; + if (effectiveApprove < minAllowance) { + throw new Error( + `approveAmount (${effectiveApprove}) must be >= minAllowance (${minAllowance})`, + ); + } + + const publicClient = createPublicClient({ + transport: http(BASE_MAINNET_RPC), + }); + const walletClient = createWalletClient({ + account, + transport: http(BASE_MAINNET_RPC), + }); + + const owner = getAddress(account.address); + const spender = getAddress(PERMIT2_ADDRESS); + + const allowanceBefore = await readAllowance(publicClient, owner, spender); + + const minBase = toBaseUnits(minAllowance); + let approveBase = toBaseUnits(effectiveApprove); + + if (allowanceBefore >= minBase) { + return { + allowanceBefore, + allowanceAfter: allowanceBefore, + txHash: null, + }; + } + + const balance = await readBalance(publicClient, owner); + if (balance === 0n) { + throw new Error( + `Wallet ${owner} has no OPG tokens. Fund the wallet before approving.`, + ); + } else if (minBase > balance) { + throw new Error( + `Wallet ${owner} has insufficient OPG balance: has ${formatOpg(balance)} OPG, ` + + `but the minimum required is ${formatOpg(minBase)} OPG. ` + + `Fund the wallet before approving.`, + ); + } else if (approveBase > balance) { + // eslint-disable-next-line no-console + console.warn( + `Requested approveAmount (${effectiveApprove} OPG) exceeds wallet balance ` + + `(${formatOpg(balance)} OPG), capping approval to wallet balance`, + ); + approveBase = balance; + } + + console.debug( + `Permit2 allowance below minimum threshold (${allowanceBefore} < ${minBase}), ` + + `approving ${approveBase} base units`, + ); + return sendApproveTx( + publicClient, + walletClient, + account, + owner, + spender, + approveBase, + ); +}