Custody Wallets

🏦 Feature 4: Custody Wallets

Hold stablecoins without automatic conversion. User controls when to convert to fiat.

What Are Custody Wallets?

Custody wallets are standard stablecoin deposit addresses managed by Bakkt. Unlike on-ramp linked wallets, stablecoins deposited to custody addresses are held until the user manually triggers a conversion.

Key characteristics:

  • ✅ Hold stablecoins without automatic conversion
  • ✅ User controls conversion timing
  • ✅ No Accounts API link required
  • ✅ Works for both users and corporates

When to use:

  • User wants to hold stablecoins and convert later
  • Wait for favorable exchange rates
  • Accumulate stablecoins over time before converting
  • Don't need immediate fiat access

Custody vs On-Ramp Linked

FeatureCustody WalletOn-Ramp Linked Wallet
ConversionManual (user decides when)Automatic (when fiat deposited)
Accounts APINot requiredRequired (stablecoin fiat account link)
Use CaseHold & convert laterAuto-convert fiat deposits
EndpointGET /user/wallet/{chain}GET /user/wallet/{chain}?linked_bank_account_uuid=xxx

User Custody Example

// No Accounts API needed for custody

// 1. Get custody wallet address
const wallet = await fetch('https://api.bakkt.com/stablecoin/user/wallet/polygon', {
  headers: {
    'Authorization': apiKey,
    'user-uuid': userUuid
  }
});

const walletData = await wallet.json();
console.log('Custody address:', walletData[0].address);
console.log('Chain:', walletData[0].chain);

// 2. User deposits crypto (held in custody)
// User sends USDC from their wallet to custody address

// 3. Later, when user wants to convert:
// User triggers off-ramp (see Off-Ramp feature above)
// → Adds remote bank account
// → Sends crypto to wallet
// → Receives fiat in bank account

Corporate Custody Example

const corporateUuid = 'YOUR_CORPORATE_UUID';

// Get corporate custody wallet
const wallet = await fetch(
  `https://api.bakkt.com/stablecoin/corporate/${corporateUuid}/wallet/base`,
  {
    headers: {
      'Authorization': apiKey,
      'user-uuid': corporateUuid
    }
  }
).then(r => r.json());

// Returns custody address for holding crypto
// Corporate can accumulate and convert when ready
console.log('Corporate custody address:', wallet[0].address);

Info:
Custody Benefit: Users can hold stablecoins in Bakkt custody (secure, insured) and convert to fiat whenever they choose, without needing an immediate Accounts API integration.

🔐 Feature 5: Smart Contract Custodial Wallets

Create smart contract-based custodial wallets for enhanced security and control. These wallets provide institutional-grade custody with programmable security rules.

What Are Smart Contract Custodial Wallets?

Smart contract custodial wallets are on-chain smart contracts that hold user funds with enhanced security features:

Key characteristics:

  • ✅ Smart contract-based custody (not just EOA addresses)
  • ✅ Enhanced security with programmable rules
  • ✅ OTP-based withdrawal confirmation
  • ✅ View balances across all tokens
  • ✅ Per-chain wallet creation

When to use:

  • Enterprise clients requiring institutional custody
  • Applications needing programmable withdrawal rules
  • Multi-signature or approval workflows
  • Enhanced security requirements

How Smart Contract Wallets Work

Note: This section includes a sequence diagram showing the workflow. Refer to the original documentation for the visual diagram.

Create Custodial Wallet

Step 1: Create the wallet

// Create custodial wallet on Polygon
const wallet = await fetch('https://api.bakkt.com/stablecoin/wallet/custodial', {
  method: 'POST',
  headers: {
    'Authorization': apiKey,
    'bakkt-session-id': sessionId,
    'user-uuid': userUuid,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    chain: 'Polygon'
  })
});

const walletData = await wallet.json();
console.log('Wallet UUID:', walletData.walletUuid);
console.log('Merchant UUID:', walletData.merchantUuid);
console.log('Chain:', walletData.chain);

Response:

{
  "walletUuid": "c12345b5-7890-4a2f-8c2f-f3e913c12e5f",
  "merchantUuid": "d12345b5-7890-4a2f-8c2f-f3e913c12e5f",
  "chain": "polygon"
}

Warning:
Wallet Status: Initially, the wallet will be in Pending status while the smart contract is deployed. Check status using GET endpoint.

Get Wallet Details & Status

Step 2: Check wallet status and get addresses

// Get custodial wallet details
const details = await fetch(
  `https://api.bakkt.com/stablecoin/wallet/custodial/${walletUuid}`,
  {
    headers: {
      'Authorization': apiKey,
      'bakkt-session-id': sessionId,
      'user-uuid': userUuid
    }
  }
);

