Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions core/signing-bitgo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# BitGo Signing Driver

A driver for signing and retrieving Canton transactions using the BitGo TSS MPC custodial wallet API, implementing the `SigningDriverInterface` from `@canton-network/core-signing-lib`.

## How it works

BitGo signs Canton transactions asynchronously via its MPC TSS protocol:

1. **Key creation** — a BitGo custodial wallet is created per Canton party (`POST /api/v2/{coin}/wallet`). The wallet ID becomes the stable Canton key identifier (`publicKey`).
2. **Sign request** — the Canton transaction is submitted as a message signing request (`POST /api/v2/wallet/{walletId}/msgrequests`) and returns a `txRequestId` immediately with status `pending`.
3. **Polling** — the wallet gateway polls `getTransaction(txRequestId)` until `status === 'signed'`. The Ed25519 signature and Canton signer fingerprint are extracted from the signed txRequest response.

## Credentials

1. Sign in to [BitGo](https://app.bitgo.com/) (or [BitGo Test](https://app.bitgo-test.com/) for testnet).
2. Create a **Long-Lived Access Token** in _User Settings → Developer Options → Access Tokens_. Select the scopes your use case requires (at minimum: wallet management and transaction signing).
3. Note your **Enterprise ID** from _Settings → Enterprise_. This is required for wallet creation.

## Configuration

| Field | Required | Description |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `accessToken` | Yes | BitGo long-lived access token |
| `baseUrl` | No | API base URL. Defaults to `https://app.bitgo.com` (prod). Use `https://app.bitgo-test.com` for testnet. |
| `enterpriseId` | No | BitGo enterprise ID. Required for `createKey`. Enables restart-safe `getTransaction` fallback via the enterprise txrequests endpoint. |
| `coin` | No | Canton coin identifier. Auto-detected: `tcanton` for `bitgo-test.com` URLs, `canton` for everything else (prod, proxies, custom URLs). |

## Wallet Gateway configuration (`config.json`)

```json
{
"signingProvider": "bitgo",
"signingConfig": {
"accessToken": "<your-long-lived-access-token>",
"baseUrl": "https://app.bitgo-test.com",
"enterpriseId": "<your-enterprise-id>"
}
}
```

## Transaction state lifecycle

BitGo signing is asynchronous — the MPC TSS protocol requires multiple internal rounds before a signature is produced. The driver maps BitGo states to Canton `SigningStatus`:

| BitGo state | Canton status | Notes |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- |
| `initialized`, `pendingApproval`, `pendingDelivery`, `pendingUserSignature`, `pendingUserCommitment`, `pendingUserRShare`, `pendingUserGShare`, `readyToSend` | `pending` | MPC rounds in progress |
| `messages[0].state === 'signed'` | `signed` | Message-level state takes precedence — signing is complete even if txRequest is still `pendingDelivery` |
| `delivered`, `signed` | `signed` | |
| `canceled`, `rejected` | `rejected` | |
| `failed` | `failed` | |

## Restart resilience

The driver maintains an in-memory `txRequestId → walletId` map for fast lookups. If the process restarts, this map is lost. Transactions submitted before a restart are recovered via the BitGo enterprise txrequests endpoint (`GET /api/v2/enterprise/{enterpriseId}/txrequests?txRequestIds=...`), which requires `enterpriseId` to be configured.

## Test scripts

### Connectivity check

```bash
BITGO_ACCESS_TOKEN=<token> \
BITGO_ENTERPRISE_ID=<enterprise-id> \
npx tsx scripts/test-connectivity.ts
```

### Sign a transaction

**Submit mode** (no `BITGO_TX_ID` set):

```bash
BITGO_ACCESS_TOKEN=<token> \
BITGO_TX=<base64-tx> \
BITGO_TX_HASH=<base64-hash> \
BITGO_ENTERPRISE_ID=<enterprise-id> \
npx tsx scripts/test-sign.ts
# Prints txId and walletId to use in check mode
```

**Check mode** (`BITGO_TX_ID` set):

```bash
BITGO_ACCESS_TOKEN=<token> \
BITGO_TX_ID=<txRequestId> \
BITGO_WALLET_ID=<walletId> \
npx tsx scripts/test-sign.ts
```

Optional env vars for both modes: `BITGO_API_URL`, `BITGO_COIN`, `BITGO_MSG_TYPE` (`CANTON_SIGN_TOPOLOGY` default, or `CANTON_SIGN_TRANSACTION`).

## Development

```bash
yarn build # compile
yarn test # run tests
yarn test:coverage # with coverage report
```
52 changes: 52 additions & 0 deletions core/signing-bitgo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@canton-network/core-signing-bitgo",
"version": "1.0.0",
"type": "module",
"description": "Wallet Gateway signing driver for BitGo",
"license": "Apache-2.0",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsdown --onSuccess \"tsc\"",
"clean": "rm -rf ./dist",
"test": "vitest run --project node",
"test:coverage": "vitest run --project node --coverage"
},
"dependencies": {
"@bitgo/sdk-coin-canton": "^1.27.2",
"@canton-network/core-signing-lib": "workspace:^",
"@canton-network/core-wallet-auth": "workspace:^",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@vitest/coverage-v8": "^4.1.8",
"tsdown": "^0.22.9",
"typescript": "^5.9.3",
"vitest": "^4.1.8"
},
"files": [
"dist/**"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/canton-network/wallet.git",
"directory": "core/signing-bitgo"
},
"homepage": "https://github.com/canton-network/wallet/tree/main/core/signing-bitgo#readme",
"bugs": {
"url": "https://github.com/canton-network/wallet/issues"
}
}
46 changes: 46 additions & 0 deletions core/signing-bitgo/scripts/test-connectivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { BitGoHandler } from '../src/bitgo.js'

