From 8315a61246d142cbe60385f21852a3726310bcc0 Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Thu, 16 Oct 2025 10:29:12 +0200 Subject: [PATCH] Add Withdraw Token Route/Tool --- .env.example | 8 ++-- app/api/withdraw/route.ts | 76 ++++++++++++++++++++++++++++++++++ lib/near.ts | 86 ++++++++++++++++++++++++++++++++++++--- lib/utils.ts | 2 + 4 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 app/api/withdraw/route.ts diff --git a/.env.example b/.env.example index 8c98de8..1ad8074 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -BITTE_API_KEY='' -NEAR_PK='' -NEXT_PUBLIC_ACCOUNT_ID='' -CRON_SECRET='' +BITTE_API_KEY= +NEAR_PK= +NEXT_PUBLIC_ACCOUNT_ID= +CRON_SECRET= diff --git a/app/api/withdraw/route.ts b/app/api/withdraw/route.ts new file mode 100644 index 0000000..bcd74cc --- /dev/null +++ b/app/api/withdraw/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BALANCE_UPDATE_DELAY, USDC_CONTRACT } from "@/lib/utils"; +import { + initializeNearAccount, + depositUSDC, + getUSDCBalance, + intentsUSDCBalance, + withdrawUSDC, + withdrawToken, + intentsBalance, +} from "@/lib/near"; +import { formatUnits } from "@/lib/viem"; + +const bigIntMin = (a: bigint, b: bigint) => (a < b ? a : b); +const ZERO = BigInt(0); + +export async function GET(request: NextRequest) { + try { + if (process.env.CRON_SECRET) { + const authHeader = request.headers.get("Authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const { searchParams } = new URL(request.url); + const withdrawStr = searchParams.get("amount"); + if (!withdrawStr) { + return NextResponse.json( + { error: "unspecified amount" }, + { status: 400 }, + ); + } + const token = searchParams.get("token") || USDC_CONTRACT; + + const accountId = process.env.NEXT_PUBLIC_ACCOUNT_ID; + if (!accountId) { + return NextResponse.json( + { error: "accountId is not configured" }, + { status: 500 }, + ); + } + + const requestedWithdrawAmount = BigInt(withdrawStr); + + const account = await initializeNearAccount(accountId); + + const usdcBalance = await intentsBalance(account, token); + let withdrawAmount = bigIntMin(requestedWithdrawAmount, usdcBalance); + + if (withdrawAmount == ZERO) { + return NextResponse.json( + { message: "Nothing to withdraw" }, + { status: 200 }, + ); + } + + const tx = await withdrawToken(account, token, withdrawAmount); + + await new Promise((resolve) => setTimeout(resolve, BALANCE_UPDATE_DELAY)); + + const uiAmount = formatUnits(withdrawAmount, 6); + + return NextResponse.json({ + message: `Successfully withdrew $${uiAmount} USDC`, + transactionHash: tx.transaction.hash, + amount: uiAmount, + }); + } catch (error) { + console.error("Error in deposit endpoint:", error); + return NextResponse.json( + { error: "Failed to process deposit request" }, + { status: 500 }, + ); + } +} diff --git a/lib/near.ts b/lib/near.ts index 148a86d..30d0258 100644 --- a/lib/near.ts +++ b/lib/near.ts @@ -1,9 +1,17 @@ import { actionCreators } from "@near-js/transactions"; import { Account, KeyPair, keyStores, Near } from "near-api-js"; import type { Quote } from "./types"; -import { INTENTS_CONTRACT_ID, NEAR_RPC_URL, TGas } from "./utils"; +import { + INTENTS_CONTRACT_ID, + NEAR_RPC_URL, + TGas, + USDC_CONTRACT, +} from "./utils"; import { NEAR_PK } from "./env"; +const FIFTY_TGAS = BigInt(TGas * 50); +const ONE_YOCTO = BigInt(1); + export async function getTokenBalance( account: Account, assetId: string, @@ -55,9 +63,6 @@ export function buildTransactionPayload(quote: Quote) { }; } -const USDC_CONTRACT = - "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"; - export async function getUSDCBalance(account: Account): Promise { try { const result = await account.viewFunction({ @@ -72,6 +77,27 @@ export async function getUSDCBalance(account: Account): Promise { } } +export async function intentsUSDCBalance(account: Account): Promise { + return intentsBalance(account, USDC_CONTRACT); +} + +export async function intentsBalance( + account: Account, + token: string, +): Promise { + try { + const result = await account.viewFunction({ + contractId: INTENTS_CONTRACT_ID, + methodName: "mt_balance_of", + args: { token_id: `nep141:${token}`, account_id: account.accountId }, + }); + return BigInt(result as string); + } catch (error) { + console.warn("Failed to fetch USDC balance:", error); + return BigInt(0); + } +} + export async function depositUSDC(account: Account, amount: bigint) { const result = await account.signAndSendTransaction({ receiverId: USDC_CONTRACT, @@ -83,8 +109,8 @@ export async function depositUSDC(account: Account, amount: bigint) { amount: amount.toString(), msg: account.accountId, }, - BigInt(TGas * 50), - BigInt(1), + FIFTY_TGAS, + ONE_YOCTO, ), ], }); @@ -107,3 +133,51 @@ export async function depositUSDC(account: Account, amount: bigint) { return result; } + +export async function withdrawUSDC(account: Account, amount: bigint) { + return withdrawToken(account, USDC_CONTRACT, amount); +} + +export async function withdrawToken( + account: Account, + token: string, + amount: bigint, +) { + const result = await account.signAndSendTransaction({ + receiverId: INTENTS_CONTRACT_ID, + actions: [ + actionCreators.functionCall( + "ft_withdraw", + { + token, + amount: amount.toString(), + receiver_id: account.accountId, + // Docs suggest refund is not necessarily possible if msg is specified! + // https://docs.near-intents.org/near-intents/market-makers/verifier/deposits-and-withdrawals/withdrawals#refunds-on-failed-withdrawals-warning + // msg: null + }, + FIFTY_TGAS, + ONE_YOCTO, + ), + ], + }); + + const hasSuccess = result.receipts_outcome.some((receipt) => + receipt.outcome.logs.some( + (log) => log.includes("ft_transfer") && log.includes(account.accountId), + ), + ); + const hasRefund = result.receipts_outcome.some((receipt) => + receipt.outcome.logs.some( + (log) => log.includes("ft_transfer") && log.includes('"memo":"refund"'), + ), + ); + + if (hasRefund || !hasSuccess) { + throw new Error( + `Withdraw failed - transaction was refunded ${result.transaction.hash}`, + ); + } + + return result; +} diff --git a/lib/utils.ts b/lib/utils.ts index 23fcd6a..abbfb0c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -3,6 +3,8 @@ import type { AgentContext, Token, ToolResult } from "./types"; export const AGENT_ID = "trading-agent-kappa.vercel.app"; +export const USDC_CONTRACT = + "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"; export const INTENTS_CONTRACT_ID = "intents.near"; export const TGas = 1000000000000; export const NEAR_RPC_URL = "https://free.rpc.fastnear.com";