const walletDetails = await details.json();
console.log('Status:', walletDetails.status); // "Pending" or "Active"
console.log('Smart Contract Address:', walletDetails.smartContractAddress);
console.log('Key Address:', walletDetails.smartContractKeyAddress);

Response (Active wallet):

{
  "walletUuid": "c12345b5-7890-4a2f-8c2f-f3e913c12e5f",
  "merchantUuid": "d56789a1-2345-6789-abcd-1234567890ef",
  "chain": "polygon",
  "status": "Active",
  "smartContractAddress": "0xabc123...def456",
  "smartContractKeyAddress": "0xkey123...abc789"
}

Wallet Lifecycle:

  • Pending: Smart contract deployment in progress (1-2 minutes)
  • Active: Wallet ready to receive deposits

Check Wallet Balance

View balance across all tokens:

// Get current balance
const balance = await fetch('https://api.bakkt.com/stablecoin/wallet/balance', {
  headers: {
    'Authorization': apiKey,
    'bakkt-session-id': sessionId,
    'user-uuid': userUuid
  }
});

const balanceData = await balance.json();
console.log('Balance:', balanceData.balance);

Response:

{
  "balance": 100.23
}

Note:
Balance Tracking: The balance endpoint shows the total value of all assets in the custodial wallet, aggregated across all supported tokens.

Request Offramp from Custodial Wallet

Step 3: Initiate withdrawal with OTP

// Request offramp (triggers OTP email/SMS)
const offrampRequest = await fetch(
  'https://api.bakkt.com/stablecoin/wallet/custodial/offramp',
  {
    method: 'POST',
    headers: {
      'Authorization': apiKey,
      'bakkt-session-id': sessionId,
      'user-uuid': userUuid,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      walletUuid: 'c12345b5-7890-4a2f-8c2f-f3e913c12e5f',
      chain: 'polygon',
      token: 'USDC',
      amount: 25.0,
      offrampAddress: '0xF2f5F4Df47664C1030d3FAAC2E5D6b927d0717B1'
    })
  }
);

const offrampData = await offrampRequest.json();
console.log('Offramp Request UUID:', offrampData.offrampRequestUuid);
console.log('Recipient UUID:', offrampData.beneficiaryUuid);
// OTP sent to user's email/phone

Response:

{
  "offrampRequestUuid": "f19c111e-5a2e-41c2-9f00-8e7d0b8c2e31",
  "merchantUuid": "e0123abc-7890-4567-a1b2-c3d4e5f67890",
  "walletUuid": "a12345bc-6789-4def-9012-3456789abcde",
  "token": "USDC",
  "amount": 20.5,
  "beneficiaryUuid": "b98765cb-3210-4321-8765-abcdef987654"
}

Warning:
OTP Security: An OTP (One-Time Password) will be sent to the user's registered email/phone. This must be provided to confirm the withdrawal.

Confirm Offramp with OTP

Step 4: Submit OTP to execute withdrawal

// User receives OTP via email/SMS
const userOTP = '123456'; // 6-digit code

// Confirm offramp
const confirmation = await fetch(
  'https://api.bakkt.com/stablecoin/wallet/custodial/offramp/confirm',
  {
    method: 'POST',
    headers: {
      'Authorization': apiKey,
      'bakkt-session-id': sessionId,
      'user-uuid': userUuid,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      offrampRequestUuid: 'f19c111e-5a2e-41c2-9f00-8e7d0b8c2e31',
      otp: userOTP
    })
  }
);

// Returns 204 No Content on success
if (confirmation.status === 204) {
  console.log('Offramp confirmed! Funds will be transferred.');
}

Sign In with Custodial Wallet

Authenticate using a custodial wallet:

// Sign in or link custodial wallet
const signin = await fetch('https://api.bakkt.com/stablecoin/wallet/custodial/signin', {
  method: 'POST',
  headers: {
    'Authorization': apiKey,
    'bakkt-session-id': sessionId,
    'user-uuid': userUuid,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    walletUuid: 'c12345b5-7890-4a2f-8c2f-f3e913c12e5f'
  })
});

const signinData = await signin.json();
console.log('Message to sign:', signinData.message);
console.log('Signature:', signinData.signature);
console.log('Wallet UUID:', signinData.walletUuid);

Response:

{
  "message": "Please sign this message to authenticate your wallet.",
  "signature": "0xabcdef1234567890",
  "merchantUuid": "f4b32e1e-d9e0-4b4f-bbb3-6fc2f8a7b9dc",
  "walletUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Complete Custodial Workflow

End-to-end example:

// === SETUP: Create & Activate Custodial Wallet ===

// 1. Create custodial wallet
const createResponse = await bakkt.custodial.create({
  chain: 'Polygon'
});

const walletUuid = createResponse.walletUuid;

// 2. Wait for activation (poll until Active)
let walletStatus = 'Pending';
while (walletStatus === 'Pending') {
  await new Promise(r => setTimeout(r, 5000)); // Wait 5 seconds

  const statusCheck = await bakkt.custodial.getDetails(walletUuid);
  walletStatus = statusCheck.status;

  if (walletStatus === 'Active') {
    console.log('Wallet active!');
    console.log('Deposit address:', statusCheck.smartContractAddress);
  }
}

// === DEPOSIT: User Sends Crypto ===

// 3. User deposits USDC to smart contract address
// (happens in user's wallet app, e.g., MetaMask)

// 4. Check balance
const balance = await bakkt.custodial.getBalance();
console.log('Current balance:', balance.balance, 'USDC');

// === WITHDRAW: Offramp to Fiat ===

// 5. Add remote bank account (if not already added)
const bankAccount = await bakkt.stablecoin.createRemoteBankAccount({
  account_name: 'My Bank',
  main_recipient: true,
  account_details: {
    currency: 'EUR',
    iban: 'DE89370400440532013000'
  }
});

// 6. Request offramp (triggers OTP)
const offrampRequest = await bakkt.custodial.requestOfframp({
  walletUuid,
  chain: 'polygon',
  token: 'USDC',
  amount: 100,
  offrampAddress: bankAccount.receiving_address
});

console.log('OTP sent to user. Request UUID:', offrampRequest.offrampRequestUuid);

// 7. User receives OTP and submits
const userOTP = promptUser('Enter OTP from email/SMS:');

// 8. Confirm offramp
await bakkt.custodial.confirmOfframp({
  offrampRequestUuid: offrampRequest.offrampRequestUuid,
  otp: userOTP
});

console.log('Withdrawal confirmed! Processing...');

// 9. Monitor via webhooks
webhook.on('cryptoToFiat SUCCESS', (data) => {
  console.log(`${data.amountCrypto} USDC → ${data.amountFiat} EUR`);
  console.log('Fiat sent to bank account');
});

Security Features

Smart Contract Security

On-Chain Custody: Funds are held in audited smart contracts, not hot wallets.

**Programmable Rules**: Withdrawal limits, multi-sig, time locks can be configured.

**Transparent**: All transactions visible on-chain via block explorer.

OTP Confirmation

Two-Factor Withdrawal: Every withdrawal requires both API auth + OTP.

**6-Digit Code**: Sent via email or SMS to registered user.

**Time-Limited**: OTP expires after 10 minutes for security.

Balance Tracking

Real-Time: Query balance at any time via GET /wallet/balance.

**Multi-Token**: Aggregated value across all supported stablecoins.

**Webhook Updates**: Receive notifications on balance changes.

Custodial vs Standard Wallets

FeatureSmart Contract CustodialStandard CustodyOn-Ramp Linked
SecuritySmart contract + OTPBakkt custodyBakkt custody
WithdrawalRequires OTPAutomaticAutomatic
Use CaseHigh-security holdingSimple holdingAuto-convert
SetupPOST /wallet/custodialGET /user/wallet/{chain}stablecoin fiat account link
Best ForEnterprises, high-valueIndividual usersFiat depositors

Supported Chains for Custodial

Custodial wallets can be created on all supported EVM chains:

  • Ethereum (mainnet)
  • Polygon
  • Arbitrum
  • Optimism
  • Base
  • BSC (Binance Smart Chain)
  • Avalanche

Note:
Solana Support: Smart contract custodial wallets are currently available only on EVM-compatible chains. For Solana, use standard custody wallets via GET /user/wallet/solana.

Best Practices for Custodial Wallets

Wallet Creation

  • Create wallets during user onboarding
    • Wait for Active status before displaying deposit address

    • Store walletUuid for future operations

    • Create one wallet per chain if multi-chain support needed

Deposits

  • Only show deposit address when status is Active
    • Display clear instructions on supported tokens

    • Warn about minimum deposit amounts

    • Provide transaction hash for tracking

Withdrawals

  • Implement OTP input UI for user
    • Show OTP expiration timer (10 minutes)

    • Handle OTP errors gracefully (invalid, expired)

    • Provide "resend OTP" option

Security

  • Never store OTPs in logs or databases
    • Implement rate limiting on OTP attempts
    • Show clear security warnings to users
    • Monitor for suspicious withdrawal patterns

Error Handling

Common errors and solutions:

ErrorCauseSolution
Wallet status: PendingSmart contract still deployingWait and retry GET endpoint
Invalid OTPWrong code enteredPrompt user to re-enter
OTP expired>10 minutes passedRequest new offramp
Insufficient balanceNot enough fundsCheck balance first
Chain not supportedSolana smart contractsUse standard custody

Info:
Production Use: Custodial wallets are production-ready and used by enterprise clients for institutional custody requirements.