Skip to content

Commit bc9c6ff

Browse files
authored
fix: withdraw and transfer 5xxs (#672)
* fix: withdraw and transfer 5xxs * remove debug line * add biome, fix spacing * wip * increase gas, log error * tx overrides
1 parent 49fb34c commit bc9c6ff

File tree

5 files changed

+104
-52
lines changed

5 files changed

+104
-52
lines changed

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@
2727
}
2828
}
2929
}
30-
}
30+
}

src/server/routes/backend-wallet/transfer.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
import { Static, Type } from "@sinclair/typebox";
2-
import { FastifyInstance } from "fastify";
1+
import { Type, type Static } from "@sinclair/typebox";
2+
import type { FastifyInstance } from "fastify";
33
import { StatusCodes } from "http-status-codes";
44
import {
5-
Address,
65
NATIVE_TOKEN_ADDRESS,
76
ZERO_ADDRESS,
87
getContract,
98
toWei,
9+
type Address,
1010
} from "thirdweb";
1111
import { transfer as transferERC20 } from "thirdweb/extensions/erc20";
1212
import { isContractDeployed, resolvePromisedValue } from "thirdweb/utils";
1313
import { getChain } from "../../../utils/chain";
1414
import { maybeBigInt, normalizeAddress } from "../../../utils/primitiveTypes";
1515
import { thirdwebClient } from "../../../utils/sdk";
1616
import { insertTransaction } from "../../../utils/transaction/insertTransaction";
17-
import { InsertedTransaction } from "../../../utils/transaction/types";
17+
import type { InsertedTransaction } from "../../../utils/transaction/types";
1818
import { createCustomError } from "../../middleware/error";
1919
import { AddressSchema } from "../../schemas/address";
20+
import { TokenAmountStringSchema } from "../../schemas/number";
2021
import {
2122
requestQuerystringSchema,
2223
standardResponseSchema,
@@ -43,10 +44,11 @@ const requestBodySchema = Type.Object({
4344
description:
4445
"The token address to transfer. Omit to transfer the chain's native currency (e.g. ETH on Ethereum).",
4546
}),
46-
amount: Type.String({
47+
amount: {
48+
...TokenAmountStringSchema,
4749
description:
4850
'The amount in ether to transfer. Example: "0.1" to send 0.1 ETH.',
49-
}),
51+
},
5052
...txOverridesWithValueSchema.properties,
5153
});
5254

Lines changed: 71 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
import { Static, Type } from "@sinclair/typebox";
2-
import { FastifyInstance } from "fastify";
1+
import { Type, type Static } from "@sinclair/typebox";
2+
import type { FastifyInstance } from "fastify";
33
import { StatusCodes } from "http-status-codes";
44
import {
5-
Address,
6-
estimateGasCost,
7-
prepareTransaction,
8-
sendTransaction,
5+
toSerializableTransaction,
6+
toTokens,
7+
type Address,
8+
type Hex,
99
} from "thirdweb";
10+
import { getChainMetadata } from "thirdweb/chains";
1011
import { getWalletBalance } from "thirdweb/wallets";
1112
import { getAccount } from "../../../utils/account";
1213
import { getChain } from "../../../utils/chain";
14+
import { logger } from "../../../utils/logger";
15+
import { getChecksumAddress, maybeBigInt } from "../../../utils/primitiveTypes";
1316
import { thirdwebClient } from "../../../utils/sdk";
14-
import { AddressSchema } from "../../schemas/address";
17+
import { createCustomError } from "../../middleware/error";
18+
import { AddressSchema, TransactionHashSchema } from "../../schemas/address";
19+
import { TokenAmountStringSchema } from "../../schemas/number";
1520
import {
1621
requestQuerystringSchema,
1722
standardResponseSchema,
1823
} from "../../schemas/sharedApiSchemas";
24+
import { txOverridesSchema } from "../../schemas/txOverrides";
1925
import {
2026
walletHeaderSchema,
2127
walletWithAddressParamSchema,
@@ -29,11 +35,13 @@ const requestBodySchema = Type.Object({
2935
...AddressSchema,
3036
description: "Address to withdraw all funds to",
3137
},
38+
...txOverridesSchema.properties,
3239
});
3340

3441
const responseBodySchema = Type.Object({
3542
result: Type.Object({
36-
transactionHash: Type.String(),
43+
transactionHash: TransactionHashSchema,
44+
amount: TokenAmountStringSchema,
3745
}),
3846
});
3947

