diff --git a/.changeset/new-drinks-suffer.md b/.changeset/new-drinks-suffer.md new file mode 100644 index 0000000000..ba664fa2ff --- /dev/null +++ b/.changeset/new-drinks-suffer.md @@ -0,0 +1,5 @@ +--- +'@chainlink/dlc-cbtc-por-adapter': major +--- + +dlc-cbtc-por initial release diff --git a/.gitignore b/.gitignore index 4a7e39bfe9..6b9dbd83a7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,10 @@ packages/k6/src/config/http.json # IDE metadata .vscode .idea +.editorconfig +.gitattributes # Secrets .envrc .env + diff --git a/.pnp.cjs b/.pnp.cjs index 40e97bc618..661f96f772 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -362,6 +362,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/dlc-btc-por-adapter",\ "reference": "workspace:packages/sources/dlc-btc-por"\ },\ + {\ + "name": "@chainlink/dlc-cbtc-por-adapter",\ + "reference": "workspace:packages/sources/dlc-cbtc-por"\ + },\ {\ "name": "@chainlink/dns-query-adapter",\ "reference": "workspace:packages/sources/dns-query"\ @@ -859,6 +863,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/deribit-adapter", ["workspace:packages/sources/deribit"]],\ ["@chainlink/deutsche-boerse-adapter", ["workspace:packages/sources/deutsche-boerse"]],\ ["@chainlink/dlc-btc-por-adapter", ["workspace:packages/sources/dlc-btc-por"]],\ + ["@chainlink/dlc-cbtc-por-adapter", ["workspace:packages/sources/dlc-cbtc-por"]],\ ["@chainlink/dns-query-adapter", ["workspace:packages/sources/dns-query"]],\ ["@chainlink/dxfeed-adapter", ["workspace:packages/sources/dxfeed"]],\ ["@chainlink/dydx-stark-adapter", ["workspace:packages/targets/dydx-stark"]],\ @@ -5963,6 +5968,24 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/dlc-cbtc-por-adapter", [\ + ["workspace:packages/sources/dlc-cbtc-por", {\ + "packageLocation": "./packages/sources/dlc-cbtc-por/",\ + "packageDependencies": [\ + ["@chainlink/dlc-cbtc-por-adapter", "workspace:packages/sources/dlc-cbtc-por"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.4"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["bip32", "npm:4.0.0"],\ + ["bitcoinjs-lib", "npm:6.1.7"],\ + ["nock", "npm:13.5.6"],\ + ["tiny-secp256k1", "npm:2.2.4"],\ + ["tslib", "npm:2.8.1"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/dns-query-adapter", [\ ["workspace:packages/sources/dns-query", {\ "packageLocation": "./packages/sources/dns-query/",\ @@ -29919,6 +29942,14 @@ const RAW_RUNTIME_STATE = ["node-gyp", "npm:10.2.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.2.4", {\ + "packageLocation": "./.yarn/cache/tiny-secp256k1-npm-2.2.4-888e0d46db-3f29104fcd.zip/node_modules/tiny-secp256k1/",\ + "packageDependencies": [\ + ["tiny-secp256k1", "npm:2.2.4"],\ + ["uint8array-tools", "npm:0.0.7"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["tinyglobby", [\ @@ -30575,6 +30606,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["uint8array-tools", [\ + ["npm:0.0.7", {\ + "packageLocation": "./.yarn/cache/uint8array-tools-npm-0.0.7-96ca58a124-6ffc45c7d2.zip/node_modules/uint8array-tools/",\ + "packageDependencies": [\ + ["uint8array-tools", "npm:0.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["undici", [\ ["npm:5.29.0", {\ "packageLocation": "./.yarn/cache/undici-npm-5.29.0-caeb96c8ee-0ceca8924a.zip/node_modules/undici/",\ diff --git a/.yarn/cache/tiny-secp256k1-npm-2.2.4-888e0d46db-3f29104fcd.zip b/.yarn/cache/tiny-secp256k1-npm-2.2.4-888e0d46db-3f29104fcd.zip new file mode 100644 index 0000000000..a2a6a7f121 Binary files /dev/null and b/.yarn/cache/tiny-secp256k1-npm-2.2.4-888e0d46db-3f29104fcd.zip differ diff --git a/.yarn/cache/uint8array-tools-npm-0.0.7-96ca58a124-6ffc45c7d2.zip b/.yarn/cache/uint8array-tools-npm-0.0.7-96ca58a124-6ffc45c7d2.zip new file mode 100644 index 0000000000..00e4203be4 Binary files /dev/null and b/.yarn/cache/uint8array-tools-npm-0.0.7-96ca58a124-6ffc45c7d2.zip differ diff --git a/packages/sources/dlc-cbtc-por/DESIGN.md b/packages/sources/dlc-cbtc-por/DESIGN.md new file mode 100644 index 0000000000..ce2407ff20 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/DESIGN.md @@ -0,0 +1,175 @@ +# DLC CBTC Proof of Reserves Adapter + +This adapter provides multiple endpoints for CBTC proof of reserves: + +- **`attesterSupply`** (default): CBTC supply from DLC.Link Attester APIs +- **`daSupply`**: CBTC supply from Digital Asset's API +- **`reserves`**: Bitcoin reserves calculated from on-chain UTXOs + +## Configuration + +| Variable | Required | Default | Description | +| ----------------------- | -------- | ---------------- | ------------------------------------------------------------------------------------ | +| `ATTESTER_API_URLS` | Yes | - | Comma-separated list of DLC.Link Attester API base URLs | +| `BITCOIN_RPC_ENDPOINT` | Yes | - | Electrs-compatible Bitcoin blockchain API endpoint | +| `CANTON_API_URL` | No | - | Digital Asset API endpoint URL for token metadata | +| `CHAIN_NAME` | No | `canton-mainnet` | Chain name to filter addresses (`canton-mainnet`, `canton-testnet`, `canton-devnet`) | +| `BACKGROUND_EXECUTE_MS` | No | `10000` | Interval in milliseconds between background executions | + +## Endpoints + +### `attesterSupply` (default) + +Returns the total CBTC supply from DLC.Link Attester APIs as an integer string (scaled by 10^10). + +Queries multiple attesters in parallel and returns the **median** of successful responses. Requires at least 1 successful response. + +#### Example Request + +```json +{ + "data": {} +} +``` + +#### Example Response + +```json +{ + "result": "143127387999", + "data": { + "result": "143127387999" + }, + "statusCode": 200 +} +``` + +### `daSupply` + +Returns the total CBTC supply from the Digital Asset API as an integer string (scaled by 10^decimals). + +Requires `CANTON_API_URL` to be set. + +#### Example Request + +```json +{ + "data": { + "endpoint": "daSupply" + } +} +``` + +#### Example Response + +```json +{ + "result": "143127388000", + "data": { + "result": "143127388000" + }, + "statusCode": 200 +} +``` + +### `reserves` + +Returns the total Bitcoin reserves (in satoshis) across all vault addresses. + +The endpoint: + +1. Fetches vault address data from attesters +2. Independently calculates Bitcoin addresses from the threshold public key +3. Verifies calculated addresses match attester-provided addresses +4. Queries the Bitcoin blockchain for UTXOs with at least 1 confirmation +5. Returns the **median** reserves across all attesters + +#### Example Request + +```json +{ + "data": { + "endpoint": "reserves" + } +} +``` + +#### Example Response + +```json +{ + "result": "789982326000", + "data": { + "result": "789982326000" + }, + "statusCode": 200 +} +``` + +## Precision Handling + +The `result` is a **string** to preserve precision for values exceeding `Number.MAX_SAFE_INTEGER` (9×10^15). + +CBTC uses 10 decimals, so the maximum supply (21M BTC) would be 2.1×10^17 in base units, exceeding JavaScript's safe integer limit. + +## Error Handling + +The adapter returns a 502 error when: + +**attesterSupply:** + +- No successful attester responses +- Attester status is not `"ready"` +- `total_supply_cbtc` is missing, empty, or whitespace-only + +**daSupply:** + +- No instruments found in API response +- CBTC instrument not found +- `totalSupply` is missing, empty, or whitespace-only +- `decimals` is missing or negative + +**reserves:** + +- No successful attester responses +- Address verification fails (calculated address doesn't match attester) +- No vault addresses found for the configured chain + +The adapter **never** returns a default value like `0` on error - it either succeeds with valid data or fails explicitly. + +## Running Locally + +```bash +# Build +yarn build + +# Set environment variables +export ATTESTER_API_URLS="https://attester1.example.com,https://attester2.example.com" +export BITCOIN_RPC_ENDPOINT="https://your-electrs-endpoint.com" +export CANTON_API_URL="https://your-canton-api-endpoint.com/instruments" + +# Start +yarn start + +# Query attesterSupply (default) +curl -X POST http://localhost:8080 -H "Content-Type: application/json" -d '{"data": {}}' + +# Query daSupply +curl -X POST http://localhost:8080 -H "Content-Type: application/json" -d '{"data": {"endpoint": "daSupply"}}' + +# Query reserves +curl -X POST http://localhost:8080 -H "Content-Type: application/json" -d '{"data": {"endpoint": "reserves"}}' +``` + +## Running Tests + +```bash +# From repo root - run all tests +yarn jest packages/sources/dlc-cbtc-por/test/ --no-coverage + +# Run unit tests only +yarn jest packages/sources/dlc-cbtc-por/test/unit/ --no-coverage + +# Run integration tests only +yarn jest packages/sources/dlc-cbtc-por/test/integration/ --no-coverage +``` diff --git a/packages/sources/dlc-cbtc-por/package.json b/packages/sources/dlc-cbtc-por/package.json new file mode 100644 index 0000000000..30e110131f --- /dev/null +++ b/packages/sources/dlc-cbtc-por/package.json @@ -0,0 +1,48 @@ +{ + "name": "@chainlink/dlc-cbtc-por-adapter", + "version": "0.0.0", + "description": "Chainlink DLC CBTC Proof of Reserves adapter. Queries Attester APIs, Digital Asset API, and Bitcoin blockchain for CBTC reserves.", + "keywords": [ + "Chainlink", + "LINK", + "CBTC", + "Canton", + "BTC", + "Bitcoin", + "Digital Assets", + "blockchain", + "oracle", + "Proof of Reserves" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.11.4", + "bip32": "^4.0.0", + "bitcoinjs-lib": "^6.1.7", + "tiny-secp256k1": "^2.2.4", + "tslib": "^2.3.1" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + } +} diff --git a/packages/sources/dlc-cbtc-por/src/config/index.ts b/packages/sources/dlc-cbtc-por/src/config/index.ts new file mode 100644 index 0000000000..16cc6e40d0 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/config/index.ts @@ -0,0 +1,40 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig( + { + // Shared config + ATTESTER_API_URLS: { + description: 'Comma-separated list of DLC.Link Attester API URLs', + type: 'string', + required: true, + }, + // Canton/DA Supply config + CANTON_API_URL: { + description: 'Digital Asset API endpoint URL for CBTC token metadata', + type: 'string', + }, + // BTC Reserves config + CHAIN_NAME: { + description: 'Chain name to filter addresses from Attester API', + type: 'enum', + options: ['canton-mainnet', 'canton-testnet', 'canton-devnet'], + default: 'canton-mainnet', + }, + BITCOIN_RPC_ENDPOINT: { + description: 'Electrs-compatible Bitcoin blockchain API endpoint for UTXO queries', + type: 'string', + required: true, + }, + // General config + BACKGROUND_EXECUTE_MS: { + description: 'Interval in milliseconds between background executions', + type: 'number', + default: 10_000, + }, + }, + { + envDefaultOverrides: { + CACHE_MAX_AGE: 10_000, + }, + }, +) diff --git a/packages/sources/dlc-cbtc-por/src/endpoint/attester-supply.ts b/packages/sources/dlc-cbtc-por/src/endpoint/attester-supply.ts new file mode 100644 index 0000000000..4aa6842185 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/endpoint/attester-supply.ts @@ -0,0 +1,16 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { EmptyInputParameters } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { transport } from '../transport/attester-supply' +import { StringResultResponse } from '../types' + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: StringResultResponse + Settings: typeof config.settings +} + +export const attesterSupply = new AdapterEndpoint({ + name: 'attesterSupply', + transport, +}) diff --git a/packages/sources/dlc-cbtc-por/src/endpoint/da-supply.ts b/packages/sources/dlc-cbtc-por/src/endpoint/da-supply.ts new file mode 100644 index 0000000000..110511acc4 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/endpoint/da-supply.ts @@ -0,0 +1,26 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { EmptyInputParameters } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { transport } from '../transport/da-supply' +import { StringResultResponse } from '../types' + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: StringResultResponse + Settings: typeof config.settings +} + +export const daSupply = new AdapterEndpoint({ + name: 'daSupply', + transport, + customInputValidation: (_req, settings): AdapterInputError | undefined => { + if (!settings.CANTON_API_URL) { + throw new AdapterInputError({ + statusCode: 400, + message: 'CANTON_API_URL environment variable is required for the daSupply endpoint', + }) + } + return + }, +}) diff --git a/packages/sources/dlc-cbtc-por/src/endpoint/index.ts b/packages/sources/dlc-cbtc-por/src/endpoint/index.ts new file mode 100644 index 0000000000..c129da3fbd --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/endpoint/index.ts @@ -0,0 +1,3 @@ +export { attesterSupply } from './attester-supply' +export { daSupply } from './da-supply' +export { reserves } from './reserves' diff --git a/packages/sources/dlc-cbtc-por/src/endpoint/reserves.ts b/packages/sources/dlc-cbtc-por/src/endpoint/reserves.ts new file mode 100644 index 0000000000..c8183b05c1 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/endpoint/reserves.ts @@ -0,0 +1,17 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { EmptyInputParameters } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { transport } from '../transport/reserves' +import { StringResultResponse } from '../types' + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: StringResultResponse + Settings: typeof config.settings +} + +export const reserves = new AdapterEndpoint({ + name: 'reserves', + aliases: ['por'], + transport, +}) diff --git a/packages/sources/dlc-cbtc-por/src/index.ts b/packages/sources/dlc-cbtc-por/src/index.ts new file mode 100644 index 0000000000..71a44e9606 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { attesterSupply, daSupply, reserves } from './endpoint' + +export const adapter = new Adapter({ + name: 'DLC_CBTC_POR', + defaultEndpoint: attesterSupply.name, + config, + endpoints: [attesterSupply, daSupply, reserves], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/dlc-cbtc-por/src/lib/attester-supply.ts b/packages/sources/dlc-cbtc-por/src/lib/attester-supply.ts new file mode 100644 index 0000000000..1430ccf80b --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/attester-supply.ts @@ -0,0 +1,17 @@ +import { AttesterResponse } from '../types' +import { parseDecimalString } from '../utils' + +const CBTC_DECIMALS = 10 + +/** + * Calculates the total supply from the Attester API response. + */ +export function calculateAttesterSupply(response: AttesterResponse): string { + if (response.status !== 'ready') { + throw new Error(`Attester not ready: status=${response.status}`) + } + if (!response.total_supply_cbtc?.trim()) { + throw new Error('total_supply_cbtc is missing or empty') + } + return parseDecimalString(response.total_supply_cbtc, CBTC_DECIMALS).toString() +} diff --git a/packages/sources/dlc-cbtc-por/src/lib/btc/address.ts b/packages/sources/dlc-cbtc-por/src/lib/btc/address.ts new file mode 100644 index 0000000000..310a086389 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/btc/address.ts @@ -0,0 +1,229 @@ +/** + * Bitcoin Address Calculation for CBTC Bridge + * + * Calculates vault addresses from the threshold public key and deposit IDs, + * then verifies they match what the attester reports. + * + * Based on: https://github.com/DLC-link/cbtc-por-tools + */ + +import { makeLogger } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import BIP32Factory from 'bip32' +import * as bitcoin from 'bitcoinjs-lib' +import * as crypto from 'crypto' +import * as ecc from 'tiny-secp256k1' +import { buildUrl } from '../../utils' +import { AttesterAddressResponse, ChainAddressGroup } from './types' + +const logger = makeLogger('BtcAddress') + +// Initialize ECC library for bitcoinjs-lib (required for Taproot operations) +bitcoin.initEccLib(ecc) + +// Initialize BIP32 library with elliptic curve implementation +const bip32 = BIP32Factory(ecc) + +/** + * Unspendable Public Key for DLC (Discreet Log Contract) Taproot Addresses + * + * This is the "Nothing Up My Sleeve" (NUMS) point used in BIP-341 Taproot. + * It's derived by hashing "UNSPENDABLE" and using the result as a seed for + * a provably unspendable public key. + * + * For DLC.Link's CBTC bridge, this key serves as the Taproot internal key: + * - The internal key must be unspendable to ensure funds can only be spent + * via the script path (which requires the threshold signature) + * - Using a fixed, verifiable NUMS point ensures no party has the private key + * - This matches the Rust implementation in dlc-btc-lib reference implementation + * + * The key is 33 bytes in compressed format (02 prefix + 32-byte x-coordinate). + * Reference: https://github.com/DLC-link/dlc-btc-lib + */ +const UNSPENDABLE_PUBLIC_KEY = '0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0' + +/** + * Hash a string using SHA-256 + * Used to derive the chain code for the unspendable key from the deposit ID + */ +function hashString(input: string): Buffer { + return crypto.createHash('sha256').update(input, 'utf8').digest() +} + +/** + * Convert network string to bitcoinjs-lib network object + */ +export function getBitcoinNetwork(networkStr: string): bitcoin.Network { + switch (networkStr.toLowerCase()) { + case 'mainnet': + case 'bitcoin': + return bitcoin.networks.bitcoin + case 'testnet': + return bitcoin.networks.testnet + case 'regtest': + return bitcoin.networks.regtest + default: + throw new Error(`Unknown Bitcoin network: ${networkStr}`) + } +} + +/** + * Derive the unspendable internal key from a deposit ID + * + * Creates a deterministic but unspendable key that serves as the Taproot internal key. + * The key is "unspendable" because the base public key has no known private key. + */ +function deriveUnspendableKey(id: string, network: bitcoin.Network): Buffer { + // Hash the deposit ID to get a deterministic 32-byte chain code + const chainCode = hashString(id) + + // Parse the fixed unspendable public key (33 bytes, compressed format) + const publicKey = Buffer.from(UNSPENDABLE_PUBLIC_KEY, 'hex') + + // Create the extended key with our custom chain code + const extendedKey = bip32.fromPublicKey(publicKey, chainCode, network) + + // Derive at path m/0/0 using BIP32 non-hardened derivation + const derived = extendedKey.derive(0).derive(0) + + // Return x-only public key by stripping the first byte (0x02 or 0x03 prefix) + return derived.publicKey.slice(1) +} + +/** + * Calculate the Taproot address for a deposit account + * + * This is the core algorithm that independently calculates the Bitcoin address. + * The address is a P2TR (Pay-to-Taproot) address with script-path spending enabled. + */ +export function calculateTaprootAddress( + id: string, + xOnlyPubkey: Buffer, + network: bitcoin.Network, +): string { + // Create the Taproot script for script-path spending + // Format: <32-byte x-only pubkey> OP_CHECKSIG + const script = bitcoin.script.compile([xOnlyPubkey, bitcoin.opcodes.OP_CHECKSIG]) + + // Get the unspendable internal key (deterministic from deposit ID) + const internalPubkey = deriveUnspendableKey(id, network) + + // Create the P2TR payment and encode as Bech32m address + const payment = bitcoin.payments.p2tr({ + internalPubkey, + scriptTree: { + output: script, + }, + network, + }) + + if (!payment.address) { + throw new Error('Failed to generate address - invalid payment') + } + + return payment.address +} + +/** + * Fetch address calculation data from the Attester API + */ +export async function fetchAddressCalculationData( + requester: Requester, + attesterUrl: string, +): Promise { + const url = buildUrl(attesterUrl, '/app/get-address-calculation-data') + logger.debug(`Fetching address data from Attester API`) + + const response = await requester.request(url, { url }) + + if (!response.response.data) { + throw new Error(`Attester API request failed: no data returned`) + } + + const data = response.response.data + + // Basic validation of response structure + if (!data || !Array.isArray(data.chains) || typeof data.bitcoin_network !== 'string') { + throw new Error('Invalid Attester API response: missing required fields') + } + + return data +} + +/** + * Calculate and verify all vault addresses for a specific chain. + * Returns an array of verified Bitcoin addresses that can be queried for UTXOs. + */ +export function calculateAndVerifyAddresses( + chainGroup: ChainAddressGroup, + network: bitcoin.Network, +): string[] { + logger.debug(`Verifying ${chainGroup.addresses.length} addresses for chain ${chainGroup.chain}`) + + // Parse the xpub to get the threshold group's x-only public key + const xpubDecoded = bip32.fromBase58(chainGroup.xpub, network) + + // Convert from 33-byte compressed key to 32-byte x-only key + const xOnlyPubkey = xpubDecoded.publicKey.slice(1) + + const verifiedAddresses: string[] = [] + + for (const addressInfo of chainGroup.addresses) { + // Calculate the address independently from deposit ID and threshold pubkey + const calculatedAddress = calculateTaprootAddress(addressInfo.id, xOnlyPubkey, network) + + // Verify our calculated address matches what the attester reported + if (calculatedAddress !== addressInfo.address_for_verification) { + // Log full deposit ID for debugging + logger.error( + `Address mismatch for depositId=${addressInfo.id}: ` + + `calculated=${calculatedAddress}, attester=${addressInfo.address_for_verification}`, + ) + throw new Error( + `Address verification failed: depositId=${addressInfo.id}, ` + + `calculated=${calculatedAddress}, expected=${addressInfo.address_for_verification}`, + ) + } + + verifiedAddresses.push(calculatedAddress) + } + + logger.info(`Verified ${verifiedAddresses.length} addresses for chain ${chainGroup.chain}`) + return verifiedAddresses +} + +/** + * Fetch and calculate all vault addresses from the Attester API. + * Queries the API, filters by chain name, calculates addresses trustlessly, + * and verifies they match the attester-provided addresses. + */ +export async function fetchAndCalculateVaultAddresses( + requester: Requester, + attesterUrl: string, + chainName: string, +): Promise<{ addresses: string[]; bitcoinNetwork: bitcoin.Network }> { + logger.info(`Fetching vault addresses for chain=${chainName}`) + + // Fetch deposit account data from the attester + const data = await fetchAddressCalculationData(requester, attesterUrl) + const bitcoinNetwork = getBitcoinNetwork(data.bitcoin_network) + + logger.debug(`Attester response: network=${data.bitcoin_network}, chains=${data.chains.length}`) + + // Find the chain matching our configured chain name + const chainGroup = data.chains.find((c) => c.chain.toLowerCase() === chainName.toLowerCase()) + + if (!chainGroup) { + const availableChains = data.chains.map((c) => c.chain).join(', ') + throw new Error(`Chain "${chainName}" not found. Available: [${availableChains}]`) + } + + // Calculate and verify all addresses + const addresses = calculateAndVerifyAddresses(chainGroup, bitcoinNetwork) + + logger.info( + `Fetched ${addresses.length} verified addresses for ${chainName} (${data.bitcoin_network})`, + ) + + return { addresses, bitcoinNetwork } +} diff --git a/packages/sources/dlc-cbtc-por/src/lib/btc/index.ts b/packages/sources/dlc-cbtc-por/src/lib/btc/index.ts new file mode 100644 index 0000000000..869e68266d --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/btc/index.ts @@ -0,0 +1,3 @@ +export * from './address' +export * from './por' +export * from './types' diff --git a/packages/sources/dlc-cbtc-por/src/lib/btc/por.ts b/packages/sources/dlc-cbtc-por/src/lib/btc/por.ts new file mode 100644 index 0000000000..ef52ee0ed9 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/btc/por.ts @@ -0,0 +1,245 @@ +import { makeLogger } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { buildUrl } from '../../utils' +import { MempoolTransaction, UTXO } from './types' + +const logger = makeLogger('BtcPor') + +/** Batch size for parallel address processing to avoid overwhelming the API */ +const ADDRESS_BATCH_SIZE = 10 + +/** + * Validates that a value is a number + */ +function validateNumber(value: unknown, context: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${context}: expected number, got ${typeof value}`) + } + return value +} + +/** + * Validates UTXO response from Electrs API + */ +function validateUtxoResponse(data: unknown, address: string): UTXO[] { + if (!Array.isArray(data)) { + throw new Error(`Invalid UTXO response for ${address}: expected array`) + } + + return data.map((item, i) => { + if (!item || typeof item !== 'object') { + throw new Error(`Invalid UTXO at index ${i} for ${address}`) + } + + const utxo = item as Record + + if (typeof utxo.txid !== 'string') { + throw new Error(`Invalid UTXO txid at index ${i} for ${address}`) + } + if (typeof utxo.vout !== 'number') { + throw new Error(`Invalid UTXO vout at index ${i} for ${address}`) + } + if (typeof utxo.value !== 'number') { + throw new Error(`Invalid UTXO value at index ${i} for ${address}`) + } + if (!utxo.status || typeof utxo.status !== 'object') { + throw new Error(`Invalid UTXO status at index ${i} for ${address}`) + } + + return item as UTXO + }) +} + +/** + * Validates mempool transaction response from Electrs API + */ +function validateMempoolResponse(data: unknown, address: string): MempoolTransaction[] { + if (!Array.isArray(data)) { + throw new Error(`Invalid mempool response for ${address}: expected array`) + } + + return data.map((item, i) => { + if (!item || typeof item !== 'object') { + throw new Error(`Invalid mempool tx at index ${i} for ${address}`) + } + + const tx = item as Record + + if (typeof tx.txid !== 'string') { + throw new Error(`Invalid mempool tx txid at index ${i} for ${address}`) + } + if (!Array.isArray(tx.vin)) { + throw new Error(`Invalid mempool tx vin at index ${i} for ${address}`) + } + + return item as MempoolTransaction + }) +} + +/** Fetches current Bitcoin block height from Electrs API */ +export async function fetchBlockHeight(requester: Requester, endpoint: string): Promise { + const url = buildUrl(endpoint, '/blocks/tip/height') + logger.debug(`Fetching block height`) + + const response = await requester.request(url, { url }) + + if (!response.response.data && response.response.data !== 0) { + throw new Error(`Failed to fetch block height: no data returned`) + } + + return validateNumber(response.response.data, 'Block height response') +} + +/** Fetches confirmed & unconfirmed UTXOs for a Bitcoin address via Electrs API */ +export async function fetchAddressUtxos( + requester: Requester, + endpoint: string, + address: string, +): Promise { + const url = buildUrl(endpoint, `/address/${address}/utxo`) + logger.debug(`Fetching UTXOs for ${address}`) + + const response = await requester.request(url, { url }) + + return validateUtxoResponse(response.response.data, address) +} + +/** Fetches unconfirmed (mempool) transactions for an address */ +export async function fetchMempoolTransactions( + requester: Requester, + endpoint: string, + address: string, +): Promise { + const url = buildUrl(endpoint, `/address/${address}/txs/mempool`) + logger.debug(`Fetching mempool transactions for ${address}`) + + const response = await requester.request(url, { url }) + + return validateMempoolResponse(response.response.data, address) +} + +/** + * Calculates the number of confirmations for a UTXO + * Returns 0 for unconfirmed UTXOs + */ +export function getConfirmations(utxo: UTXO, currentBlockHeight: number): number { + if (!utxo.status.confirmed || !utxo.status.block_height) { + return 0 + } + return currentBlockHeight - utxo.status.block_height + 1 +} + +/** Checks if a UTXO has enough confirmations */ +export function hasMinConfirmations( + utxo: UTXO, + currentBlockHeight: number, + minConfirmations: number, +): boolean { + return getConfirmations(utxo, currentBlockHeight) >= minConfirmations +} + +/** + * Sums confirmed UTXOs that meet the minimum confirmation requirement. + * Uses BigInt to prevent overflow when summing large amounts across many addresses. + */ +export function sumConfirmedUtxos( + utxos: UTXO[], + currentBlockHeight: number, + minConfirmations: number, +): bigint { + return utxos + .filter((utxo) => hasMinConfirmations(utxo, currentBlockHeight, minConfirmations)) + .reduce((sum, utxo) => sum + BigInt(utxo.value), 0n) +} + +/** + * Sums pending spend input values from mempool transactions. + * When a UTXO is spent but unconfirmed, we add back its value to prevent balance dips. + * Uses BigInt to prevent overflow + */ +export function sumPendingSpendInputs(mempoolTxs: MempoolTransaction[], address: string): bigint { + let total = 0n + for (const tx of mempoolTxs) { + for (const input of tx.vin) { + if (input.prevout.scriptpubkey_address === address) { + total += BigInt(input.prevout.value) + } + } + } + return total +} + +/** + * Calculates reserves for a single address: + * 1. Sum confirmed UTXOs with sufficient confirmations + * 2. Add pending spend inputs to prevent balance dips during unconfirmed spends + */ +export async function calculateAddressReserves( + requester: Requester, + endpoint: string, + address: string, + currentBlockHeight: number, + minConfirmations: number, +): Promise { + // Fetch all UTXOs for the address + const utxos = await fetchAddressUtxos(requester, endpoint, address) + const confirmedBalance = sumConfirmedUtxos(utxos, currentBlockHeight, minConfirmations) + + // Fetch pending (mempool) transactions + const mempoolTxs = await fetchMempoolTransactions(requester, endpoint, address) + const pendingSpendValue = sumPendingSpendInputs(mempoolTxs, address) + + const total = confirmedBalance + pendingSpendValue + + logger.debug( + `Address ${address}: confirmed=${confirmedBalance} sats, pendingSpend=${pendingSpendValue} sats, total=${total} sats`, + ) + + return total +} + +/** + * Calculates total reserves across all vault addresses. + * Processes addresses in parallel batches for performance. + * Returns BigInt to handle large totals without overflow. + */ +export async function calculateReserves( + requester: Requester, + endpoint: string, + addresses: string[], + minConfirmations: number, +): Promise { + const currentBlockHeight = await fetchBlockHeight(requester, endpoint) + logger.info( + `Calculating reserves: ${addresses.length} addresses, block height ${currentBlockHeight}`, + ) + + let totalReserves = 0n + + // Process addresses in parallel batches + for (let i = 0; i < addresses.length; i += ADDRESS_BATCH_SIZE) { + const batch = addresses.slice(i, i + ADDRESS_BATCH_SIZE) + const results = await Promise.all( + batch.map((address) => + calculateAddressReserves( + requester, + endpoint, + address, + currentBlockHeight, + minConfirmations, + ), + ), + ) + + for (const reserves of results) { + totalReserves += reserves + } + + logger.debug( + `Processed batch ${Math.floor(i / ADDRESS_BATCH_SIZE) + 1}: ${batch.length} addresses`, + ) + } + + logger.info(`Total reserves: ${totalReserves} sats across ${addresses.length} addresses`) + return totalReserves +} diff --git a/packages/sources/dlc-cbtc-por/src/lib/btc/types.ts b/packages/sources/dlc-cbtc-por/src/lib/btc/types.ts new file mode 100644 index 0000000000..778879d4c3 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/btc/types.ts @@ -0,0 +1,57 @@ +/** + * Type definitions for the DLC.Link Attester API and Bitcoin address calculation + */ + +/** Individual address info for a single deposit account */ +export interface AddressInfo { + /** Deposit account ID (hex string from Canton) */ + id: string + /** Bitcoin P2TR address for verification */ + address_for_verification: string +} + +/** Group of addresses sharing the same xpub (one per Canton chain) */ +export interface ChainAddressGroup { + /** Canton network name (e.g., "canton-mainnet", "canton-testnet") */ + chain: string + /** BIP32 extended public key (already derived to m/0/0) */ + xpub: string + /** All deposit accounts on this chain */ + addresses: AddressInfo[] +} + +/** Top-level API response from /app/get-address-calculation-data */ +export interface AttesterAddressResponse { + /** Array of chain groups */ + chains: ChainAddressGroup[] + /** Bitcoin network: "mainnet", "testnet", or "regtest" */ + bitcoin_network: string +} + +/** UTXO data structure from Electrs API */ +export interface UTXO { + txid: string + vout: number + value: number + status: { + confirmed: boolean + block_height?: number + } +} + +/** Mempool transaction structure from Electrs API */ +export interface MempoolTransaction { + txid: string + vin: Array<{ + txid: string + vout: number + prevout: { + scriptpubkey_address: string + value: number + } + }> + vout: Array<{ + scriptpubkey_address: string + value: number + }> +} diff --git a/packages/sources/dlc-cbtc-por/src/lib/da-supply.ts b/packages/sources/dlc-cbtc-por/src/lib/da-supply.ts new file mode 100644 index 0000000000..992959640d --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/lib/da-supply.ts @@ -0,0 +1,26 @@ +import { Instrument } from '../types' +import { parseDecimalString } from '../utils' + +/** + * Extracts and calculates the CBTC total supply from the Digital Asset API response. + */ +export function calculateDaSupply(instruments: Instrument[]): string { + if (!instruments || instruments.length === 0) { + throw new Error('No instruments found in API response') + } + + const cbtcInstrument = instruments.find((i) => i.symbol === 'CBTC') + if (!cbtcInstrument) { + throw new Error('CBTC instrument not found in API response') + } + + if (!cbtcInstrument.totalSupply?.trim()) { + throw new Error('CBTC totalSupply is missing or empty') + } + + if (cbtcInstrument.decimals == null || cbtcInstrument.decimals < 0) { + throw new Error('CBTC decimals is missing or invalid') + } + + return parseDecimalString(cbtcInstrument.totalSupply, cbtcInstrument.decimals).toString() +} diff --git a/packages/sources/dlc-cbtc-por/src/transport/attester-supply.ts b/packages/sources/dlc-cbtc-por/src/transport/attester-supply.ts new file mode 100644 index 0000000000..43d1c7c069 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/transport/attester-supply.ts @@ -0,0 +1,114 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { config } from '../config' +import { BaseEndpointTypes } from '../endpoint/attester-supply' +import { calculateAttesterSupply } from '../lib/attester-supply' +import { AttesterResponse } from '../types' +import { buildUrl, medianBigInt, parseUrls } from '../utils' + +const logger = makeLogger('AttesterSupplyTransport') + +class AttesterSupplyTransport extends SubscriptionTransport { + requester!: Requester + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.requester = dependencies.requester + } + + async backgroundHandler( + context: EndpointContext, + _entries: BaseEndpointTypes['Parameters'][], + ): Promise { + await this.handleRequest(context) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(context: EndpointContext): Promise { + const { ATTESTER_API_URLS } = context.adapterSettings + const providerDataRequestedUnixMs = Date.now() + const attesterUrls = parseUrls(ATTESTER_API_URLS || '') + + try { + logger.info(`Fetching CBTC supply from ${attesterUrls.length} attesters`) + + // Query each attester in parallel + const results = await Promise.allSettled( + attesterUrls.map(async (attesterUrl) => { + const url = buildUrl(attesterUrl, '/app/get-total-cbtc-supply') + const response = await this.requester.request(url, { url }) + return BigInt(calculateAttesterSupply(response.response.data)) + }), + ) + + // Collect successful supply values + const supplies: bigint[] = [] + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + supplies.push(result.value) + logger.debug(`Attester ${i + 1}: ${result.value}`) + } else { + logger.warn(`Attester ${i + 1} failed: ${result.reason}`) + } + }) + + if (supplies.length < 1) { + throw new Error('No successful attester responses') + } + + const medianSupply = medianBigInt(supplies) + const result = medianSupply.toString() + + logger.info( + `Supply complete: median=${result} (${supplies.length}/${attesterUrls.length} attesters)`, + ) + + await this.responseCache.write(this.name, [ + { + params: {}, + response: { + result, + data: { result }, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }, + }, + ]) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Supply calculation failed: ${errorMessage}`) + + await this.responseCache.write(this.name, [ + { + params: {}, + response: { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + }, + }, + ]) + } + } + + getSubscriptionTtlFromConfig(adapterSettings: typeof config.settings): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const transport = new AttesterSupplyTransport() diff --git a/packages/sources/dlc-cbtc-por/src/transport/da-supply.ts b/packages/sources/dlc-cbtc-por/src/transport/da-supply.ts new file mode 100644 index 0000000000..fd3f97e41e --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/transport/da-supply.ts @@ -0,0 +1,43 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports' +import { BaseEndpointTypes } from '../endpoint/da-supply' +import { calculateDaSupply } from '../lib/da-supply' +import { DaResponse } from '../types' + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: DaResponse + } +} + +export const transport = new HttpTransport({ + prepareRequests: (params, config) => + params.map((param) => ({ + params: [param], + request: { + baseURL: config.CANTON_API_URL, + method: 'GET', + }, + })), + parseResponse: (params, res) => + params.map((param) => { + try { + const result = calculateDaSupply(res.data.instruments) + return { + params: param, + response: { + result, + data: { result }, + }, + } + } catch (error) { + return { + params: param, + response: { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + statusCode: 502, + }, + } + } + }), +}) diff --git a/packages/sources/dlc-cbtc-por/src/transport/reserves.ts b/packages/sources/dlc-cbtc-por/src/transport/reserves.ts new file mode 100644 index 0000000000..48ab2237aa --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/transport/reserves.ts @@ -0,0 +1,128 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { config } from '../config' +import { BaseEndpointTypes } from '../endpoint/reserves' +import { calculateReserves, fetchAndCalculateVaultAddresses } from '../lib/btc' +import { medianBigInt, parseUrls } from '../utils' + +const logger = makeLogger('BtcPorTransport') + +class BtcPorTransport extends SubscriptionTransport { + requester!: Requester + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.requester = dependencies.requester + } + + async backgroundHandler( + context: EndpointContext, + _entries: BaseEndpointTypes['Parameters'][], + ): Promise { + await this.handleRequest(context) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(context: EndpointContext): Promise { + const { ATTESTER_API_URLS, CHAIN_NAME, BITCOIN_RPC_ENDPOINT } = context.adapterSettings + const MIN_CONFIRMATIONS = 1 + + const providerDataRequestedUnixMs = Date.now() + const attesterUrls = parseUrls(ATTESTER_API_URLS) + + try { + logger.info( + `Starting PoR calculation for chain: ${CHAIN_NAME} with ${attesterUrls.length} attesters`, + ) + + // Query each attester and calculate reserves independently + const results = await Promise.allSettled( + attesterUrls.map(async (attesterUrl) => { + const { addresses } = await fetchAndCalculateVaultAddresses( + this.requester, + attesterUrl, + CHAIN_NAME, + ) + if (addresses.length === 0) { + throw new Error(`No vault addresses found for chain: ${CHAIN_NAME}`) + } + return calculateReserves( + this.requester, + BITCOIN_RPC_ENDPOINT, + addresses, + MIN_CONFIRMATIONS, + ) + }), + ) + + // Collect successful reserves calculations + const reserves: bigint[] = [] + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + reserves.push(result.value) + logger.debug(`Attester ${i + 1}: ${result.value} sats`) + } else { + logger.warn(`Attester ${i + 1} failed: ${result.reason}`) + } + }) + + if (reserves.length < 1) { + throw new Error('No successful attester responses') + } + + const medianReserves = medianBigInt(reserves) + const totalReserves = medianReserves.toString() + + logger.info( + `PoR complete: median=${totalReserves} sats (${reserves.length}/${attesterUrls.length} attesters)`, + ) + + await this.responseCache.write(this.name, [ + { + params: {}, + response: { + result: totalReserves, + data: { result: totalReserves }, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }, + }, + ]) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`PoR calculation failed: ${errorMessage}`) + + await this.responseCache.write(this.name, [ + { + params: {}, + response: { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + }, + }, + ]) + } + } + + getSubscriptionTtlFromConfig(adapterSettings: typeof config.settings): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const transport = new BtcPorTransport() diff --git a/packages/sources/dlc-cbtc-por/src/types.ts b/packages/sources/dlc-cbtc-por/src/types.ts new file mode 100644 index 0000000000..585d26c479 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/types.ts @@ -0,0 +1,35 @@ +/** + * Type definitions for the Canton PoR APIs + */ + +/** + * String response type to handle values beyond Number.MAX_SAFE_INTEGER. + */ +export type StringResultResponse = { + Data: { result: string } + Result: string +} + +/** Instrument data from the Digital Asset API */ +export interface Instrument { + id: string + name: string + symbol: string + totalSupply: string + totalSupplyAsOf: string | null + decimals: number + supportedApis: Record +} + +/** Digital Asset API response structure */ +export interface DaResponse { + instruments: Instrument[] + nextPageToken: string | null +} + +/** Attester API response structure */ +export interface AttesterResponse { + status: string + total_supply_cbtc: string + last_updated: string +} diff --git a/packages/sources/dlc-cbtc-por/src/utils.ts b/packages/sources/dlc-cbtc-por/src/utils.ts new file mode 100644 index 0000000000..86b64420e0 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/src/utils.ts @@ -0,0 +1,52 @@ +/** + * Shared utilities for Canton PoR adapters + */ + +/** + * Builds a URL by appending a path to a base endpoint. + * Properly handles base URLs that already contain query parameters. + */ +export function buildUrl(baseEndpoint: string, path: string): string { + const url = new URL(baseEndpoint) + url.pathname = url.pathname.replace(/\/$/, '') + path + return url.toString() +} + +/** + * Parses a decimal string and scales it to an integer with the given precision. + * Throws on invalid input - never returns default values. + * + * @example + * parseDecimalString("11.7127388", 10) → 117127388000n + * parseDecimalString("100", 8) → 10000000000n + */ +export function parseDecimalString(value: string, decimals: number): bigint { + const [whole, frac = ''] = value.split('.') + return BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals)) +} + +/** + * Parse comma-separated URLs into an array, trimming whitespace. + */ +export function parseUrls(urlsString: string): string[] { + return urlsString + .split(',') + .map((url) => url.trim()) + .filter((url) => url.length > 0) +} + +/** + * Calculate median of BigInt values. + * Returns the middle value for odd-length arrays, or the lower of two middle values for even-length. + * + * Note: For Proof of Reserves, the lower middle is more conservative - under-reporting + * reserves is safer than over-reporting collateral. + */ +export function medianBigInt(values: bigint[]): bigint { + if (values.length === 0) { + throw new Error('Cannot calculate median of empty array') + } + + const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + return sorted[Math.ceil(sorted.length / 2) - 1] +} diff --git a/packages/sources/dlc-cbtc-por/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/dlc-cbtc-por/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..3d6d455835 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLC CBTC PoR Adapter attesterSupply endpoint should return success 1`] = ` +{ + "data": { + "result": "78998232600", + }, + "result": "78998232600", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`DLC CBTC PoR Adapter attesterSupply endpoint should use attesterSupply as default endpoint 1`] = ` +{ + "data": { + "result": "78998232600", + }, + "result": "78998232600", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`DLC CBTC PoR Adapter daSupply endpoint should return success 1`] = ` +{ + "data": { + "result": "210000001234567890", + }, + "result": "210000001234567890", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`DLC CBTC PoR Adapter reserves endpoint should return success 1`] = ` +{ + "data": { + "result": "100000000", + }, + "result": "100000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; diff --git a/packages/sources/dlc-cbtc-por/test/integration/adapter.test.ts b/packages/sources/dlc-cbtc-por/test/integration/adapter.test.ts new file mode 100644 index 0000000000..57e1f99aea --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/integration/adapter.test.ts @@ -0,0 +1,76 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { + MOCK_ATTESTER_API_URLS, + MOCK_BITCOIN_RPC_URL, + MOCK_DA_API_URL, + mockAllApis, +} from './fixtures' + +describe('DLC CBTC PoR Adapter', () => { + let spy: jest.SpyInstance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.CANTON_API_URL = `${MOCK_DA_API_URL}/instruments` + process.env.ATTESTER_API_URLS = MOCK_ATTESTER_API_URLS + process.env.BITCOIN_RPC_ENDPOINT = MOCK_BITCOIN_RPC_URL + process.env.CHAIN_NAME = 'canton-mainnet' + process.env.BACKGROUND_EXECUTE_MS = '10000' + + const mockDate = new Date('2024-01-01T12:00:00.000Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + mockAllApis() + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('attesterSupply endpoint', () => { + it('should return success', async () => { + const response = await testAdapter.request({ endpoint: 'attesterSupply' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should use attesterSupply as default endpoint', async () => { + const response = await testAdapter.request({}) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) + + describe('daSupply endpoint', () => { + it('should return success', async () => { + const response = await testAdapter.request({ endpoint: 'daSupply' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) + + describe('reserves endpoint', () => { + it('should return success', async () => { + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/test/integration/fixtures.ts b/packages/sources/dlc-cbtc-por/test/integration/fixtures.ts new file mode 100644 index 0000000000..b10d03d999 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/integration/fixtures.ts @@ -0,0 +1,116 @@ +import nock from 'nock' + +// API URLs +export const MOCK_DA_API_URL = 'https://test.digitalasset.api' +export const MOCK_ATTESTER_API_URL_1 = 'https://test.attester1.api' +export const MOCK_ATTESTER_API_URL_2 = 'https://test.attester2.api' +export const MOCK_ATTESTER_API_URLS = `${MOCK_ATTESTER_API_URL_1},${MOCK_ATTESTER_API_URL_2}` +export const MOCK_BITCOIN_RPC_URL = 'https://test.bitcoin.rpc' + +// Real data from attester API to ensure address verification passes in integration tests +export const MOCK_XPUB = + 'xpub6GRcASRzGcNLhsaTsX28aV1JmNZSdUFKHsXgSjwX4ykk8X3j58gznGf73mBe1k35A69K7JNZfwZhmQHjZGd8f5ine2ztkbW3yiayRHFRFKL' +export const MOCK_DEPOSIT_ID = + '000da3e972f71100337b6039740625861997098d4f4785c7cf0f88286a0208fe60ca111220c31237da239a2317724046f46cfab8d50076a2a0314874c773db3c43ddf910c2' +export const MOCK_VAULT_ADDRESS = 'bc1p8zuluqy8pza2pt6sj7lnl5esjk9t2e4h2k6jna7dtmyhhxd8uv0qp3tw9x' + +// Mock responses +export const MOCK_BLOCK_HEIGHT = 880000 +export const MOCK_UTXO_VALUE = 100000000 // 1 BTC in sats + +export const mockDaApiResponse = (): nock.Scope => + nock(MOCK_DA_API_URL) + .get('/instruments') + .reply(200, { + instruments: [ + { + id: 'CBTC', + name: 'CBTC', + symbol: 'CBTC', + totalSupply: '21000000.1234567890', + totalSupplyAsOf: null, + decimals: 10, + supportedApis: {}, + }, + ], + nextPageToken: null, + }) + .persist() + +export const mockAttesterSupplyResponse = (url: string, supply = '7.899823260000001'): void => { + nock(url).persist().get('/app/get-total-cbtc-supply').reply(200, { + status: 'ready', + total_supply_cbtc: supply, + last_updated: '2025-01-01T00:00:00.000Z', + }) +} + +export const mockAttesterAddressResponse = (url: string): void => { + nock(url) + .persist() + .get('/app/get-address-calculation-data') + .reply(200, { + status: 'ready', + bitcoin_network: 'bitcoin', + chains: [ + { + chain: 'canton-mainnet', + xpub: MOCK_XPUB, + addresses: [ + { + id: MOCK_DEPOSIT_ID, + address_for_verification: MOCK_VAULT_ADDRESS, + }, + ], + }, + ], + last_updated: '2025-01-01T00:00:00.000Z', + }) +} + +export const mockBitcoinRpcResponses = (): void => { + // Block height + nock(MOCK_BITCOIN_RPC_URL) + .persist() + .get('/blocks/tip/height') + .reply(200, String(MOCK_BLOCK_HEIGHT)) + + // UTXOs for vault address - confirmed with sufficient confirmations + nock(MOCK_BITCOIN_RPC_URL) + .persist() + .get(`/address/${MOCK_VAULT_ADDRESS}/utxo`) + .reply(200, [ + { + txid: 'abc123def456789012345678901234567890123456789012345678901234abcd', + vout: 0, + value: MOCK_UTXO_VALUE, + status: { + confirmed: true, + block_height: MOCK_BLOCK_HEIGHT - 10, // 11 confirmations + }, + }, + ]) + + // Empty mempool for vault address + nock(MOCK_BITCOIN_RPC_URL) + .persist() + .get(`/address/${MOCK_VAULT_ADDRESS}/txs/mempool`) + .reply(200, []) +} + +export const mockAttesterApiResponse = (): void => { + mockAttesterSupplyResponse(MOCK_ATTESTER_API_URL_1) + mockAttesterSupplyResponse(MOCK_ATTESTER_API_URL_2) +} + +export const mockReservesApis = (): void => { + mockAttesterAddressResponse(MOCK_ATTESTER_API_URL_1) + mockAttesterAddressResponse(MOCK_ATTESTER_API_URL_2) + mockBitcoinRpcResponses() +} + +export const mockAllApis = (): void => { + mockDaApiResponse() + mockAttesterApiResponse() + mockReservesApis() +} diff --git a/packages/sources/dlc-cbtc-por/test/unit/attester-supply.test.ts b/packages/sources/dlc-cbtc-por/test/unit/attester-supply.test.ts new file mode 100644 index 0000000000..3b931eea9d --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/unit/attester-supply.test.ts @@ -0,0 +1,92 @@ +import { calculateAttesterSupply } from '../../src/lib/attester-supply' +import { AttesterResponse } from '../../src/types' + +const createResponse = (overrides: Partial = {}): AttesterResponse => ({ + status: 'ready', + total_supply_cbtc: '7.899823260000001', + last_updated: '2025-12-19T17:04:07.328982+00:00', + ...overrides, +}) + +describe('Attester Supply', () => { + describe('calculateAttesterSupply', () => { + it('should calculate supply from API response', () => { + const response = createResponse() + expect(calculateAttesterSupply(response)).toBe('78998232600') + }) + + it('should handle whole number supply', () => { + const response = createResponse({ total_supply_cbtc: '100' }) + expect(calculateAttesterSupply(response)).toBe('1000000000000') + }) + + it('should handle zero supply', () => { + const response = createResponse({ total_supply_cbtc: '0' }) + expect(calculateAttesterSupply(response)).toBe('0') + }) + + it('should handle very small supply values', () => { + const response = createResponse({ total_supply_cbtc: '0.0000000001' }) + expect(calculateAttesterSupply(response)).toBe('1') + }) + + it('should handle large supply without precision loss', () => { + const response = createResponse({ total_supply_cbtc: '21000000' }) + expect(calculateAttesterSupply(response)).toBe('210000000000000000') + }) + + it('should handle max supply with full decimal precision', () => { + const response = createResponse({ total_supply_cbtc: '21000000.9999999999' }) + expect(calculateAttesterSupply(response)).toBe('210000009999999999') + }) + + it('should truncate excess decimal places', () => { + const response = createResponse({ total_supply_cbtc: '1.123456789012345' }) + expect(calculateAttesterSupply(response)).toBe('11234567890') + }) + + it('should truncate not round - value ending in 9s', () => { + const response = createResponse({ total_supply_cbtc: '1.99999999999' }) + expect(calculateAttesterSupply(response)).toBe('19999999999') + }) + + it('should throw when status is not ready', () => { + const response = createResponse({ status: 'pending' }) + expect(() => calculateAttesterSupply(response)).toThrow('Attester not ready: status=pending') + }) + + it('should throw when status is initializing', () => { + const response = createResponse({ status: 'initializing' }) + expect(() => calculateAttesterSupply(response)).toThrow('Attester not ready') + }) + + it('should throw when total_supply_cbtc is empty string', () => { + const response = createResponse({ total_supply_cbtc: '' }) + expect(() => calculateAttesterSupply(response)).toThrow( + 'total_supply_cbtc is missing or empty', + ) + }) + + it('should throw when total_supply_cbtc is whitespace only', () => { + const response = createResponse({ total_supply_cbtc: ' ' }) + expect(() => calculateAttesterSupply(response)).toThrow( + 'total_supply_cbtc is missing or empty', + ) + }) + + it('should throw when total_supply_cbtc is null', () => { + const response = createResponse({ total_supply_cbtc: null as unknown as string }) + expect(() => calculateAttesterSupply(response)).toThrow() + }) + + it('should throw when total_supply_cbtc is undefined', () => { + const response = createResponse({ total_supply_cbtc: undefined as unknown as string }) + expect(() => calculateAttesterSupply(response)).toThrow() + }) + + it('should throw when total_supply_cbtc is invalid numeric format', () => { + const response = createResponse({ total_supply_cbtc: 'invalid' }) + expect(() => calculateAttesterSupply(response)).toThrow() + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/test/unit/btc-address.test.ts b/packages/sources/dlc-cbtc-por/test/unit/btc-address.test.ts new file mode 100644 index 0000000000..1fc877c2b7 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/unit/btc-address.test.ts @@ -0,0 +1,101 @@ +import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import BIP32Factory from 'bip32' +import * as bitcoin from 'bitcoinjs-lib' +import * as ecc from 'tiny-secp256k1' +import { + calculateAndVerifyAddresses, + calculateTaprootAddress, + getBitcoinNetwork, +} from '../../src/lib/btc/address' +import { ChainAddressGroup } from '../../src/lib/btc/types' + +const bip32 = BIP32Factory(ecc) + +// Initialize logger for functions that use makeLogger +LoggerFactoryProvider.set() + +describe('BTC Address Calculation', () => { + describe('getBitcoinNetwork', () => { + it('should return bitcoin mainnet for "mainnet"', () => { + expect(getBitcoinNetwork('mainnet')).toBe(bitcoin.networks.bitcoin) + }) + + it('should return bitcoin mainnet for "bitcoin"', () => { + expect(getBitcoinNetwork('bitcoin')).toBe(bitcoin.networks.bitcoin) + }) + + it('should return testnet for "testnet"', () => { + expect(getBitcoinNetwork('testnet')).toBe(bitcoin.networks.testnet) + }) + + it('should return regtest for "regtest"', () => { + expect(getBitcoinNetwork('regtest')).toBe(bitcoin.networks.regtest) + }) + + it('should be case insensitive', () => { + expect(getBitcoinNetwork('MAINNET')).toBe(bitcoin.networks.bitcoin) + expect(getBitcoinNetwork('Testnet')).toBe(bitcoin.networks.testnet) + }) + + it('should throw for unknown network', () => { + expect(() => getBitcoinNetwork('unknown')).toThrow('Unknown Bitcoin network: unknown') + }) + }) + + describe('calculateTaprootAddress', () => { + const xOnlyPubkey = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'.slice(2), + 'hex', + ) + + it('should produce a valid testnet P2TR address', () => { + const address = calculateTaprootAddress( + 'test-deposit-id', + xOnlyPubkey, + bitcoin.networks.testnet, + ) + expect(address).toMatch(/^tb1p[a-z0-9]+$/) + }) + + it('should produce a valid mainnet P2TR address', () => { + const address = calculateTaprootAddress( + 'test-deposit-id', + xOnlyPubkey, + bitcoin.networks.bitcoin, + ) + expect(address).toMatch(/^bc1p[a-z0-9]+$/) + }) + + it('should produce different addresses for different deposit IDs', () => { + const address1 = calculateTaprootAddress('deposit-1', xOnlyPubkey, bitcoin.networks.testnet) + const address2 = calculateTaprootAddress('deposit-2', xOnlyPubkey, bitcoin.networks.testnet) + expect(address1).not.toBe(address2) + }) + }) + + describe('calculateAndVerifyAddresses', () => { + it('should throw when calculated address does not match verification', () => { + const testKey = bip32.fromSeed(Buffer.alloc(32, 1), bitcoin.networks.testnet) + const chainGroup: ChainAddressGroup = { + chain: 'testnet', + xpub: testKey.neutered().toBase58(), + addresses: [{ id: 'test-deposit-id', address_for_verification: 'tb1pwrongaddress' }], + } + + expect(() => calculateAndVerifyAddresses(chainGroup, bitcoin.networks.testnet)).toThrow( + /Address verification failed/, + ) + }) + + it('should return empty array for chain with no addresses', () => { + const testKey = bip32.fromSeed(Buffer.alloc(32, 1), bitcoin.networks.testnet) + const chainGroup: ChainAddressGroup = { + chain: 'testnet', + xpub: testKey.neutered().toBase58(), + addresses: [], + } + + expect(calculateAndVerifyAddresses(chainGroup, bitcoin.networks.testnet)).toEqual([]) + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/test/unit/btc-reserves.test.ts b/packages/sources/dlc-cbtc-por/test/unit/btc-reserves.test.ts new file mode 100644 index 0000000000..5a5b1dff5d --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/unit/btc-reserves.test.ts @@ -0,0 +1,154 @@ +import { + getConfirmations, + hasMinConfirmations, + sumConfirmedUtxos, + sumPendingSpendInputs, +} from '../../src/lib/btc/por' +import { MempoolTransaction, UTXO } from '../../src/lib/btc/types' + +const createUtxo = (overrides: Partial = {}): UTXO => ({ + txid: 'abc123', + vout: 0, + value: 1000000, + status: { confirmed: true, block_height: 995 }, + ...overrides, +}) + +describe('BTC Reserves Calculation', () => { + describe('getConfirmations', () => { + it('should return correct confirmation count', () => { + const utxo = createUtxo({ status: { confirmed: true, block_height: 995 } }) + expect(getConfirmations(utxo, 1000)).toBe(6) // 1000 - 995 + 1 + }) + + it('should return 0 for unconfirmed UTXO', () => { + const utxo = createUtxo({ status: { confirmed: false } }) + expect(getConfirmations(utxo, 1000)).toBe(0) + }) + + it('should return 0 for UTXO with missing block_height', () => { + const utxo = createUtxo({ status: { confirmed: true } }) + expect(getConfirmations(utxo, 1000)).toBe(0) + }) + + it('should return 1 for UTXO at current block height', () => { + const utxo = createUtxo({ status: { confirmed: true, block_height: 1000 } }) + expect(getConfirmations(utxo, 1000)).toBe(1) + }) + }) + + describe('hasMinConfirmations', () => { + it('should return true when confirmations meet minimum', () => { + const utxo = createUtxo({ status: { confirmed: true, block_height: 995 } }) + expect(hasMinConfirmations(utxo, 1000, 6)).toBe(true) + }) + + it('should return true when confirmations exceed minimum', () => { + const utxo = createUtxo({ status: { confirmed: true, block_height: 990 } }) + expect(hasMinConfirmations(utxo, 1000, 6)).toBe(true) + }) + + it('should return false when confirmations below minimum', () => { + const utxo = createUtxo({ status: { confirmed: true, block_height: 996 } }) + expect(hasMinConfirmations(utxo, 1000, 6)).toBe(false) + }) + + it('should return false for unconfirmed UTXO', () => { + const utxo = createUtxo({ status: { confirmed: false } }) + expect(hasMinConfirmations(utxo, 1000, 6)).toBe(false) + }) + }) + + describe('sumConfirmedUtxos', () => { + it('should sum only UTXOs with sufficient confirmations', () => { + const utxos: UTXO[] = [ + createUtxo({ txid: 'tx1', value: 1000000, status: { confirmed: true, block_height: 995 } }), // 6 conf ✓ + createUtxo({ txid: 'tx2', value: 2000000, status: { confirmed: true, block_height: 996 } }), // 5 conf ✗ + createUtxo({ txid: 'tx3', value: 3000000, status: { confirmed: true, block_height: 994 } }), // 7 conf ✓ + ] + expect(sumConfirmedUtxos(utxos, 1000, 6)).toBe(4000000n) + }) + + it('should exclude unconfirmed UTXOs', () => { + const utxos: UTXO[] = [ + createUtxo({ value: 5000000, status: { confirmed: true, block_height: 990 } }), + createUtxo({ value: 3000000, status: { confirmed: false } }), + ] + expect(sumConfirmedUtxos(utxos, 1000, 6)).toBe(5000000n) + }) + + it('should return 0n for empty UTXO list', () => { + expect(sumConfirmedUtxos([], 1000, 6)).toBe(0n) + }) + }) + + describe('sumPendingSpendInputs', () => { + const targetAddress = 'bc1ptest1234567890' + + it('should sum pending spend inputs from the target address', () => { + const mempoolTxs: MempoolTransaction[] = [ + { + txid: 'pending_tx', + vin: [ + { + txid: 'original_tx', + vout: 0, + prevout: { scriptpubkey_address: targetAddress, value: 5000000 }, + }, + ], + vout: [], + }, + ] + expect(sumPendingSpendInputs(mempoolTxs, targetAddress)).toBe(5000000n) + }) + + it('should ignore inputs from other addresses', () => { + const mempoolTxs: MempoolTransaction[] = [ + { + txid: 'pending_tx', + vin: [ + { + txid: 'other_tx', + vout: 0, + prevout: { scriptpubkey_address: 'bc1pdifferent', value: 10000000 }, + }, + ], + vout: [], + }, + ] + expect(sumPendingSpendInputs(mempoolTxs, targetAddress)).toBe(0n) + }) + + it('should sum across multiple transactions', () => { + const mempoolTxs: MempoolTransaction[] = [ + { + txid: 'tx1', + vin: [ + { + txid: 'a', + vout: 0, + prevout: { scriptpubkey_address: targetAddress, value: 3000000 }, + }, + ], + vout: [], + }, + { + txid: 'tx2', + vin: [ + { + txid: 'b', + vout: 0, + prevout: { scriptpubkey_address: targetAddress, value: 2000000 }, + }, + ], + vout: [], + }, + ] + expect(sumPendingSpendInputs(mempoolTxs, targetAddress)).toBe(5000000n) + }) + + it('should return 0n for empty mempool', () => { + expect(sumPendingSpendInputs([], targetAddress)).toBe(0n) + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/test/unit/da-supply.test.ts b/packages/sources/dlc-cbtc-por/test/unit/da-supply.test.ts new file mode 100644 index 0000000000..c95e27b309 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/unit/da-supply.test.ts @@ -0,0 +1,123 @@ +import { calculateDaSupply } from '../../src/lib/da-supply' +import { Instrument } from '../../src/types' + +const createInstrument = (overrides: Partial = {}): Instrument => ({ + id: 'CBTC', + name: 'CBTC', + symbol: 'CBTC', + totalSupply: '0', + totalSupplyAsOf: null, + decimals: 10, + supportedApis: {}, + ...overrides, +}) + +describe('DA Supply', () => { + describe('calculateDaSupply', () => { + it('should calculate supply from API response', () => { + const instruments = [createInstrument({ totalSupply: '11.7127388' })] + expect(calculateDaSupply(instruments)).toBe('117127388000') + }) + + it('should handle different decimal configurations', () => { + const instruments = [createInstrument({ totalSupply: '5.5', decimals: 8 })] + expect(calculateDaSupply(instruments)).toBe('550000000') + }) + + it('should handle whole number supply', () => { + const instruments = [createInstrument({ totalSupply: '100', decimals: 8 })] + expect(calculateDaSupply(instruments)).toBe('10000000000') + }) + + it('should handle zero supply', () => { + const instruments = [createInstrument({ totalSupply: '0' })] + expect(calculateDaSupply(instruments)).toBe('0') + }) + + it('should handle very small supply values', () => { + const instruments = [createInstrument({ totalSupply: '0.0000001' })] + expect(calculateDaSupply(instruments)).toBe('1000') + }) + + it('should handle large supply without precision loss', () => { + const instruments = [createInstrument({ totalSupply: '21000000' })] + expect(calculateDaSupply(instruments)).toBe('210000000000000000') + }) + + it('should handle max supply with full decimal precision', () => { + const instruments = [createInstrument({ totalSupply: '21000000.9999999999' })] + expect(calculateDaSupply(instruments)).toBe('210000009999999999') + }) + + it('should truncate excess decimal places', () => { + const instruments = [createInstrument({ totalSupply: '1.123456789012345' })] + expect(calculateDaSupply(instruments)).toBe('11234567890') + }) + + it('should find CBTC among multiple instruments', () => { + const instruments = [ + createInstrument({ symbol: 'OTHER', id: 'OTHER', totalSupply: '999' }), + createInstrument({ totalSupply: '25.5' }), + ] + expect(calculateDaSupply(instruments)).toBe('255000000000') + }) + + it('should throw when no instruments found', () => { + expect(() => calculateDaSupply([])).toThrow('No instruments found') + }) + + it('should throw when instruments is undefined', () => { + expect(() => calculateDaSupply(undefined as unknown as Instrument[])).toThrow( + 'No instruments found', + ) + }) + + it('should throw when instruments is null', () => { + expect(() => calculateDaSupply(null as unknown as Instrument[])).toThrow( + 'No instruments found', + ) + }) + + it('should throw when CBTC not found', () => { + const instruments = [createInstrument({ symbol: 'OTHER', id: 'OTHER', name: 'Other' })] + expect(() => calculateDaSupply(instruments)).toThrow('CBTC instrument not found') + }) + + it('should throw when CBTC totalSupply is empty string', () => { + const instruments = [createInstrument({ totalSupply: '' })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC totalSupply is whitespace only', () => { + const instruments = [createInstrument({ totalSupply: ' ' })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC totalSupply is invalid', () => { + const instruments = [createInstrument({ totalSupply: 'invalid' })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC totalSupply is null', () => { + const instruments = [createInstrument({ totalSupply: null as unknown as string })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC totalSupply is undefined', () => { + const instruments = [createInstrument({ totalSupply: undefined as unknown as string })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC decimals is undefined', () => { + const instruments = [ + createInstrument({ totalSupply: '100', decimals: undefined as unknown as number }), + ] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + + it('should throw when CBTC decimals is negative', () => { + const instruments = [createInstrument({ totalSupply: '100', decimals: -1 })] + expect(() => calculateDaSupply(instruments)).toThrow() + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/test/unit/utils.test.ts b/packages/sources/dlc-cbtc-por/test/unit/utils.test.ts new file mode 100644 index 0000000000..91a1cfa093 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/test/unit/utils.test.ts @@ -0,0 +1,106 @@ +import { buildUrl, medianBigInt, parseDecimalString, parseUrls } from '../../src/utils' + +describe('Utils', () => { + describe('buildUrl', () => { + it('should append path to simple base URL', () => { + expect(buildUrl('https://api.example.com', '/app/endpoint')).toBe( + 'https://api.example.com/app/endpoint', + ) + }) + + it('should handle trailing slash in base URL', () => { + expect(buildUrl('https://api.example.com/', '/app/endpoint')).toBe( + 'https://api.example.com/app/endpoint', + ) + }) + + it('should preserve query parameters', () => { + expect(buildUrl('https://api.example.com?auth=TOKEN', '/app/endpoint')).toBe( + 'https://api.example.com/app/endpoint?auth=TOKEN', + ) + }) + }) + + describe('parseDecimalString', () => { + it('should parse decimal with full precision', () => { + expect(parseDecimalString('11.7127388', 10)).toBe(117127388000n) + }) + + it('should parse whole number', () => { + expect(parseDecimalString('100', 8)).toBe(10000000000n) + }) + + it('should parse zero', () => { + expect(parseDecimalString('0', 10)).toBe(0n) + }) + }) + + describe('parseUrls', () => { + it('should parse single URL', () => { + expect(parseUrls('https://attester1.api')).toEqual(['https://attester1.api']) + }) + + it('should parse multiple comma-separated URLs', () => { + expect( + parseUrls('https://attester1.api,https://attester2.api,https://attester3.api'), + ).toEqual(['https://attester1.api', 'https://attester2.api', 'https://attester3.api']) + }) + + it('should trim whitespace around URLs', () => { + expect(parseUrls(' https://a.api , https://b.api ')).toEqual([ + 'https://a.api', + 'https://b.api', + ]) + }) + + it('should filter out empty strings', () => { + expect(parseUrls('https://a.api,,https://b.api,')).toEqual(['https://a.api', 'https://b.api']) + }) + + it('should return empty array for empty string', () => { + expect(parseUrls('')).toEqual([]) + }) + + it('should handle 5 URLs', () => { + const urls = 'https://a1.api,https://a2.api,https://a3.api,https://a4.api,https://a5.api' + expect(parseUrls(urls)).toHaveLength(5) + }) + }) + + describe('medianBigInt', () => { + it('should return single value for array of length 1', () => { + expect(medianBigInt([100n])).toBe(100n) + }) + + it('should return lower middle for array of length 2', () => { + expect(medianBigInt([100n, 200n])).toBe(100n) + }) + + it('should return middle value for odd-length array', () => { + expect(medianBigInt([100n, 200n, 300n])).toBe(200n) + }) + + it('should return lower middle for even-length array of 4', () => { + expect(medianBigInt([100n, 200n, 300n, 400n])).toBe(200n) + }) + + it('should correctly calculate median for 5 values', () => { + expect(medianBigInt([500n, 100n, 300n, 200n, 400n])).toBe(300n) + }) + + it('should sort values before calculating median', () => { + expect(medianBigInt([300n, 100n, 200n])).toBe(200n) + }) + + it('should throw for empty array', () => { + expect(() => medianBigInt([])).toThrow('Cannot calculate median of empty array') + }) + + it('should handle large BigInt values', () => { + const large1 = 50000000000000n + const large2 = 60000000000000n + const large3 = 70000000000000n + expect(medianBigInt([large3, large1, large2])).toBe(large2) + }) + }) +}) diff --git a/packages/sources/dlc-cbtc-por/tsconfig.json b/packages/sources/dlc-cbtc-por/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/dlc-cbtc-por/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/dlc-cbtc-por/tsconfig.test.json b/packages/sources/dlc-cbtc-por/tsconfig.test.json new file mode 100644 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/dlc-cbtc-por/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index af6c89b86d..2ca592eda1 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -254,6 +254,9 @@ { "path": "./sources/dlc-btc-por" }, + { + "path": "./sources/dlc-cbtc-por" + }, { "path": "./sources/dns-query" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 180b66a06d..77621753e2 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -254,6 +254,9 @@ { "path": "./sources/dlc-btc-por/tsconfig.test.json" }, + { + "path": "./sources/dlc-cbtc-por/tsconfig.test.json" + }, { "path": "./sources/dns-query/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index b28717c7fe..6c91775e8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3347,6 +3347,22 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/dlc-cbtc-por-adapter@workspace:packages/sources/dlc-cbtc-por": + version: 0.0.0-use.local + resolution: "@chainlink/dlc-cbtc-por-adapter@workspace:packages/sources/dlc-cbtc-por" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.11.4" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + bip32: "npm:^4.0.0" + bitcoinjs-lib: "npm:^6.1.7" + nock: "npm:13.5.6" + tiny-secp256k1: "npm:^2.2.4" + tslib: "npm:^2.3.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/dns-query-adapter@workspace:packages/sources/dns-query": version: 0.0.0-use.local resolution: "@chainlink/dns-query-adapter@workspace:packages/sources/dns-query" @@ -12303,7 +12319,7 @@ __metadata: languageName: node linkType: hard -"bip32@npm:4.0.0": +"bip32@npm:4.0.0, bip32@npm:^4.0.0": version: 4.0.0 resolution: "bip32@npm:4.0.0" dependencies: @@ -12368,7 +12384,7 @@ __metadata: languageName: node linkType: hard -"bitcoinjs-lib@npm:6.1.7": +"bitcoinjs-lib@npm:6.1.7, bitcoinjs-lib@npm:^6.1.7": version: 6.1.7 resolution: "bitcoinjs-lib@npm:6.1.7" dependencies: @@ -24306,6 +24322,15 @@ __metadata: languageName: node linkType: hard +"tiny-secp256k1@npm:^2.2.4": + version: 2.2.4 + resolution: "tiny-secp256k1@npm:2.2.4" + dependencies: + uint8array-tools: "npm:0.0.7" + checksum: 10/3f29104fcd6b17aefe8f3ce2fd3aa69cd2fb4b1c96e2e2f51d709dbea518705a9a706433c98d6f413f2d46555f4be0e1fd02b1727492a20d98d23ab848ff187d + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.6": version: 0.2.12 resolution: "tinyglobby@npm:0.2.12" @@ -24857,6 +24882,13 @@ __metadata: languageName: node linkType: hard +"uint8array-tools@npm:0.0.7": + version: 0.0.7 + resolution: "uint8array-tools@npm:0.0.7" + checksum: 10/6ffc45c7d2136757d63c6e556eb8345f908948618a9de37c805fec1249d989c265187b3fbef6cffc4ce5129083204829025b3c58800a0f24c8548e243d42ba13 + languageName: node + linkType: hard + "undici-types@npm:^7.15.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0"