const accessToken = process.env.BITGO_ACCESS_TOKEN
if (!accessToken) {
console.error('Error: BITGO_ACCESS_TOKEN environment variable is required')
process.exit(1)
}

const enterpriseId = process.env.BITGO_ENTERPRISE_ID

const handler = new BitGoHandler({
accessToken,
baseUrl: process.env.BITGO_API_URL ?? 'https://app.bitgo-test.com',
enterpriseId,
coin: process.env.BITGO_COIN,
})

async function run() {
console.log('\n=== 1. getKeys (list canton wallets) ===')
const keys = await handler.getKeys()
console.log(`Found ${keys.length} canton wallet(s):`)
keys.forEach((k) => console.log(` id=${k.id} name=${k.name}`))

if (!enterpriseId) {
console.log(
'\nSkipping createKey — set BITGO_ENTERPRISE_ID to test wallet creation'
)
return
}

console.log('\n=== 2. createKey (create a new TSS canton wallet) ===')
const key = await handler.createKey(`bitgo-canton-test-${Date.now()}`)
console.log('Created:', key)

console.log('\n=== 3. getKeys again (should include new wallet) ===')
const keysAfter = await handler.getKeys()
console.log(`Now ${keysAfter.length} canton wallet(s)`)
}

run().catch((err) => {
console.error('FAILED:', err.message)
process.exit(1)
})
100 changes: 100 additions & 0 deletions core/signing-bitgo/scripts/test-sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { BitGoHandler } from '../src/bitgo.js'

function requireEnv(name: string): string {
const val = process.env[name]
if (!val) {
console.error(`Error: ${name} environment variable is required`)
process.exit(1)
}
return val
}

const accessToken = requireEnv('BITGO_ACCESS_TOKEN')
const enterpriseId = process.env.BITGO_ENTERPRISE_ID
const txId = process.env.BITGO_TX_ID
const walletId = process.env.BITGO_WALLET_ID

const handler = new BitGoHandler({
accessToken,
baseUrl: process.env.BITGO_API_URL ?? 'https://app.bitgo-test.com',
enterpriseId,
coin: process.env.BITGO_COIN,
})

async function submit() {
const tx = process.env.BITGO_TX
const txHash = process.env.BITGO_TX_HASH
if (!tx || !txHash) {
console.error(
'SUBMIT mode requires BITGO_TX and BITGO_TX_HASH (base64-encoded)'
)
process.exit(1)
}

let signingWalletId = walletId
if (!signingWalletId) {
const keys = await handler.getKeys()
if (keys.length === 0) {
console.error(
'No canton wallets found — set BITGO_WALLET_ID explicitly'
)
process.exit(1)
}
signingWalletId = keys[0].id
console.log(`Using wallet: id=${signingWalletId} name=${keys[0].name}`)
}

const messageStandardType =
process.env.BITGO_MSG_TYPE ?? 'CANTON_SIGN_TOPOLOGY'
const result = await handler.signTransaction({
tx,
txHash,
walletId: signingWalletId,
messageStandardType,
})
console.log('\n=== Signing request submitted ===')
console.log(`txId: ${result.txId}`)
console.log(`walletId: ${signingWalletId}`)
console.log('\nOnce signing is complete, run:')
console.log(
` BITGO_ACCESS_TOKEN=<token> BITGO_TX_ID=${result.txId} BITGO_WALLET_ID=${signingWalletId} npx tsx scripts/test-sign.ts`
)
}

async function check() {
if (!txId) {
console.error('CHECK mode requires BITGO_TX_ID')
process.exit(1)
}

let result
if (walletId) {
result = await handler.fetchTxRequest(txId, walletId)
} else {
result = await handler.getTransaction(txId)
if (!result) {
console.error(
`No transaction found for txId=${txId}. Set BITGO_WALLET_ID or BITGO_ENTERPRISE_ID for fallback lookup.`
)
process.exit(1)
}
}

console.log('\n=== Transaction status ===')
console.log(JSON.stringify(result, null, 2))
}

if (txId) {
check().catch((err) => {
console.error('FAILED:', err.message)
process.exit(1)
})
} else {
submit().catch((err) => {
console.error('FAILED:', err.message)
process.exit(1)
})
}
Loading