MPC Error Recovery
Overview
When withdrawing from an MPC wallet, you may encounter a server-side error during the MPC signing protocol. Depending on the status of the transaction request, you can recover the transaction and successfully complete and broadcast it. Configuring error handling in your server-side application helps prevent nonce holes from occurring and minimizes manual interventions should signing errors occur.
BitGo enables a systematic approach to transaction recovery using a unique sequenceId per transaction request.
Prerequisites
- Get Started.
- Ensure your
.env
files is properly configured and all dependencies are installed (see Install SDK). - Ensure BitGo Express is running (see Install BitGo Express).
- Ensure your
- Initiate a withdrawal with a unique
sequenceId
(see Withdraw Overview).
1. Check Status
Use the sequenceId
to check the status of a failed transaction request.
Endpoint: Get Transaction Requests by Wallet
export COIN="<ASSET_ID>"
export WALLET_ID="<YOUR_WALLET_ID>"
export SEQUENCE_ID="<YOUR_SEQUENCE_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
curl -X GET \
"https://app.bitgo-test.com/api/v2/$COIN/wallet/$WALLET_ID/txrequests?sequenceId=$SEQUENCE_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN"
const listTxRequestResponse = await api.get(
`/api/v2/wallet/${walletId}/txrequests`,
{ params: { sequenceIds: [sequenceId], latest: true } }
);
const txRequests = await listTxRequestResponse.data.txRequests;
if (txRequests.length > 1) {
console.error(
`Found ${txRequests.length} transaction requests with sequence ID: ${sequenceId}`
);
console.error("This is unexpected, and should be reported as a bug.");
return;
}
const sequenceIdExists = txRequests.length >= 0;
Step Result
If the sequenceId
isn't registered with BitGo, you can safely retry the original withdrawal.
However, if the sequenceId
is registered with BitGo and the state
response field is pendingDelivery
, then continue following the steps on this page to rebuild, re-sign, and send the transaction request.
The following is an abbreviated response:
{
"txRequests": [
{
"intent": {
"selfSend": true,
"intentType": "payment",
"recipients": [
{
"address": {
"address": "string",
"option": {}
},
"data": "string"
}
],
"sequenceId": "string",
"comment": "string",
"nonce": "string",
"receiveAddress": "string",
},
"apiVersion": "full",
"txRequestId": "123e4567-e89b-12d3-a456-426614174000",
"idempotencyKey": "string",
"walletId": "59cd72485007a239fb00282ed480da1f",
"walletType": "hot",
"version": 0,
"enterpriseId": "59cd72485007a239fb00282ed480da1f",
"state": "pendingDelivery", // must be pendingDelivery
"date": "2025-04-24T00:00:00.000Z",
"createdDate": "2025-04-24T00:00:00.000Z",
"userId": "string",
"initiatedBy": "string",
"pendingApprovalId": "string",
"policiesChecked": true,
}
]
}
2. Rebuild Transaction Request
// The following libraries are required:
import dotenv from "dotenv";
dotenv.config();
import * as z from "zod";
import axios from "axios";
import cuid from "cuid";
import readline from "readline";
// The following environment variables and utility functions are required:
const env = z
.object({
ACCESS_TOKEN: z.string().min(1),
ENV: z.union([z.literal("test"), z.literal("prod")]),
WALLET_PASSPHRASE: z.string().min(1),
WALLET_ID: z.string(),
ENTERPRISE_ID: z.string(),
BITGO_EXPRESS_URL: z.string().default("http://localhost:3080"),
})
.parse(process.env);
const headers = {
Authorization: `Bearer ${env.ACCESS_TOKEN}`,
};
const api = axios.create({
headers,
baseURL: env.BITGO_EXPRESS_URL,
});
async function doWithRetry(
fn: () => Promise<void>,
maxAttempts: number = 3,
attemptCount: number = 1
) {
try {
return await fn();
} catch (e) {
console.log(`Attempt ${attemptCount} failed with error, Retrying...`, e);
if (attemptCount === maxAttempts) {
console.log("Max attempts reached. Exiting...");
throw e;
}
return await doWithRetry(fn, maxAttempts, attemptCount + 1);
}
}
/**
*
* Send a transaction via express
* @param walletId Wallet ID
* @param coin wallet coin
* @param sequenceId Unique identifier for this transaction
* @param destination Destination address
* @param amount Amount to send (default: 1 base unit (lamport, satoshi, etc.))
* @returns {Promise<void>}
*/
async function sendWithdrawal(
enterpriseId: string,
walletId: string,
walletPassphrase: string,
coin: string,
sequenceId: string,
destination: string,
amount: number = 1
) {
console.log(`Sending a ${amount} base units of ${coin} to ${destination}`);
const sendManyUrl = `/api/v2/${coin}/wallet/${walletId}/sendmany`;
const sendManyData = {
sequenceId: sequenceId,
apiVersion: "full",
recipients: [
{
address: destination,
amount: amount.toString(),
},
],
type: "transfer",
walletPassphrase: walletPassphrase,
enterprise: enterpriseId,
};
// This may throw an error!
// For this example, we force a failure in the local express instance
try {
await api.post(sendManyUrl, sendManyData);
} catch (e: any) {
console.log(
"Got error while sending transaction: ",
e.response?.data?.error
);
throw e;
}
}
/**
* Rebuilds, signs, and broadcasts an existing transaction.
* @param walletId Wallet ID
* @param txRequestId ID of the transaction request that needs to be retried
* @param walletPassphrase Wallet passphrase
* @returns {Promise<void>}
*/
async function retryWithdrawal(
walletId: string,
walletPassphrase: string,
coin: string,
txRequestId: string
) {
if (!txRequestId) {
throw new Error("Transaction ID (txRequestId) is required for retry mode");
}
console.log(`Retrying signature for transaction with ID: ${txRequestId}`);
// Rebuild transaction & Reset signing steps
const rebuildUrl = `/api/v2/wallet/${walletId}/txrequests/${txRequestId}/signatureshares`;
await api.delete(rebuildUrl);
console.log("Transaction rebuilt successfully");
// Sign
const signUrl = `/api/v2/${coin}/wallet/${walletId}/signtxtss`;
const signData = {
walletPassphrase: walletPassphrase,
txPrebuild: {
txRequestId: txRequestId,
},
};
const signResponse = await api.post(signUrl, signData);
console.log("Transaction signed successfully:", signResponse.data);
}
/**
* Implements a generic retry mechanism for any withdrawal errors.
* Any errors get safely retried 3 times.
* Failures beyond 3 retries are logged and the script will exit.
* @param walletId
* @param walletPassphrase
* @param coin
* @param destination
* @param amount
* @param sequenceId
* @returns
*/
async function handleWithdrawalError(
walletId: string,
walletPassphrase: string,
coin: string,
destination: string,
amount: number,
sequenceId: string
): Promise<void> {
await new Promise((resolve) => {
rl.question(
"Sending transaction failed! When ready press Enter to start the retry process...",
() => {
rl.close();
resolve(null);
}
);
});
const listTxRequestResponse = await api.get(
`/api/v2/wallet/${walletId}/txrequests`,
{ params: { sequenceIds: [sequenceId], latest: true } }
);
const txRequests = await listTxRequestResponse.data.txRequests;
if (txRequests.length > 1) {
console.error(
`Found ${txRequests.length} transaction requests with sequence ID: ${sequenceId}`
);
console.error("This is unexpected, and should be reported as a bug.");
return;
}
const sequenceIdExists = txRequests.length >= 0;
if (!sequenceIdExists) {
console.log(
"sequenceId is not used, transaction can safely be re-tried with the same sequenceId."
);
return await doWithRetry(async () => {
console.log("Retrying transaction with the same sequenceId...");
return await processWithdrawal(
env.WALLET_ID,
destination,
amount,
sequenceId
);
});
} else {
const txRequest = txRequests[0];
const { txRequestId, state } = txRequest;
console.log(`Found txRequest ${txRequestId} sequenceId: ${sequenceId}`);
if (["delivered", "canceled", "rejected", "failed"].includes(state)) {
console.log(
`Transaction ${txRequestId} is in terminal state ${state}. Exiting...`
);
return;
} else if (state === "pendingApproval") {
console.log(`Transaction ${txRequestId} is pending approval. Exiting...`);
return;
}
return await doWithRetry(async () => {
console.log(`Rebuilding and re-signing transaction...`);
return await retryWithdrawal(
walletId,
walletPassphrase,
coin,
txRequestId
);
});
}
}
async function processWithdrawal(
walletId: string,
destination: string,
amount: number,
sequenceId: string
): Promise<void> {
const getWalletResponse = await api.get(`/api/v2/wallet/${walletId}`, {
params: {
allTokens: false,
unspentCount: false,
expandAdvancedWhitelist: false,
includeStakingBalances: false,
includeBalance: false,
},
});
const { label, coin } = getWalletResponse.data;
console.log(`Starting script for wallet: ${env.WALLET_ID} ${label} ${coin}`);
try {
await sendWithdrawal(
env.ENTERPRISE_ID,
env.WALLET_ID,
env.WALLET_PASSPHRASE,
coin,
sequenceId,
destination,
amount
);
console.log(`Transaction sent successfully, no retries required.`);
} catch (e) {
console.log("Got error while sending transaction, attempting to recover");
await handleWithdrawalError(
env.WALLET_ID,
env.WALLET_PASSPHRASE,
coin,
destination,
amount,
sequenceId
);
} finally {
console.log("Script finished.");
}
}
// The following is the `main` function that handles command line arguments and calls the `processWithdrawal` function:
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
if (!args.length || args.length < 2 || args[0] == "help") {
console.log(
"Usage: ts-node index.ts <destination> <amount> <sequenceId>"
);
console.log("Example: ts-node index.ts 2N4v... 1 12345");
console.log("Example: ts-node index.ts help");
process.exit(0);
}
const destination = args[0];
const amount = args[1] ? parseInt(args[1], 10) : 1;
const sequenceId = args[2] || cuid();
await processWithdrawal(env.WALLET_ID, destination, amount, sequenceId);
}
main()
.catch((err) => console.error(err))
.finally(() => process.exit(0));
// The following libraries are required:
import dotenv from "dotenv";
dotenv.config();
import * as z from "zod";
import axios from "axios";
import cuid from "cuid";
import readline from "readline";
// The following environment variables and utility functions are required:
const env = z
.object({
ACCESS_TOKEN: z.string().min(1),
ENV: z.union([z.literal("test"), z.literal("prod")]),
WALLET_PASSPHRASE: z.string().min(1),
WALLET_ID: z.string(),
ENTERPRISE_ID: z.string(),
BITGO_EXPRESS_URL: z.string().default("http://localhost:3080"),
})
.parse(process.env);
const headers = {
Authorization: `Bearer ${env.ACCESS_TOKEN}`,
};
const api = axios.create({
headers,
baseURL: env.BITGO_EXPRESS_URL,
});
async function doWithRetry(
fn: () => Promise<void>,
maxAttempts: number = 3,
attemptCount: number = 1
) {
try {
return await fn();
} catch (e) {
console.log(`Attempt ${attemptCount} failed with error, Retrying...`, e);
if (attemptCount === maxAttempts) {
console.log("Max attempts reached. Exiting...");
throw e;
}
return await doWithRetry(fn, maxAttempts, attemptCount + 1);
}
}
/**
*
* Send a transaction via express
* @param walletId Wallet ID
* @param coin wallet coin
* @param sequenceId Unique identifier for this transaction
* @param destination Destination address
* @param amount Amount to send (default: 1 base unit (lamport, satoshi, etc.))
* @returns {Promise<void>}
*/
async function sendWithdrawal(
enterpriseId: string,
walletId: string,
walletPassphrase: string,
coin: string,
sequenceId: string,
destination: string,
amount: number = 1
) {
console.log(`Sending a ${amount} base units of ${coin} to ${destination}`);
const sendManyUrl = `/api/v2/${coin}/wallet/${walletId}/sendmany`;
const sendManyData = {
sequenceId: sequenceId,
apiVersion: "lite",
recipients: [
{
address: destination,
amount: amount.toString(),
},
],
type: "transfer",
walletPassphrase: walletPassphrase,
enterprise: enterpriseId,
};
// This may throw an error!
// For this example, we force a failure in the local express instance
try {
await api.post(sendManyUrl, sendManyData);
} catch (e: any) {
console.log(
"Got error while sending transaction: ",
e.response?.data?.error
);
throw e;
}
}
/**
* Rebuild, signs and broadcasts an existing transaction.
* @param walletId Wallet ID
* @param txRequestId ID of the transaction request that needs to be retried
* @param walletPassphrase Wallet passphrase
* @returns {Promise<void>}
*/
async function retryWithdrawal(
walletId: string,
walletPassphrase: string,
coin: string,
txRequestId: string
) {
if (!txRequestId) {
throw new Error("Transaction ID (txRequestId) is required for retry mode");
}
console.log(`Retrying signature for transaction with ID: ${txRequestId}`);
// Rebuild transaction & Reset signing steps
const rebuildUrl = `/api/v2/wallet/${walletId}/txrequests/${txRequestId}/signatureshares`;
await api.delete(rebuildUrl);
console.log("Transaction rebuilt successfully");
// Sign
const signUrl = `/api/v2/${coin}/wallet/${walletId}/signtxtss`;
const signData = {
walletPassphrase: walletPassphrase,
txPrebuild: {
txRequestId: txRequestId,
},
};
await api.post(signUrl, signData);
console.log("Transaction signed successfully");
const sendUrl = `/api/v2/${coin}/wallet/${walletId}/tx/send`;
const sendData = {
txRequestId: txRequestId,
};
const sendResponse = await api.post(sendUrl, sendData);
console.log("Transaction sent successfully:", sendResponse.data);
}
/**
* Implements a generic retry mechanism for any withdrawal errors.
* Any errors get safely retried 3 times.
* Failures beyond 3 retries will be logged and the script will exit.
* @param walletId
* @param walletPassphrase
* @param coin
* @param destination
* @param amount
* @param sequenceId
* @returns
*/
async function handleWithdrawalError(
walletId: string,
walletPassphrase: string,
coin: string,
destination: string,
amount: number,
sequenceId: string
): Promise<void> {
await new Promise((resolve) => {
rl.question(
"Sending transaction failed! When ready press Enter to start the retry process...",
() => {
rl.close();
resolve(null);
}
);
});
const listTxRequestResponse = await api.get(
`/api/v2/wallet/${walletId}/txrequests`,
{ params: { sequenceIds: [sequenceId], latest: true } }
);
const txRequests = await listTxRequestResponse.data.txRequests;
if (txRequests.length > 1) {
console.error(
`Found ${txRequests.length} transaction requests with sequence ID: ${sequenceId}`
);
console.error("This is unexpected, and should be reported as a bug.");
return;
}
const sequenceIdExists = txRequests.length >= 0;
if (!sequenceIdExists) {
console.log(
"sequenceId is not used, transaction can safely be re-tried with the same sequenceId."
);
return await doWithRetry(async () => {
console.log("Retrying transaction with the same sequenceId...");
return await processWithdrawal(
env.WALLET_ID,
destination,
amount,
sequenceId
);
});
} else {
const txRequest = txRequests[0];
const { txRequestId, state } = txRequest;
console.log(`Found txRequest ${txRequestId} sequenceId: ${sequenceId}`);
if (["delivered", "canceled", "rejected", "failed"].includes(state)) {
console.log(
`Transaction ${txRequestId} is in terminal state ${state}. Exiting...`
);
return;
} else if (state === "pendingApproval") {
console.log(`Transaction ${txRequestId} is pending approval. Exiting...`);
return;
}
return await doWithRetry(async () => {
console.log(`Rebuilding and re-signing transaction...`);
return await retryWithdrawal(
walletId,
walletPassphrase,
coin,
txRequestId
);
});
}
}
async function processWithdrawal(
walletId: string,
destination: string,
amount: number,
sequenceId: string
): Promise<void> {
const getWalletResponse = await api.get(`/api/v2/wallet/${walletId}`, {
params: {
allTokens: false,
unspentCount: false,
expandAdvancedWhitelist: false,
includeStakingBalances: false,
includeBalance: false,
},
});
const { label, coin } = getWalletResponse.data;
console.log(`Starting script for wallet: ${env.WALLET_ID} ${label} ${coin}`);
try {
await sendWithdrawal(
env.ENTERPRISE_ID,
env.WALLET_ID,
env.WALLET_PASSPHRASE,
coin,
sequenceId,
destination,
amount
);
console.log(`Transaction sent successfully, no retries required.`);
} catch (e) {
console.log("Got error while sending transaction, attempting to recover");
await handleWithdrawalError(
env.WALLET_ID,
env.WALLET_PASSPHRASE,
coin,
destination,
amount,
sequenceId
);
} finally {
console.log("Script finished.");
}
}
// The following is the `main` function that handles command line arguments and calls the `processWithdrawal` function:
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
if (!args.length || args.length < 2 || args[0] == "help") {
console.log(
"Usage: ts-node index.ts <destination> <amount> <sequenceId>"
);
console.log("Example: ts-node index.ts 2N4v... 1 12345");
console.log("Example: ts-node index.ts help");
process.exit(0);
}
const destination = args[0];
const amount = args[1] ? parseInt(args[1], 10) : 1;
const sequenceId = args[2] || cuid();
await processWithdrawal(env.WALLET_ID, destination, amount, sequenceId);
}
main()
.catch((err) => console.error(err))
.finally(() => process.exit(0));
Next Steps
Confirm the status of your transaction request by calling the Get Transaction Requests by Wallet endpoint.
See Also
Updated 22 days ago