Blockstream Enterprise Custody SDK
Getting Started

Issuer quickstart

The guide explains the shortest path from invite data to first AMP2 wallet registration for an issuer. The issuer creates an authenticated SDK session and receives a WID.

This guide walks you through the shortest path from receiving invite data to registering your first wallet in AMP2. By the end, you will have an authenticated SDK session and a wallet ID ready for asset operations.

Prerequisites

  • Node.js 18+
  • npm access to @blockstream/cryptic, @blockstream/ecs-registry, and @blockstream/amp-registry (see Environments for access details)
  • Invite data from Blockstream (device UUID, invite string, one time key, server keys, API URL)

1. Install the SDK

npm install @blockstream/cryptic @blockstream/ecs-registry @blockstream/amp-registry

If the packages are hosted on a private registry, configure your .npmrc first:

@blockstream:registry=https://enterprise.blockstream.com/integrations/npm
//enterprise.blockstream.com/integrations/npm/:_authToken=${NPM_TOKEN}

2. Generate device key pairs

Every AMP2 device needs an RSA key pair (for encryption) and an ECDSA P-256 key pair (for signing). The @blockstream/cryptic package provides a helper:

import { generateUserKeyPairs } from '@blockstream/cryptic/utils';

const deviceKeys = await generateUserKeyPairs();
// deviceKeys.rsa.privateKeyPem   - Promise<string> (RSA-2048 PKCS#8 PEM)
// deviceKeys.rsa.publicKeyPem    - Promise<string> (RSA-2048 SPKI PEM)
// deviceKeys.ecdsa.privateKeyHex - ECDSA P-256 private key (hex string)
// deviceKeys.ecdsa.publicKeyHex  - ECDSA P-256 compressed public key (hex string)

These keys are used to establish the COSE encryption envelope for all subsequent requests. Store the private keys securely; they are never sent to AMP2.

3. Create a one time broadcaster with mTLS

The one time broadcaster uses the invite's one time private key to authenticate the initial authorization request. This key is consumed during activation and cannot be reused. The transport flow is:

  1. Build the encrypted payload with blockstream.request(...)
  2. Provision or load an mTLS client identity in Node
  3. Send the /request body through a broadcaster requestCallback that uses mtlsRequest(...)
  4. Parse the response with blockstream.parse(...)
Broadcaster with Node-side mTLS transport
import { Buffer } from 'buffer'
import { Blockstream } from '@blockstream/cryptic'
import { Broadcaster } from '@blockstream/cryptic/broadcaster'
import { provisionMtlsClientCertificate, PROD_CA_CERT_PEM, buildMtlsSecret, MtlsProfile, MtlsSecretType } from '@blockstream/cryptic/mtls'
import { mtlsRequest } from '@blockstream/cryptic/mtls/node'

const otBlockstream = new Blockstream(
  rsaPrivateKeyPem,
  Buffer.from(oneTimePrivateKey, 'hex'),
  deviceUuid,
  serverRsaPublicKeyPem,
  Buffer.from(serverEcdsaPublicKeyHex, 'hex'),
)

const ecdsaPublicKey = Buffer.from(
  p256.getPublicKey(
    new Uint8Array(Buffer.from(oneTimePrivateKey, 'hex')),
    true,
  ),
)
const secret = buildMtlsSecret(ecdsaPublicKey)

const identity = await provisionMtlsClientCertificate('https://enterprise.blockstream.com', {
  profile: MtlsProfile.MtlsClientV1,
  deviceUuid,
  secret,
  secretType: MtlsSecretType.PubKey,
})

const otBroadcaster = new Broadcaster(ECS_BASE_URL, otBlockstream, async request => {
  const response = await mtlsRequest({
    url: request.url,
    method: request.method,
    headers: request.headers,
    body: request.body,
    certPem: identity.certificatePem,
    keyPem: identity.privateKeyPem,
    caPem: PROD_CA_CERT_PEM,
  })

  return {
    status: response.status,
    body: response.body,
  }
})

Parameters:

  • rsaPrivateKeyPem: your RSA private key (await deviceKeys.rsa.privateKeyPem), used to decrypt AMP2 responses.
  • oneTimePrivateKey: the hex encoded key from the invite, used to sign this first request only.
  • device_uuid: the unique device identifier from the invite.
  • SERVER_RSA_PUBLIC_KEY_PEM: AMP2's RSA public key (from invite data).
  • SERVER_ECDSA_PUBLIC_KEY: AMP2's ECDSA public key as compressed hex (same as in invite data); decode with Buffer.from(..., 'hex'), not new Uint8Array(string).

Notes:

  • Number of mTLS certificates are strictly limited (3 per month). Store them securely and reuse until they expire.
  • Provisioning (@blockstream/cryptic/mtls) is browser-safe; the client-certificate request transport mtlsRequest (@blockstream/cryptic/mtls/node) is Node-only.
  • In Electron, do the mTLS transport in main or preload, not in page JS.
  • mTLS only changes the TLS handshake. The ECS request body is still the same encrypted COSE payload.
  • If you need per-request variation, requestCallback is also available in BroadcastOptions on the standalone broadcaster helpers.

The browser-safe / Node split shown above lands in @blockstream/cryptic 1.0.4. On earlier versions the entire mTLS API was a single Node-only @blockstream/cryptic/mtls entrypoint — import everything (including mtlsRequest) from there:

Legacy import (@blockstream/cryptic < 1.0.4)
import { mtlsRequest, provisionMtlsClientCertificate, PROD_CA_CERT_PEM, buildMtlsSecret, MtlsProfile, MtlsSecretType } from '@blockstream/cryptic/mtls'

4. Authorize the device

The authorize call activates your account and registers your device keys with AMP2. After this, the one time key is consumed.

