Custody Starter Architecture
Set Up Custody Wallet
Overview
The custody wallet serves as your cold storage for the majority of your assets. BitGo manages all keys for custody wallets, providing the highest level of security. Withdrawals from custody wallets require video verification with a BitGo operator.
In the custody starter architecture, this wallet:
- Holds the majority of enterprise assets.
- Has the strictest policies (admin approvals, velocity limits).
- Only allows withdrawals to the standby wallet through whitelist.
Prerequisites
Cookbooks
Need just the steps? Expand a cookbook below to get started:
Set Up Custody Wallet (MPC)Open Cookbook Set Up Custody Wallet (Multisig)Open CookbookCreate Custody Wallet
Create a custody wallet using the Add Wallet endpoint. BitGo automatically creates and manages all keys for custody wallets.
Endpoint: Add Wallet
export COIN="<ASSET_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export ENTERPRISE="<YOUR_ENTERPRISE_ID>"
export LABEL="<YOUR_DESIRED_WALLET_NAME>"
curl -X POST \
https://app.bitgo-test.com/api/v2/$COIN/wallet/add \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"enterprise": "'"$ENTERPRISE"'",
"label": "'"$LABEL"'",
"isCustodial": true
}'
const { BitGo } = require('bitgo');
const accessToken = '<YOUR_ACCESS_TOKEN>';
const bitgo = new BitGo({
accessToken: accessToken,
env: 'prod',
});
async function createCustodyWallet() {
const options = {
label: 'Custody Wallet - Cold Storage',
isCustodial: true,
enterprise: '<YOUR_ENTERPRISE_ID>'
};
const newWallet = await bitgo.coin('<ASSET_ID>').wallets().add(options);
console.log('Custody Wallet Created:');
console.log('Wallet ID:', newWallet.id());
console.log('Receive Address:', newWallet.receiveAddress());
}
createCustodyWallet();
Step Result
{
"id": "62f03dee8d13be0007894b3dbc21be17",
"users": [
{
"user": "62ab90e06dfda30007974f0a52a12995",
"permissions": ["admin", "view", "spend"]
}
],
"coin": "btc",
"label": "Custody Wallet - Cold Storage",
"m": 2,
"n": 3,
"keys": [
"62f03dee8d13be0007894b41f63bc504",
"62f03dee8d13be0007894b43cb0f65d0",
"62f03dee8d13be0007894b3f8024e0e8"
],
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"approvalsRequired": 1,
"isCold": true,
"type": "custodial",
"admin": {
"policy": {
"rules": [
{
"id": "Custody Wallet Transaction Custodian Approval",
"mutabilityConstraint": "permanent",
"type": "allTx",
"action": {
"type": "getCustodianApproval"
}
},
{
"id": "Custody Wallet Whitelist",
"mutabilityConstraint": "managed",
"type": "advancedWhitelist",
"action": {
"type": "deny"
},
"condition": {
"entries": []
}
}
]
}
},
"receiveAddress": {
"address": "bc1q..."
}
}
Endpoint: Add Wallet
export BITGO_EXPRESS_HOST="<YOUR_LOCAL_HOST>"
export COIN="<ASSET_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export ENTERPRISE="<ENTERPRISE_ID>"
export WALLET_PASSPHRASE="<YOUR_BITGO_LOGIN_PASSPHRASE>"
export LABEL="<YOUR_DESIRED_WALLET_NAME>"
curl -X POST \
http://$BITGO_EXPRESS_HOST/api/v2/$COIN/wallet/generate?includeKeychains=true \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"enterprise": "'"$ENTERPRISE"'",
"passphrase": "'"$PASSPHRASE"'",
"label": "'"$LABEL"'",
"type": "hot",
"multisigType": "tss",
"walletVersion": 5 # Required for ECDSA assets, such as ETH and MATIC
}'
const { BitGo } = require('bitgo');
const accessToken = '<YOUR_ACCESS_TOKEN>';
const bitgo = new BitGo({
accessToken: accessToken,
env: 'prod',
});
async function createCustodyWallet() {
const options = {
label: 'Custody Wallet - Cold Storage',
isCustodial: true,
multisigType: 'tss',
enterprise: '<YOUR_ENTERPRISE_ID>'
};
const newWallet = await bitgo.coin('<ASSET_ID>').wallets().add(options);
console.log('Custody Wallet Created:');
console.log('Wallet ID:', newWallet.id());
console.log('Receive Address:', newWallet.receiveAddress());
}
createCustodyWallet();
Step Result
{
"id": "62f03dee8d13be0007894b3dbc21be17",
"users": [
{
"user": "62ab90e06dfda30007974f0a52a12995",
"permissions": ["admin", "view", "spend"]
}
],
"coin": "eth",
"label": "Custody Wallet - Cold Storage",
"m": 2,
"n": 3,
"keys": [
"62f03dee8d13be0007894b41f63bc504",
"62f03dee8d13be0007894b43cb0f65d0",
"62f03dee8d13be0007894b3f8024e0e8"
],
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"approvalsRequired": 1,
"isCold": true,
"type": "custodial",
"multisigType": "tss",
"admin": {
"policy": {
"rules": [
{
"id": "Custody Wallet Transaction Custodian Approval",
"mutabilityConstraint": "permanent",
"type": "allTx",
"action": {
"type": "getCustodianApproval"
}
},
{
"id": "Custody Wallet Whitelist",
"mutabilityConstraint": "managed",
"type": "advancedWhitelist",
"action": {
"type": "deny"
},
"condition": {
"entries": []
}
}
]
}
},
"receiveAddress": {
"address": "0x..."
}
}
Note
Save the wallet ID from the response. You need this to configure whitelist policies and to reference this wallet in subsequent steps.
Configure Recommended Policies
After creating the custody wallet, configure the following policies to maximize security.
Velocity Limit Policy
Create a velocity limit to restrict how much can be withdrawn from the custody wallet within a time window. This protects against rapid depletion of funds.
Endpoint: Create Policy Rule
export ENTERPRISE_ID="<YOUR_ENTERPRISE_ID>"
export TOUCHPOINT="wallet.segregated.transfer"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export APPROVER_USER_ID="<APPROVER_USER_ID>"
export CUSTODY_WALLET_ID="<CUSTODY_WALLET_ID>"
curl -X POST \
"https://app.bitgo-test.com/api/policy/v1/enterprises/$ENTERPRISE_ID/touchpoints/$TOUCHPOINT/rules" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"name": "Custody Wallet - Daily Velocity Limit",
"adminOnly": false,
"clauses": [
{
"conditions": [
{
"name": "transfer.velocityLimit",
"parameters": {
"operator": ">",
"amount": "1000000000",
"coin": "btc",
"timeWindow": "86400"
}
}
],
"actions": [
{
"name": "approvals.customer.enterpriseUser",
"parameters": {
"userIds": ["'"$APPROVER_USER_ID"'"],
"minRequired": "2",
"initiatorIsAllowedToApprove": false
}
}
]
}
],
"filteringConditions": [
{
"name": "wallet.ids",
"parameters": {
"walletId": ["'"$CUSTODY_WALLET_ID"'"]
}
}
]
}'
Step Result
{
"id": "684a1b2c3d4e5f6g7h8i9j0k",
"name": "Custody Wallet - Daily Velocity Limit",
"adminOnly": false,
"touchpoint": "wallet.segregated.transfer",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"clauses": [
{
"conditions": [
{
"name": "transfer.velocityLimit",
"parameters": {
"operator": ">",
"amount": "1000000000",
"coin": "btc",
"timeWindow": "86400"
}
}
],
"actions": [
{
"name": "approvals.customer.enterpriseUser",
"parameters": {
"userIds": ["62ab90e06dfda30007974f0a52a12995"],
"minRequired": "2",
"initiatorIsAllowedToApprove": false
}
}
]
}
],
"filteringConditions": [
{
"name": "wallet.ids",
"parameters": {
"walletId": ["62f03dee8d13be0007894b3dbc21be17"]
}
}
]
}
Admin Approval Policy
Require multiple admin approvals for all withdrawals from the custody wallet.
Endpoint: Create Policy Rule
export ENTERPRISE_ID="<YOUR_ENTERPRISE_ID>"
export TOUCHPOINT="wallet.segregated.transfer"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export APPROVER_USER_ID_1="<APPROVER_USER_ID_1>"
export APPROVER_USER_ID_2="<APPROVER_USER_ID_2>"
export CUSTODY_WALLET_ID="<CUSTODY_WALLET_ID>"
curl -X POST \
"https://app.bitgo-test.com/api/policy/v1/enterprises/$ENTERPRISE_ID/touchpoints/$TOUCHPOINT/rules" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"name": "Custody Wallet - Require Multiple Admin Approval",
"adminOnly": false,
"clauses": [
{
"conditions": [],
"actions": [
{
"name": "approvals.customer.enterpriseUser",
"parameters": {
"userIds": ["'"$APPROVER_USER_ID_1"'", "'"$APPROVER_USER_ID_2"'"],
"minRequired": "2",
"initiatorIsAllowedToApprove": false
}
}
]
}
],
"filteringConditions": [
{
"name": "wallet.ids",
"parameters": {
"walletId": ["'"$CUSTODY_WALLET_ID"'"]
}
}
]
}'
Step Result
{
"id": "684a1b2c3d4e5f6g7h8i9j0l",
"name": "Custody Wallet - Require Multiple Admin Approval",
"adminOnly": false,
"touchpoint": "wallet.segregated.transfer",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"clauses": [
{
"conditions": [],
"actions": [
{
"name": "approvals.customer.enterpriseUser",
"parameters": {
"userIds": ["62ab90e06dfda30007974f0a52a12995", "627ff9325a5c1b0007c05a40d15e1522"],
"minRequired": "2",
"initiatorIsAllowedToApprove": false
}
}
]
}
],
"filteringConditions": [
{
"name": "wallet.ids",
"parameters": {
"walletId": ["62f03dee8d13be0007894b3dbc21be17"]
}
}
]
}
Withdrawal Flow
Withdrawals from custody wallets require video verification with a BitGo operator. The flow differs based on if the wallet is multisignature or MPC.
Note
You can only transact from custody wallets in the production environment. BitGo doesn't sign transactions from custody wallets in testnet due to the enhanced security protocols that are required. You can still create testnet custody wallets and initiate transactions, but these transactions remain in an unsigned state.
1. Initiate Transaction
Submit transaction details to BitGo. BitGo uses the data you pass to construct an unsigned transaction that wallet admins can approve.
Endpoint: Initiate a Transaction
export COIN="<ASSET_ID>"
export WALLET_ID="<YOUR_WALLET_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export ADDRESS="<DESTINATION_ADDRESS>"
export AMOUNT="<AMOUNT_IN_BASE_UNITS>"
export VIDEO_APPROVER_1="<PUBLIC_ID_1>"
export VIDEO_APPROVER_2="<PUBLIC_ID_2>"
curl -X POST \
https://app.bitgo-test.com/api/v2/$COIN/wallet/$WALLET_ID/tx/initiate \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"recipients": [
{
"amount": "'"$AMOUNT"'",
"address": "'"$ADDRESS"'"
}
],
"videoApprovers": [
"'"$VIDEO_APPROVER_1"'",
"'"$VIDEO_APPROVER_2"'"
]
}'
let params = {
recipients: [
{
amount: `<AMOUNT_IN_BASE_UNITS>`,
address: `<DESTINATION_ADDRESS>`,
videoApprovers: [
`<PUBLIC_ID_1>`,
`<PUBLIC_ID_2>`
],
},
],
};
wallet.prebuildTransaction(params).then(function (transaction) {
// print transaction details
console.dir(transaction);
});
Step Result
The transaction remains in a pending-approval status until all necessary wallet admins approve it.
{
"error": "triggered all transactions policy",
"pendingApproval": {
"id": "655686880765186f0b3e9e88e1bdd0f4",
"coin": "btc",
"wallet": "6553e933288be490293ae748efafeaaf",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"creator": "62ab90e06dfda30007974f0a52a12995",
"createDate": "2023-11-16T21:15:52.703Z",
"info": {
"type": "transactionRequest",
"transactionRequest": {
"requestedAmount": "10000",
"fee": 45242,
"sourceWallet": "6553e933288be490293ae748efafeaaf",
"recipients": [
{
"address": "2N3sBpM1RnWRMXnEVoUWnM7xtYzL756JE2Q",
"amount": "10000"
}
]
}
},
"state": "pending",
"scope": "wallet",
"approvalsRequired": 1
},
"triggeredPolicy": "6553e933288be490293ae753fefc9f1a"
}
2. Approve Transaction
Note
If you configure an approval requirement for withdrawals, you can't approve your own transactions - another admin must approve them.
Endpoint: Update Pending Approval
export COIN="<ASSET_ID>"
export APPROVAL_ID="<APPROVAL_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export OTP="<OTP>"
curl -X PUT \
https://app.bitgo-test.com/api/v2/$COIN/pendingapprovals/$APPROVAL_ID \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"state": "approved",
"otp": "'"$OTP"'"
}'
const baseCoin = bitgo.coin('<ASSET_ID>');
const pendingApproval = await baseCoin.pendingApprovals().get({ id: '<APPROVAL_ID>' });
const result = await pendingApproval.approve({ otp: '<OTP>' });
console.log('Approval Result:', result);
Step Result
Once approved, BitGo rebuilds the unsigned transaction, applying the most up-to-date fees.
{
"id": "655686880765186f0b3e9e88e1bdd0f4",
"coin": "btc",
"wallet": "6553e933288be490293ae748efafeaaf",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"creator": "62ab90e06dfda30007974f0a52a12995",
"createDate": "2023-11-16T21:15:52.703Z",
"info": {
"type": "transactionRequest",
"transactionRequest": {
"requestedAmount": "10000",
"fee": 45242,
"sourceWallet": "6553e933288be490293ae748efafeaaf",
"recipients": [
{
"address": "2N3sBpM1RnWRMXnEVoUWnM7xtYzL756JE2Q",
"amount": "10000"
}
]
}
},
"state": "approved",
"scope": "wallet",
"approvalsRequired": 1,
"resolvers": [
{
"user": "627ff9325a5c1b0007c05a40d15e1522",
"date": "2023-11-16T21:33:24.644Z",
"resolutionType": "approved"
}
]
}
3. Sign and Broadcast
After approval, log in to BitGo and schedule video ID verification. Once verification completes:
- BitGo uploads the unsigned transaction to the BitGo Offline Vault Console (OVC).
- A BitGo operator uses the user key to sign the unsigned transaction in the OVC, becoming a half-signed transaction.
- A different BitGo operator uses the BitGo key to sign the half-signed transaction in a hardware security module (HSM), creating a fully-signed transaction.
- BitGo broadcasts the fully-signed transaction to the network for confirmation.
1. Request Transaction
Submit transaction request details to BitGo. BitGo uses the data you pass to construct an unsigned transaction that wallet admins must approve.
Endpoint: Create Transaction Request
export WALLET_ID="<WALLET_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export VIDEO_APPROVER_1="<PUBLIC_ID_1>"
export VIDEO_APPROVER_2="<PUBLIC_ID_2>"
curl -X POST \
https://app.bitgo-test.com/api/v2/wallet/$WALLET_ID/txrequests \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"intent": {
"intentType": "payment",
"sequenceId": "unique-sequence-id",
"recipients": [
{
"address": {
"address": "<DESTINATION_ADDRESS>"
},
"amount": {
"value": "<AMOUNT_IN_BASE_UNITS>",
"symbol": "<ASSET_SYMBOL>"
}
}
]
},
"videoApprovers": [
"'"$VIDEO_APPROVER_1"'",
"'"$VIDEO_APPROVER_2"'"
],
"apiVersion": "full",
"preview": false
}'
const { BitGoAPI } = require('@bitgo/sdk-api');
const bitgo = new BitGoAPI({
accessToken: '<YOUR_ACCESS_TOKEN>',
env: 'prod',
});
async function requestTransaction() {
const wallet = await bitgo.coin('<ASSET_ID>').wallets().get({ id: '<WALLET_ID>' });
let params = {
recipients: [
{
amount: '<AMOUNT_IN_BASE_UNITS>',
address: '<DESTINATION_ADDRESS>',
},
],
};
const transaction = await wallet.prebuildTransaction(params);
console.dir(transaction);
}
requestTransaction();
Step Result
BitGo uses the data you pass to build an unsigned transaction. The transaction remains in a pending-approval status until a wallet admin approves it.
{
"error": "triggered all transactions policy",
"pendingApproval": {
"id": "655686880765186f0b3e9e88e1bdd0f4",
"coin": "eth",
"wallet": "6553e933288be490293ae748efafeaaf",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"creator": "62ab90e06dfda30007974f0a52a12995",
"createDate": "2023-11-16T21:15:52.703Z",
"info": {
"type": "transactionRequest",
"transactionRequest": {
"requestedAmount": "100000",
"sourceWallet": "6553e933288be490293ae748efafeaaf",
"recipients": [
{
"address": "0x...",
"amount": "100000"
}
]
}
},
"state": "pending",
"scope": "wallet",
"approvalsRequired": 1
},
"triggeredPolicy": "6553e933288be490293ae753fefc9f1a"
}
2. Approve Transaction
Admins must approve all outgoing transactions from custody MPC wallets. Transactions remain in a pending-approval status until final approval.
Endpoint: Update Pending Approval
export COIN="<ASSET_ID>"
export APPROVAL_ID="<APPROVAL_ID>"
export ACCESS_TOKEN="<YOUR_ACCESS_TOKEN>"
export OTP="<OTP>"
curl -X PUT \
https://app.bitgo-test.com/api/v2/$COIN/pendingapprovals/$APPROVAL_ID \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"state": "approved",
"otp": "'"$OTP"'"
}'
const baseCoin = bitgo.coin('<ASSET_ID>');
const pendingApproval = await baseCoin.pendingApprovals().get({ id: '<APPROVAL_ID>' });
const result = await pendingApproval.approve({ otp: '<OTP>' });
console.log('Approval Result:', result);
Step Result
Once approved, BitGo rebuilds the unsigned transaction, applying the most up-to-date fees.
{
"id": "655686880765186f0b3e9e88e1bdd0f4",
"coin": "eth",
"wallet": "6553e933288be490293ae748efafeaaf",
"enterprise": "62c5ae8174ac860007aff138a2d74df7",
"creator": "62ab90e06dfda30007974f0a52a12995",
"createDate": "2023-11-16T21:15:52.703Z",
"info": {
"type": "transactionRequest",
"transactionRequest": {
"requestedAmount": "100000",
"sourceWallet": "6553e933288be490293ae748efafeaaf",
"recipients": [
{
"address": "0x...",
"amount": "100000"
}
]
}
},
"state": "approved",
"scope": "wallet",
"approvalsRequired": 1,
"resolvers": [
{
"user": "627ff9325a5c1b0007c05a40d15e1522",
"date": "2023-11-16T21:33:24.644Z",
"resolutionType": "approved"
}
]
}
3. Sign and Broadcast
After approval, log in to BitGo and schedule video ID verification. Once verification completes:
- BitGo uploads the unsigned transaction to the BitGo Offline Vault Console (OVC).
- A BitGo operator uses the user key to sign the unsigned transaction in the OVC, becoming a half-signed transaction.
- A different BitGo operator uses the BitGo key to sign the half-signed transaction in a hardware security module (HSM), creating a fully-signed transaction.
- BitGo broadcasts the fully-signed transaction to the network for confirmation.