From 8d533d35845d543501be97d5cfe415a03ae2ab9f Mon Sep 17 00:00:00 2001 From: David de Kloet Date: Tue, 17 Oct 2023 09:52:32 +0200 Subject: [PATCH 1/4] Duplicate tests for easy diff --- packages/ckbtc/src/minter.canister.spec.ts | 140 +++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/ckbtc/src/minter.canister.spec.ts b/packages/ckbtc/src/minter.canister.spec.ts index ed6a5e2c..c020775e 100644 --- a/packages/ckbtc/src/minter.canister.spec.ts +++ b/packages/ckbtc/src/minter.canister.spec.ts @@ -12,6 +12,7 @@ import { MinterAlreadyProcessingError, MinterAmountTooLowError, MinterGenericError, + MinterInsufficientAllowanceError, MinterInsufficientFundsError, MinterMalformedAddressError, MinterNoNewUtxosError, @@ -407,6 +408,145 @@ describe("ckBTC minter canister", () => { }); }); + describe("Retrieve BTC", () => { + const success: RetrieveBtcOk = { + block_index: 1n, + }; + const ok = { Ok: success }; + + const params = { + address: bitcoinAddressMock, + amount: 123n, + }; + + it("should return Ok", async () => { + const service = mock>(); + service.retrieve_btc.mockResolvedValue(ok); + + const canister = minter(service); + + const res = await canister.retrieveBtc(params); + + expect(service.retrieve_btc).toBeCalled(); + expect(res).toEqual(success); + }); + + it("should throw MinterGenericError", async () => { + const service = mock>(); + + const error = { + Err: { GenericError: { error_message: "message", error_code: 1n } }, + }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterGenericError( + `${error.Err.GenericError.error_message} (${error.Err.GenericError.error_code})`, + ), + ); + }); + + it("should throw MinterTemporarilyUnavailable", async () => { + const service = mock>(); + + const error = { Err: { TemporarilyUnavailable: "unavailable" } }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterTemporaryUnavailableError(error.Err.TemporarilyUnavailable), + ); + }); + + it("should throw MinterAlreadyProcessingError", async () => { + const service = mock>(); + + const error = { Err: { AlreadyProcessing: null } }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterAlreadyProcessingError(), + ); + }); + + it("should throw MinterMalformedAddress", async () => { + const service = mock>(); + + const error = { Err: { MalformedAddress: "malformated" } }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterMalformedAddressError(error.Err.MalformedAddress), + ); + }); + + it("should throw MinterAmountTooLowError", async () => { + const service = mock>(); + + const error = { Err: { AmountTooLow: 123n } }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterAmountTooLowError(`${error.Err.AmountTooLow}`), + ); + }); + + it("should throw MinterInsufficientFundsError", async () => { + const service = mock>(); + + const error = { Err: { InsufficientFunds: { balance: 123n } } }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterInsufficientFundsError( + `${error.Err.InsufficientFunds.balance}`, + ), + ); + }); + + it("should throw unsupported response", async () => { + const service = mock>(); + + const error = { Err: { Test: null } as unknown as RetrieveBtcError }; + service.retrieve_btc.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtc(params); + + await expect(call).rejects.toThrowError( + new MinterRetrieveBtcError( + `Unsupported response type in minter.retrieveBtc ${JSON.stringify( + error.Err, + )}`, + ), + ); + }); + }); + describe("Estimate Withdrawal Fee", () => { it("should return estimated fee", async () => { const result = { minter_fee: 123n, bitcoin_fee: 456n }; From 6c14368659f3f70fba5267c90ee64125a66245c8 Mon Sep 17 00:00:00 2001 From: David de Kloet Date: Tue, 17 Oct 2023 10:11:52 +0200 Subject: [PATCH 2/4] Add retrieveBTCWithApproval --- packages/ckbtc/src/errors/minter.errors.ts | 27 ++++++- packages/ckbtc/src/minter.canister.spec.ts | 79 +++++++++++++++----- packages/ckbtc/src/minter.canister.ts | 46 +++++++++++- packages/ckbtc/src/types/minter.responses.ts | 5 ++ 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/packages/ckbtc/src/errors/minter.errors.ts b/packages/ckbtc/src/errors/minter.errors.ts index 0ed19e4c..8f7a7eb8 100644 --- a/packages/ckbtc/src/errors/minter.errors.ts +++ b/packages/ckbtc/src/errors/minter.errors.ts @@ -1,5 +1,9 @@ import { nonNullish } from "@dfinity/utils"; -import type { RetrieveBtcError, UpdateBalanceError } from "../../candid/minter"; +import type { + RetrieveBtcError, + RetrieveBtcWithApprovalError, + UpdateBalanceError, +} from "../../candid/minter"; export class MinterGenericError extends Error {} export class MinterTemporaryUnavailableError extends MinterGenericError {} @@ -12,9 +16,10 @@ export class MinterRetrieveBtcError extends MinterGenericError {} export class MinterMalformedAddressError extends MinterRetrieveBtcError {} export class MinterAmountTooLowError extends MinterRetrieveBtcError {} export class MinterInsufficientFundsError extends MinterRetrieveBtcError {} +export class MinterInsufficientAllowanceError extends MinterRetrieveBtcError {} const mapGenericError = ( - Err: UpdateBalanceError | RetrieveBtcError, + Err: UpdateBalanceError | RetrieveBtcError | RetrieveBtcWithApprovalError, ): MinterGenericError | undefined => { if ("GenericError" in Err) { const { @@ -79,3 +84,21 @@ export const createRetrieveBtcError = ( `Unsupported response type in minter.retrieveBtc ${JSON.stringify(Err)}`, ); }; + +export const createRetrieveBtcWithApprovalError = ( + Err: RetrieveBtcWithApprovalError, +): MinterGenericError => { + const error = mapGenericError(Err); + + if (nonNullish(error)) { + return error; + } + + if ("InsufficientAllowance" in Err) { + return new MinterInsufficientAllowanceError( + `${Err.InsufficientAllowance.allowance}`, + ); + } + + return createRetrieveBtcError(Err); +}; diff --git a/packages/ckbtc/src/minter.canister.spec.ts b/packages/ckbtc/src/minter.canister.spec.ts index c020775e..482bf1a4 100644 --- a/packages/ckbtc/src/minter.canister.spec.ts +++ b/packages/ckbtc/src/minter.canister.spec.ts @@ -408,7 +408,7 @@ describe("ckBTC minter canister", () => { }); }); - describe("Retrieve BTC", () => { + describe("Retrieve BTC with approval", () => { const success: RetrieveBtcOk = { block_index: 1n, }; @@ -416,18 +416,42 @@ describe("ckBTC minter canister", () => { const params = { address: bitcoinAddressMock, - amount: 123n, + amount: 123_000n, }; it("should return Ok", async () => { const service = mock>(); - service.retrieve_btc.mockResolvedValue(ok); + service.retrieve_btc_with_approval.mockResolvedValue(ok); const canister = minter(service); - const res = await canister.retrieveBtc(params); + const res = await canister.retrieveBtcWithApproval(params); - expect(service.retrieve_btc).toBeCalled(); + expect(service.retrieve_btc_with_approval).toBeCalledTimes(1); + expect(service.retrieve_btc_with_approval).toBeCalledWith({ + ...params, + from_subaccount: [], + }); + expect(res).toEqual(success); + }); + + it("should return Ok with fromSubaccount", async () => { + const fromSubaccount = new Uint8Array([3, 4, 5]); + const service = mock>(); + service.retrieve_btc_with_approval.mockResolvedValue(ok); + + const canister = minter(service); + + const res = await canister.retrieveBtcWithApproval({ + ...params, + fromSubaccount, + }); + + expect(service.retrieve_btc_with_approval).toBeCalledTimes(1); + expect(service.retrieve_btc_with_approval).toBeCalledWith({ + ...params, + from_subaccount: [fromSubaccount], + }); expect(res).toEqual(success); }); @@ -437,11 +461,11 @@ describe("ckBTC minter canister", () => { const error = { Err: { GenericError: { error_message: "message", error_code: 1n } }, }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterGenericError( @@ -454,11 +478,11 @@ describe("ckBTC minter canister", () => { const service = mock>(); const error = { Err: { TemporarilyUnavailable: "unavailable" } }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterTemporaryUnavailableError(error.Err.TemporarilyUnavailable), @@ -469,11 +493,11 @@ describe("ckBTC minter canister", () => { const service = mock>(); const error = { Err: { AlreadyProcessing: null } }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterAlreadyProcessingError(), @@ -484,11 +508,11 @@ describe("ckBTC minter canister", () => { const service = mock>(); const error = { Err: { MalformedAddress: "malformated" } }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterMalformedAddressError(error.Err.MalformedAddress), @@ -499,11 +523,11 @@ describe("ckBTC minter canister", () => { const service = mock>(); const error = { Err: { AmountTooLow: 123n } }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterAmountTooLowError(`${error.Err.AmountTooLow}`), @@ -514,11 +538,11 @@ describe("ckBTC minter canister", () => { const service = mock>(); const error = { Err: { InsufficientFunds: { balance: 123n } } }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterInsufficientFundsError( @@ -527,15 +551,32 @@ describe("ckBTC minter canister", () => { ); }); + it("should throw MinterInsufficientAllowanceError", async () => { + const service = mock>(); + + const error = { Err: { InsufficientAllowance: { allowance: 123n } } }; + service.retrieve_btc_with_approval.mockResolvedValue(error); + + const canister = minter(service); + + const call = () => canister.retrieveBtcWithApproval(params); + + await expect(call).rejects.toThrowError( + new MinterInsufficientAllowanceError( + `${error.Err.InsufficientAllowance.allowance}`, + ), + ); + }); + it("should throw unsupported response", async () => { const service = mock>(); const error = { Err: { Test: null } as unknown as RetrieveBtcError }; - service.retrieve_btc.mockResolvedValue(error); + service.retrieve_btc_with_approval.mockResolvedValue(error); const canister = minter(service); - const call = () => canister.retrieveBtc(params); + const call = () => canister.retrieveBtcWithApproval(params); await expect(call).rejects.toThrowError( new MinterRetrieveBtcError( diff --git a/packages/ckbtc/src/minter.canister.ts b/packages/ckbtc/src/minter.canister.ts index 9e86c583..ddf2c6ce 100644 --- a/packages/ckbtc/src/minter.canister.ts +++ b/packages/ckbtc/src/minter.canister.ts @@ -14,6 +14,7 @@ import { idlFactory as certifiedIdlFactory } from "../candid/minter.certified.id import { idlFactory } from "../candid/minter.idl"; import { createRetrieveBtcError, + createRetrieveBtcWithApprovalError, createUpdateBalanceError, } from "./errors/minter.errors"; import type { CkBTCMinterCanisterOptions } from "./types/canister.options"; @@ -26,6 +27,7 @@ import type { import type { EstimateWithdrawalFee, RetrieveBtcResponse, + RetrieveBtcWithApprovalResponse, UpdateBalanceOk, UpdateBalanceResponse, } from "./types/minter.responses"; @@ -49,7 +51,7 @@ export class CkBTCMinterCanister extends Canister { * * @param {GetBTCAddressParams} params The parameters for which a BTC address should be resolved. * @param {Principal} params.owner The owner for which the BTC address should be generated. If not provided, the `caller` will be use instead. - * @param {Principal} params.subaccount An optional subaccount to compute the address. + * @param {Uint8Array} params.subaccount An optional subaccount to compute the address. * @returns {Promise} The BTC address of the given account. */ getBtcAddress = ({ @@ -125,6 +127,48 @@ export class CkBTCMinterCanister extends Canister { return response.Ok; }; + /** + * Submits a request to convert ckBTC to BTC after making an ICRC-2 approval. + * + * # Note + * + * The BTC retrieval process is slow. Instead of synchronously waiting for a BTC transaction to settle, this method returns a request ([block_index]) that the caller can use to query the request status. + * + * # Preconditions + * + * The caller allowed the minter's principal to spend its funds using + * [icrc2_approve] on the ckBTC ledger. + * + * @param {string} params.address The bitcoin address. + * @param {bigint} params.amount The ckBTC amount. + * @param {Uint8Array} params.fromSubaccount An optional subaccount from which + * the ckBTC should be transferred. + * @returns {Promise} The result or the operation. + */ + retrieveBtcWithApproval = async ({ + address, + amount, + fromSubaccount, + }: { + address: string; + amount: bigint; + fromSubaccount?: Uint8Array; + }): Promise => { + const response: RetrieveBtcWithApprovalResponse = await this.caller({ + certified: true, + }).retrieve_btc_with_approval({ + address, + amount, + from_subaccount: toNullable(fromSubaccount), + }); + + if ("Err" in response) { + throw createRetrieveBtcWithApprovalError(response.Err); + } + + return response.Ok; + }; + /** * Returns an estimation of the user's fee (in Satoshi) for a retrieve_btc request based on the current status of the Bitcoin network and the fee related to the minter. * diff --git a/packages/ckbtc/src/types/minter.responses.ts b/packages/ckbtc/src/types/minter.responses.ts index d3f1f1bd..31d4c682 100644 --- a/packages/ckbtc/src/types/minter.responses.ts +++ b/packages/ckbtc/src/types/minter.responses.ts @@ -1,6 +1,7 @@ import type { RetrieveBtcError, RetrieveBtcOk, + RetrieveBtcWithApprovalError, UpdateBalanceError, UtxoStatus, } from "../../candid/minter"; @@ -15,4 +16,8 @@ export type RetrieveBtcResponse = | { Ok: RetrieveBtcOk } | { Err: RetrieveBtcError }; +export type RetrieveBtcWithApprovalResponse = + | { Ok: RetrieveBtcOk } + | { Err: RetrieveBtcWithApprovalError }; + export type EstimateWithdrawalFee = { minter_fee: bigint; bitcoin_fee: bigint }; From b103827bfd408c5a05fab6eea9b9f0f0d078aec2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 08:13:40 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20Documentation=20auto-update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ckbtc/README.md | 43 ++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/ckbtc/README.md b/packages/ckbtc/README.md index 68e01930..c00ce2cf 100644 --- a/packages/ckbtc/README.md +++ b/packages/ckbtc/README.md @@ -78,7 +78,7 @@ Parameters: ### :factory: CkBTCMinterCanister -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L33) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L35) #### Methods @@ -87,6 +87,7 @@ Parameters: - [updateBalance](#gear-updatebalance) - [getWithdrawalAccount](#gear-getwithdrawalaccount) - [retrieveBtc](#gear-retrievebtc) +- [retrieveBtcWithApproval](#gear-retrievebtcwithapproval) - [estimateWithdrawalFee](#gear-estimatewithdrawalfee) - [getMinterInfo](#gear-getminterinfo) @@ -96,7 +97,7 @@ Parameters: | -------- | ------------------------------------------------------------------------ | | `create` | `(options: CkBTCMinterCanisterOptions<_SERVICE>) => CkBTCMinterCanister` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L34) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L36) ##### :gear: getBtcAddress @@ -114,7 +115,7 @@ Parameters: - `params.owner`: The owner for which the BTC address should be generated. If not provided, the `caller` will be use instead. - `params.subaccount`: An optional subaccount to compute the address. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L55) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L57) ##### :gear: updateBalance @@ -132,7 +133,7 @@ Parameters: - `params.owner`: The owner of the address. If not provided, the `caller` will be use instead. - `params.subaccount`: An optional subaccount of the address. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L74) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L76) ##### :gear: getWithdrawalAccount @@ -142,7 +143,7 @@ Returns the account to which the caller should deposit ckBTC before withdrawing | ---------------------- | ------------------------ | | `getWithdrawalAccount` | `() => Promise` | -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L97) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L99) ##### :gear: retrieveBtc @@ -166,7 +167,33 @@ Parameters: - `params.address`: The bitcoin address. - `params.amount`: The ckBTC amount. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L116) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L118) + +##### :gear: retrieveBtcWithApproval + +Submits a request to convert ckBTC to BTC after making an ICRC-2 approval. + +# Note + +The BTC retrieval process is slow. Instead of synchronously waiting for a BTC transaction to settle, this method returns a request ([block_index]) that the caller can use to query the request status. + +# Preconditions + +The caller allowed the minter's principal to spend its funds using +[icrc2_approve] on the ckBTC ledger. + +| Method | Type | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `retrieveBtcWithApproval` | `({ address, amount, fromSubaccount, }: { address: string; amount: bigint; fromSubaccount?: Uint8Array; }) => Promise` | + +Parameters: + +- `params.address`: The bitcoin address. +- `params.amount`: The ckBTC amount. +- `params.fromSubaccount`: An optional subaccount from which + the ckBTC should be transferred. + +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L148) ##### :gear: estimateWithdrawalFee @@ -182,7 +209,7 @@ Parameters: - `params.certified`: query or update call - `params.amount`: The optional amount for which the fee should be estimated. -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L135) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L179) ##### :gear: getMinterInfo @@ -197,7 +224,7 @@ Parameters: - `params`: The parameters to get the deposit fee. - `params.certified`: query or update call -[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L149) +[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/ckbtc/src/minter.canister.ts#L193) From 1e3e7024f43025a1b50b7c6fdd961e0c57a957f6 Mon Sep 17 00:00:00 2001 From: David de Kloet Date: Tue, 17 Oct 2023 10:17:58 +0200 Subject: [PATCH 4/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7186637..d8aa9143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - support new fields from swap canister response types: `min_direct_participation_icp_e8s` and `max_direct_participation_icp_e8s` - support new fields in the `CreateServiceNervousSystem` proposal action `maximum_direct_participation_icp` and `minimum_direct_participation_icp`. - support new filter field `omit_large_fields` in `list_proposals`. +- add support for `retrieve_btc_with_approval` in `@dfinity/ckbtc`. # 2023.10.02-1515Z