MPC Error Recovery - MPC Lite
Recover failed MPC Lite withdrawal transactions using sequenceId. See the Guide.
-
Use the
sequenceIdto check the status of a failed transaction request. If BitGo hasn't registered thesequenceId, you can safely retry the original withdrawal. However, if BitGo has registered thesequenceIdand thestateresponse field ispendingDelivery, then continue to the next step to rebuild, re-sign, and send the transaction request.Prerequisites: Initiate a withdrawal with a unique
sequenceId(see Withdraw Overview). -
Use the MPC Lite recovery script to rebuild, re-sign, and broadcast a failed transaction. The script uses the
sequenceIdto look up the transaction request and checks if it is in a recoverable state (pendingDelivery). It deletes the existing signature shares, re-signs the transaction through BitGo Express, sends the signed transaction, and retries up to 3 times. Unlike MPC Full, the Lite version requires an additional explicit send step after signing. Terminal states (delivered,canceled,rejected,failed) cause the script to exit gracefully.
// 1. Check Status
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"
// 2. Rebuild Transaction Request
// Use the JavaScript SDK for this step (see JavaScript tab)
// 1. Check Status
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;
// 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: "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));
// 1. Check Status 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",
"date": "2025-04-24T00:00:00.000Z",
"createdDate": "2025-04-24T00:00:00.000Z",
"userId": "string",
"initiatedBy": "string",
"pendingApprovalId": "string",
"policiesChecked": true,
}
]
}