Skip to content

Commit 61e8c68

Browse files
authored
Add Withdraw Token Route/Tool (#3)
1 parent 287d4a3 commit 61e8c68

4 files changed

Lines changed: 162 additions & 10 deletions

File tree

.env.example

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
BITTE_API_KEY=''
2-
NEAR_PK=''
3-
NEXT_PUBLIC_ACCOUNT_ID=''
4-
CRON_SECRET=''
1+
BITTE_API_KEY=
2+
NEAR_PK=
3+
NEXT_PUBLIC_ACCOUNT_ID=
4+
CRON_SECRET=

app/api/withdraw/route.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { BALANCE_UPDATE_DELAY, USDC_CONTRACT } from "@/lib/utils";
3+
import {
4+
initializeNearAccount,
5+
depositUSDC,
6+
getUSDCBalance,
7+
intentsUSDCBalance,
8+
withdrawUSDC,
9+
withdrawToken,
10+
intentsBalance,
11+
} from "@/lib/near";
12+
import { formatUnits } from "@/lib/viem";
13+
14+
const bigIntMin = (a: bigint, b: bigint) => (a < b ? a : b);
15+
const ZERO = BigInt(0);
16+
17+
export async function GET(request: NextRequest) {
18+
try {
19+
if (process.env.CRON_SECRET) {
20+
const authHeader = request.headers.get("Authorization");
21+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
22+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
23+
}
24+
}
25+
26+
const { searchParams } = new URL(request.url);
27+
const withdrawStr = searchParams.get("amount");
28+
if (!withdrawStr) {
29+
return NextResponse.json(
30+
{ error: "unspecified amount" },
31+
{ status: 400 },
32+
);
33+
}
34+
const token = searchParams.get("token") || USDC_CONTRACT;
35+
36+
const accountId = process.env.NEXT_PUBLIC_ACCOUNT_ID;
37+
if (!accountId) {
38+
return NextResponse.json(
39+
{ error: "accountId is not configured" },
40+
{ status: 500 },
41+
);
42+
}
43+
44+
const requestedWithdrawAmount = BigInt(withdrawStr);
45+
46+
const account = await initializeNearAccount(accountId);
47+
48+
const usdcBalance = await intentsBalance(account, token);
49+
let withdrawAmount = bigIntMin(requestedWithdrawAmount, usdcBalance);
50+
51+
if (withdrawAmount == ZERO) {
52+
return NextResponse.json(
53+
{ message: "Nothing to withdraw" },
54+
{ status: 200 },
55+
);
56+
}
57+
58+
const tx = await withdrawToken(account, token, withdrawAmount);
59+
60+
await new Promise((resolve) => setTimeout(resolve, BALANCE_UPDATE_DELAY));
61+
62+
const uiAmount = formatUnits(withdrawAmount, 6);
63+
64+
return NextResponse.json({
65+
message: `Successfully withdrew $${uiAmount} USDC`,
66+
transactionHash: tx.transaction.hash,
67+
amount: uiAmount,
68+
});
69+
} catch (error) {
70+
console.error("Error in deposit endpoint:", error);
71+
return NextResponse.json(
72+
{ error: "Failed to process deposit request" },
73+
{ status: 500 },
74+
);
75+
}
76+
}

lib/near.ts

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { actionCreators } from "@near-js/transactions";
22
import { Account, KeyPair, keyStores, Near } from "near-api-js";
33
import type { Quote } from "./types";
4-
import { INTENTS_CONTRACT_ID, NEAR_RPC_URL, TGas } from "./utils";
4+
import {
5+
INTENTS_CONTRACT_ID,
6+
NEAR_RPC_URL,
7+
TGas,
8+
USDC_CONTRACT,
9+
} from "./utils";
510
import { getEnvVar } from "./env";
611

12+
const FIFTY_TGAS = BigInt(TGas * 50);
13+
const ONE_YOCTO = BigInt(1);
14+
715
export async function getTokenBalance(
816
account: Account,
917
assetId: string,
@@ -57,9 +65,6 @@ export function buildTransactionPayload(quote: Quote) {
5765
};
5866
}
5967