@@ -62,45 +70,69 @@ export async function withdraw(fastify: FastifyInstance) {
6270
},
6371
handler: async (request, reply) => {
6472
const { chain: chainQuery } = request.params;
65-
const { toAddress } = request.body;
66-
const {
67-
"x-backend-wallet-address": walletAddress,
68-
"x-idempotency-key": idempotencyKey,
69-
} = request.headers as Static<typeof walletHeaderSchema>;
73+
const { toAddress, txOverrides } = request.body;
74+
const { "x-backend-wallet-address": walletAddress } =
75+
request.headers as Static<typeof walletHeaderSchema>;
7076

7177
const chainId = await getChainIdFromChain(chainQuery);
7278
const chain = await getChain(chainId);
73-
const from = walletAddress as Address;
79+
const from = getChecksumAddress(walletAddress);
80+
81+
// Populate a transfer transaction with 2x gas.
82+
const populatedTransaction = await toSerializableTransaction({
83+
from,
84+
transaction: {
85+
to: toAddress,
86+
chain,
87+
client: thirdwebClient,
88+
data: "0x",
89+
// Dummy value, replaced below.
90+
value: 1n,
91+
gas: maybeBigInt(txOverrides?.gas),
92+
maxFeePerGas: maybeBigInt(txOverrides?.maxFeePerGas),
93+
maxPriorityFeePerGas: maybeBigInt(txOverrides?.maxPriorityFeePerGas),
94+
},
95+
});
96+
97+
// Compute the maximum amount to withdraw taking into account gas fees.
98+
const value = await getWithdrawValue(from, populatedTransaction);
99+
populatedTransaction.value = value;
74100

75101
const account = await getAccount({ chainId, from });
76-
const value = await getWithdrawValue({ chainId, from });
102+
let transactionHash: Hex | undefined;
103+
try {
104+
const res = await account.sendTransaction(populatedTransaction);
105+
transactionHash = res.transactionHash;
106+
} catch (e) {
107+
logger({
108+
level: "warn",
109+
message: `Error withdrawing funds: ${e}`,
110+
service: "server",
111+
});
77112

78-
const transaction = prepareTransaction({
79-
to: toAddress,
80-
chain,
81-
client: thirdwebClient,
82-
value,
83-
});
84-
const { transactionHash } = await sendTransaction({
85-
account,
86-
transaction,
87-
});
113+
const metadata = await getChainMetadata(chain);
114+
throw createCustomError(
115+
`Insufficient ${metadata.nativeCurrency?.symbol} on ${metadata.name} in ${from}. Try again when network gas fees are lower. See: https://portal.thirdweb.com/engine/troubleshooting`,
116+
StatusCodes.BAD_REQUEST,
117+
"INSUFFICIENT_FUNDS",
118+
);
119+
}
88120

89121
reply.status(StatusCodes.OK).send({
90122
result: {
91123
transactionHash,
124+
amount: toTokens(value, 18),
92125
},
93126
});
94127
},
95128
});
96129
}
97130

98-
const getWithdrawValue = async (args: {
99-
chainId: number;
100-
from: Address;
101-
}): Promise<bigint> => {
102-
const { chainId, from } = args;
103-
const chain = await getChain(chainId);
131+
const getWithdrawValue = async (
132+
from: Address,
133+
populatedTransaction: Awaited<ReturnType<typeof toSerializableTransaction>>,
134+
): Promise<bigint> => {
135+
const chain = await getChain(populatedTransaction.chainId);
104136

105137
// Get wallet balance.
106138
const { value: balanceWei } = await getWalletBalance({
@@ -109,17 +141,13 @@ const getWithdrawValue = async (args: {
109141
chain,
110142
});
111143

112-
// Estimate gas for a transfer.
113-
const transaction = prepareTransaction({
114-
chain,
115-
client: thirdwebClient,
116-
value: 1n, // dummy value
117-
to: from, // dummy value
118-
});
119-
const { wei: transferCostWei } = await estimateGasCost({ transaction });
120-
121-
// Add a +20% buffer for gas variance.
122-
const buffer = transferCostWei / 5n;
144+
// Set the withdraw value to be the amount of gas that isn't reserved to send the transaction.
145+
const gasPrice =
146+
populatedTransaction.maxFeePerGas ?? populatedTransaction.gasPrice;
147+
if (!gasPrice) {
148+
throw new Error("Unable to estimate gas price for withdraw request.");
149+
}
123150

124-
return balanceWei - transferCostWei - buffer;
151+
const transferCostWei = populatedTransaction.gas * gasPrice;
152+
return balanceWei - transferCostWei;
125153
};

src/server/schemas/number.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Type } from "@sinclair/typebox";
2+
3+
export const TokenAmountStringSchema = Type.RegExp(/^\d+(\.\d+)?$/, {
4+
description: 'An amount in native token (decimals allowed). Example: "1.5"',
5+
examples: ["0.1"],
6+
});
7+
8+
export const WeiAmountStringSchema = Type.RegExp(/^\d+$/, {
9+
description: 'An amount in wei (no decimals). Example: "100000000"',
10+
examples: ["0"],
11+
});

src/utils/error.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { getChainMetadata } from "thirdweb/chains";
33
import { getChain } from "./chain";
44
import { isEthersErrorCode } from "./ethers";
55
import { doSimulateTransaction } from "./transaction/simulateQueuedTransaction";
6-
import { AnyTransaction } from "./transaction/types";
6+
import type { AnyTransaction } from "./transaction/types";
77

88
export const prettifyError = async (
99
transaction: AnyTransaction,
1010
error: Error,
1111
): Promise<string> => {
1212
if (!transaction.isUserOp) {
13-
if (isEthersErrorCode(error, ethers.errors.INSUFFICIENT_FUNDS)) {
13+
if (isInsufficientFundsError(error)) {
1414
const chain = await getChain(transaction.chainId);
1515
const metadata = await getChainMetadata(chain);
1616
return `Insufficient ${metadata.nativeCurrency?.symbol} on ${metadata.name} in ${transaction.from}.`;
@@ -51,3 +51,14 @@ export const isReplacementGasFeeTooLow = (error: unknown) => {
5151
}
5252
return isEthersErrorCode(error, ethers.errors.REPLACEMENT_UNDERPRICED);
5353
};
54+
55+
export const isInsufficientFundsError = (error: unknown) => {
56+
const message = _parseMessage(error);
57+
if (message) {
58+
return (
59+
message.includes("insufficient funds for gas * price + value") ||
60+
message.includes("insufficient funds for intrinsic transaction cost")
61+
);
62+
}
63+
return isEthersErrorCode(error, ethers.errors.INSUFFICIENT_FUNDS);
64+
};

0 commit comments

Comments
 (0)