The authorize endpoint is an ECS endpoint. Use the ecsResources action map from @blockstream/ecs-registry:

import type { ecsResources } from '@blockstream/ecs-registry'

const authorizePayload = await otBroadcaster.processBroadcast<
  typeof ecsResources.auth.authorize
>(
  {
    action: 'add',
    resource: '/account-management/authorize',
    details: {
      invite_string: inviteInfo.invite_string!,
      device_name: 'Issuer Backend',
      password: 'StrongPassword123!',
      device_signature_pubkey: deviceKeys.ecdsa.publicKeyHex,
      device_encryption_pubkey: await deviceKeys.rsa.publicKeyPem,
    },
  },
  oneTimeBroadcaster,
);

What this does:

  • Consumes the invite string (single use).
  • Sets the account password.
  • Registers your ECDSA public key (for future request signature verification).
  • Registers your RSA public key (for encrypting responses to you).

If authorization fails, check that the invite string hasn't already been used and that the server keys match the target environment.

5. Create a durable broadcaster with mTLS

After authorization, switch to a broadcaster that uses your permanent device keys instead of the one time key:

import { Buffer } from 'node:buffer';
import { Blockstream } from '@blockstream/cryptic';

const blockstream = new Blockstream(
  await deviceKeys.rsa.privateKeyPem,
  Buffer.from(deviceKeys.ecdsa.privateKeyHex, 'hex'),
  inviteInfo.device_uuid!,
  SERVER_RSA_PUBLIC_KEY_PEM,
  Buffer.from(SERVER_ECDSA_PUBLIC_KEY, 'hex'),
);

const broadcaster = new Broadcaster(ECS_BASE_URL, blockstream, async request => {
  const response = await mtlsRequest({
    url: request.url,
    method: request.method,
    headers: request.headers,
    body: request.body,
    certPem: identity.certificatePem,
    keyPem: identity.privateKeyPem,
    caPem: PROD_CA_CERT_PEM,
  })

  return {
    status: response.status,
    body: response.body,
  }
})

This broadcaster handles all future SDK requests. Every call will be ECDSA signed and RSA encrypted automatically.

6. Fetch the AMP xpub

To build a wallet descriptor, you need the AMP2 server's keyorigin xpub. This is the server's half of the 2-of-2 multisig that protects every AMP2 wallet.

import { AmpActions } from '@blockstream/amp-registry';

const xpubResp = await broadcaster.processBroadcast<
  typeof AmpActions.xpub
>(
  {
    action: 'get',
    resource: '/amp/xpub',
    details: {},
  },
);
const ampKeyoriginXpub = xpubResp.details.keyorigin_xpub;

7. Build a wallet descriptor

To register a wallet, you first need a confidential 2-of-2 multisig descriptor. Use LWK (Liquid Wallet Kit) to generate the key material and construct the descriptor. See the LWK documentation for installation and detailed usage.

The high level steps are:

  1. Create a signer from a BIP 39 mnemonic using LWK (e.g., SwSigner).
  2. Derive your keyorigin xpub from the signer (e.g., signer.keyorigin_xpub()).
  3. Get AMP2's keyorigin xpub from the SDK (see step 6 above) or from the invite data.
  4. Build the descriptor by combining both xpubs into a ct(elip151,elwsh(multi(2, <your_xpub>, <amp_xpub>))) confidential descriptor.

The resulting descriptor fully describes the 2-of-2 multisig wallet where both your key and AMP2's key are required to sign transactions. Here is the example (using lwk_wasm):

import * as lwk from 'lwk_wasm'

const mnemonic = lwk.Mnemonic.fromRandom(12)
const network = lwk.Network.testnet()
let signer = new lwk.Signer(mnemonic, network)

const signerKeyoriginXpub = signer.keyoriginXpub(lwk.Bip.bip84())
console.log(`signer_keyoriginxpub: ${signerKeyoriginXpub}`)

const threshold = 2
const desc = `ct(elip151,elwsh(multi(${threshold},${signerKeyoriginXpub.trim()}/<0;1>/*,${ampKeyoriginXpub.trim()}/<0;1>/*)))`
let walletDescriptor = new lwk.WolletDescriptor(desc)

let wallet = new lwk.Wollet(network, walletDescriptor)

8. Register the wallet

Submit the descriptor to AMP2:

import { AmpWalletRequestSchema } from '@blockstream/amp-registry';

const registerResp = await broadcaster.processBroadcast<
  typeof AmpActions.wallet
>(
  AmpWalletRequestSchema.parse({
    action: 'add',
    resource: '/amp/wallets',
    details: {
      descriptor: walletDescriptor.toString(),
    },
  }),
);

if (registerResp.status !== 'success') {
  throw new Error(`Wallet registration failed: ${registerResp.message}`);
}

const wid = registerResp.details.wid;

The returned wid (wallet ID) is a deterministic identifier derived from the descriptor. Use it in all subsequent asset operations.

9. Verify it worked

Check the wallet balance to confirm registration:

const balanceResp = await broadcaster.processBroadcast<
  typeof AmpActions.walletBalance
>(
  {
    action: 'get',
    resource: `/amp/wallets/${wid}/balance`,
    details: { with_names: true },
  },
);

console.log('Wallet balance:', balanceResp.details);

A newly registered wallet will have zero balances. Fund it with LBTC (for transaction fees) before issuing or sending assets.

What just happened

  1. You generated RSA and ECDSA key pairs for your device.
  2. You used a one time broadcaster to activate your AMP2 account.
  3. You switched to a durable broadcaster using your permanent keys.
  4. You fetched AMP2's xpub and built a wallet descriptor using LWK.
  5. You registered the descriptor and received a wallet ID (wid) that you'll use for all asset operations.

Next steps

On this page