From b2f76c90b42b9e92a132b3d3191f59b9710e0caf Mon Sep 17 00:00:00 2001 From: Antonio Ventilii <169057656+AntonioVentilii-DFINITY@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:26:58 +0200 Subject: [PATCH] feat: add getBalance to BTC package (#701) # Motivation Expose function getBalance, used to retrieve the balance of a Bitcoin address given a network and, optionally, a minimum number of confirmations. # Changes - Create params types (note that the endpoint returns `bigint` number identified by the type `satoshi`). - Create util function to parse "string" network type to "candid" network type. - Expose `get_balance` from the canister into new function `getBalance` of object `BitcoinCanister`. - Add tests. # Tests Create set of test mirroring the ones used for `getUtxos` function. --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/ckbtc/README.md | 24 ++++- packages/ckbtc/src/bitcoin.canister.spec.ts | 103 +++++++++++++++++++- packages/ckbtc/src/bitcoin.canister.ts | 31 +++++- packages/ckbtc/src/types/bitcoin.params.ts | 29 +++++- 5 files changed, 181 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c832817..c1510b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Provide a new utility to convert Candid `Nat` to `BigInt`. This utility is useful for interpreting the fees provided by the SNS Aggregator. - Support conversion of `InstallCode`, `StopOrStartCanister` and `UpdateCanisterSettings` actions, `SetVisibility` neuron operation, and `Neuron::visibility` attribute. +- Add function `getBalance` to `BitcoinCanister` object of package `@dfinity/ckbtc`, that implements the `bitcoin_get_balance` method of the IC Bitcoin API. ## Build diff --git a/packages/ckbtc/README.md b/packages/ckbtc/README.md index 2d3f6a55..d6ed6c39 100644 --- a/packages/ckbtc/README.md +++ b/packages/ckbtc/README.md @@ -276,12 +276,13 @@ Parameters: ### :factory: BitcoinCanister -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L11) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L17) #### Methods - [create](#gear-create) - [getUtxos](#gear-getutxos) +- [getBalance](#gear-getbalance) ##### :gear: create @@ -289,7 +290,7 @@ Parameters: | -------- | -------------------------------------------------------------- | | `create` | `(options: CkBTCCanisterOptions<_SERVICE>) => BitcoinCanister` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L12) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L18) ##### :gear: getUtxos @@ -306,7 +307,24 @@ Parameters: - `params.address`: A Bitcoin address. - `params.certified`: query or update call -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L35) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L41) + +##### :gear: getBalance + +Given a `get_balance_request`, which must specify a Bitcoin address and a Bitcoin network (`mainnet` or `testnet`), the function returns the current balance of this address in `Satoshi` (10^8 Satoshi = 1 Bitcoin) in the specified Bitcoin network. + +| Method | Type | +| ------------ | ----------------------------------------------------------------- | +| `getBalance` | `({ certified, ...params }: GetBalanceParams) => Promise` | + +Parameters: + +- `params.network`: Tesnet or mainnet. +- `params.min_confirmations`: The optional filter parameter can be used to limit the set of considered UTXOs for the calculation of the balance to those with at least the provided number of confirmations in the same manner as for the `bitcoin_get_utxos` call. +- `params.address`: A Bitcoin address. +- `params.certified`: query or update call + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/bitcoin.canister.ts#L64) diff --git a/packages/ckbtc/src/bitcoin.canister.spec.ts b/packages/ckbtc/src/bitcoin.canister.spec.ts index 14c2c12b..04144ab9 100644 --- a/packages/ckbtc/src/bitcoin.canister.spec.ts +++ b/packages/ckbtc/src/bitcoin.canister.spec.ts @@ -5,10 +5,11 @@ import { mock } from "jest-mock-extended"; import type { _SERVICE as BitcoinService, get_utxos_response, + satoshi, } from "../candid/bitcoin"; import { BitcoinCanister } from "./bitcoin.canister"; import { bitcoinCanisterIdMock } from "./mocks/minter.mock"; -import { GetUtxosParams } from "./types/bitcoin.params"; +import { GetBalanceParams, GetUtxosParams } from "./types/bitcoin.params"; describe("BitcoinCanister", () => { const createBitcoinCanister = ( @@ -143,4 +144,104 @@ describe("BitcoinCanister", () => { }); }); }); + + describe("bitcoinGetBalance", () => { + const params: Omit = { + network: "testnet", + min_confirmations: 2, + address: bitcoinAddressMock, + }; + + const response: satoshi = 1000n; + + describe("certified", () => { + it("returns balance result when success", async () => { + const certifiedService = mock>(); + certifiedService.bitcoin_get_balance.mockResolvedValue(response); + + const service = mock>(); + + const { getBalance } = await createBitcoinCanister({ + certifiedServiceOverride: certifiedService, + serviceOverride: service, + }); + + const res = await getBalance({ + ...params, + certified: true, + }); + + expect(res).toEqual(response); + expect(certifiedService.bitcoin_get_balance).toHaveBeenCalledWith({ + network: { testnet: null }, + min_confirmations: [2], + address: bitcoinAddressMock, + }); + expect(service.bitcoin_get_balance_query).not.toHaveBeenCalled(); + }); + + it("throws Error", async () => { + const error = new Error("Test"); + const certifiedService = mock>(); + certifiedService.bitcoin_get_balance.mockRejectedValue(error); + + const { getBalance } = await createBitcoinCanister({ + certifiedServiceOverride: certifiedService, + }); + + const call = () => + getBalance({ + ...params, + certified: true, + }); + + expect(call).rejects.toThrowError(Error); + }); + }); + + describe("Not certified", () => { + it("returns balance query result when success", async () => { + const service = mock>(); + service.bitcoin_get_balance_query.mockResolvedValue(response); + + const certifiedService = mock>(); + + const { getBalance } = await createBitcoinCanister({ + certifiedServiceOverride: certifiedService, + serviceOverride: service, + }); + + const res = await getBalance({ + ...params, + certified: false, + }); + + expect(res).toEqual(response); + expect(service.bitcoin_get_balance_query).toHaveBeenCalledWith({ + network: { testnet: null }, + min_confirmations: [2], + address: bitcoinAddressMock, + }); + expect(certifiedService.bitcoin_get_balance).not.toHaveBeenCalled(); + }); + + it("throws Error", async () => { + const error = new Error("Test"); + const service = mock>(); + service.bitcoin_get_balance_query.mockRejectedValue(error); + + const { getBalance } = await createBitcoinCanister({ + serviceOverride: service, + }); + + const call = () => + getBalance({ + ...params, + certified: false, + }); + + expect(call).rejects.toThrowError(Error); + }); + }); + }); }); diff --git a/packages/ckbtc/src/bitcoin.canister.ts b/packages/ckbtc/src/bitcoin.canister.ts index 7afedce6..600e90c6 100644 --- a/packages/ckbtc/src/bitcoin.canister.ts +++ b/packages/ckbtc/src/bitcoin.canister.ts @@ -2,10 +2,16 @@ import { Canister, createServices } from "@dfinity/utils"; import type { _SERVICE as BitcoinService, get_utxos_response, + satoshi, } from "../candid/bitcoin"; import { idlFactory as certifiedIdlFactory } from "../candid/bitcoin.certified.idl"; import { idlFactory } from "../candid/bitcoin.idl"; -import { toGetUtxosParams, type GetUtxosParams } from "./types/bitcoin.params"; +import { + toGetBalanceParams, + toGetUtxosParams, + type GetBalanceParams, + type GetUtxosParams, +} from "./types/bitcoin.params"; import type { CkBTCCanisterOptions } from "./types/canister.options"; export class BitcoinCanister extends Canister { @@ -42,4 +48,27 @@ export class BitcoinCanister extends Canister { const fn = certified ? bitcoin_get_utxos : bitcoin_get_utxos_query; return fn(toGetUtxosParams(params)); }; + + /** + * Given a `get_balance_request`, which must specify a Bitcoin address and a Bitcoin network (`mainnet` or `testnet`), the function returns the current balance of this address in `Satoshi` (10^8 Satoshi = 1 Bitcoin) in the specified Bitcoin network. + * + * @link https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-bitcoin_get_balance + * + * @param {Object} params + * @param {BitcoinNetwork} params.network Tesnet or mainnet. + * @param {Object} params.min_confirmations The optional filter parameter can be used to limit the set of considered UTXOs for the calculation of the balance to those with at least the provided number of confirmations in the same manner as for the `bitcoin_get_utxos` call. + * @param {string} params.address A Bitcoin address. + * @param {boolean} params.certified query or update call + * @returns {Promise} The balance is returned in `Satoshi` (10^8 Satoshi = 1 Bitcoin). + */ + getBalance = ({ + certified = true, + ...params + }: GetBalanceParams): Promise => { + const { bitcoin_get_balance, bitcoin_get_balance_query } = this.caller({ + certified, + }); + const fn = certified ? bitcoin_get_balance : bitcoin_get_balance_query; + return fn(toGetBalanceParams(params)); + }; } diff --git a/packages/ckbtc/src/types/bitcoin.params.ts b/packages/ckbtc/src/types/bitcoin.params.ts index ae9069ed..7a4d4104 100644 --- a/packages/ckbtc/src/types/bitcoin.params.ts +++ b/packages/ckbtc/src/types/bitcoin.params.ts @@ -1,8 +1,15 @@ import { toNullable, type QueryParams } from "@dfinity/utils"; -import type { get_utxos_request } from "../../candid/bitcoin"; +import type { + get_balance_request, + get_utxos_request, + network, +} from "../../candid/bitcoin"; export type BitcoinNetwork = "testnet" | "mainnet"; +const mapBitcoinNetwork = (network: BitcoinNetwork): network => + network === "testnet" ? { testnet: null } : { mainnet: null }; + export type GetUtxosParams = Omit & { network: BitcoinNetwork; filter?: { page: Uint8Array | number[] } | { min_confirmations: number }; @@ -14,6 +21,24 @@ export const toGetUtxosParams = ({ ...rest }: GetUtxosParams): get_utxos_request => ({ filter: toNullable(filter), - network: network === "testnet" ? { testnet: null } : { mainnet: null }, + network: mapBitcoinNetwork(network), + ...rest, +}); + +export type GetBalanceParams = Omit< + get_balance_request, + "network" | "min_confirmations" +> & { + network: BitcoinNetwork; + min_confirmations?: number; +} & QueryParams; + +export const toGetBalanceParams = ({ + network, + min_confirmations, + ...rest +}: GetBalanceParams): get_balance_request => ({ + min_confirmations: toNullable(min_confirmations), + network: mapBitcoinNetwork(network), ...rest, });