60-
const USDC_CONTRACT =
61-
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1";
62-
6368
export async function getUSDCBalance(account: Account): Promise<bigint> {
6469
try {
6570
const result = await account.viewFunction({
@@ -74,6 +79,27 @@ export async function getUSDCBalance(account: Account): Promise<bigint> {
7479
}
7580
}
7681

82+
export async function intentsUSDCBalance(account: Account): Promise<bigint> {
83+
return intentsBalance(account, USDC_CONTRACT);
84+
}
85+
86+
export async function intentsBalance(
87+
account: Account,
88+
token: string,
89+
): Promise<bigint> {
90+
try {
91+
const result = await account.viewFunction({
92+
contractId: INTENTS_CONTRACT_ID,
93+
methodName: "mt_balance_of",
94+
args: { token_id: `nep141:${token}`, account_id: account.accountId },
95+
});
96+
return BigInt(result as string);
97+
} catch (error) {
98+
console.warn("Failed to fetch USDC balance:", error);
99+
return BigInt(0);
100+
}
101+
}
102+
77103
export async function depositUSDC(account: Account, amount: bigint) {
78104
const result = await account.signAndSendTransaction({
79105
receiverId: USDC_CONTRACT,
@@ -85,8 +111,8 @@ export async function depositUSDC(account: Account, amount: bigint) {
85111
amount: amount.toString(),
86112
msg: account.accountId,
87113
},
88-
BigInt(TGas * 50),
89-
BigInt(1),
114+
FIFTY_TGAS,
115+
ONE_YOCTO,
90116
),
91117
],
92118
});
@@ -109,3 +135,51 @@ export async function depositUSDC(account: Account, amount: bigint) {
109135

110136
return result;
111137
}
138+
139+
export async function withdrawUSDC(account: Account, amount: bigint) {
140+
return withdrawToken(account, USDC_CONTRACT, amount);
141+
}
142+
143+
export async function withdrawToken(
144+
account: Account,
145+
token: string,
146+
amount: bigint,
147+
) {
148+
const result = await account.signAndSendTransaction({
149+
receiverId: INTENTS_CONTRACT_ID,
150+
actions: [
151+
actionCreators.functionCall(
152+
"ft_withdraw",
153+
{
154+
token,
155+
amount: amount.toString(),
156+
receiver_id: account.accountId,
157+
// Docs suggest refund is not necessarily possible if msg is specified!
158+
// https://docs.near-intents.org/near-intents/market-makers/verifier/deposits-and-withdrawals/withdrawals#refunds-on-failed-withdrawals-warning
159+
// msg: null
160+
},
161+
FIFTY_TGAS,
162+
ONE_YOCTO,
163+
),
164+
],
165+
});
166+
167+
const hasSuccess = result.receipts_outcome.some((receipt) =>
168+
receipt.outcome.logs.some(
169+
(log) => log.includes("ft_transfer") && log.includes(account.accountId),
170+
),
171+
);
172+
const hasRefund = result.receipts_outcome.some((receipt) =>
173+
receipt.outcome.logs.some(
174+
(log) => log.includes("ft_transfer") && log.includes('"memo":"refund"'),
175+
),
176+
);
177+
178+
if (hasRefund || !hasSuccess) {
179+
throw new Error(
180+
`Withdraw failed - transaction was refunded ${result.transaction.hash}`,
181+
);
182+
}
183+
184+
return result;
185+
}

lib/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { AgentContext, Token, ToolResult } from "./types";
33

44
export const AGENT_ID = "trading-agent-kappa.vercel.app";
55

6+
export const USDC_CONTRACT =
7+
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1";
68
export const INTENTS_CONTRACT_ID = "intents.near";
79
export const TGas = 1000000000000;
810
export const NEAR_RPC_URL = "https://free.rpc.fastnear.com";

0 commit comments

Comments
 (0)