From b84ffa386473992d2069984a091b7109636efdcb Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 30 Sep 2026 17:09:31 +0200 Subject: [PATCH 1/2] feat(pic): support the remaining instance options Adds the bitcoin, dogecoin and canisterMigration ICP features, bitcoindAddrs and dogecoindAddrs, initialTime, autoProgress, mainnetNnsSubnetId and logLevel. --- .../docs/guides/canister-declarations.mdx | 2 +- .../docs/guides/canister-snapshots.mdx | 2 +- .../src/content/docs/guides/more-examples.mdx | 2 +- .../src/content/docs/guides/running-tests.mdx | 10 + .../docs/guides/working-with-bitcoin.mdx | 113 ++++++++++ .../docs/guides/working-with-the-nns.mdx | 27 ++- packages/pic/src/pocket-ic-client-types.ts | 82 +++++++- packages/pic/src/pocket-ic-types.ts | 94 +++++++++ packages/pic/src/pocket-ic.ts | 31 +-- .../pic/tests/src/instance-options.spec.ts | 194 ++++++++++++++++++ 10 files changed, 529 insertions(+), 28 deletions(-) create mode 100644 docs/src/content/docs/guides/working-with-bitcoin.mdx create mode 100644 packages/pic/tests/src/instance-options.spec.ts diff --git a/docs/src/content/docs/guides/canister-declarations.mdx b/docs/src/content/docs/guides/canister-declarations.mdx index b589be1..139ba59 100644 --- a/docs/src/content/docs/guides/canister-declarations.mdx +++ b/docs/src/content/docs/guides/canister-declarations.mdx @@ -1,7 +1,7 @@ --- title: Canister declarations sidebar: - order: 8 + order: 9 --- PicJS creates actors from a canister's Candid interface: an `idlFactory` that encodes and decodes calls, and a `_SERVICE` type that describes them. Generate both from the canister's `.did` file with [`@icp-sdk/bindgen`](https://js.icp.build/bindgen/latest/): diff --git a/docs/src/content/docs/guides/canister-snapshots.mdx b/docs/src/content/docs/guides/canister-snapshots.mdx index efe1d0a..2f62f2f 100644 --- a/docs/src/content/docs/guides/canister-snapshots.mdx +++ b/docs/src/content/docs/guides/canister-snapshots.mdx @@ -1,7 +1,7 @@ --- title: Canister snapshots sidebar: - order: 7 + order: 8 --- A [canister snapshot](https://docs.internetcomputer.org/guides/canister-management/snapshots/) captures a canister's state: its Wasm module, heap and stable memory, and chunk store. Taking, loading, deleting and uploading snapshots must be done by a controller of the canister. Listing and downloading them is also allowed for other principals if the canister's `snapshotVisibility` setting permits it. diff --git a/docs/src/content/docs/guides/more-examples.mdx b/docs/src/content/docs/guides/more-examples.mdx index 3703cbc..8e28a96 100644 --- a/docs/src/content/docs/guides/more-examples.mdx +++ b/docs/src/content/docs/guides/more-examples.mdx @@ -2,7 +2,7 @@ title: More Examples next: false sidebar: - order: 9 + order: 10 --- All examples are written in [TypeScript](https://www.typescriptlang.org/) with [Jest](https://jestjs.io/) or [Vitest](https://vitest.dev/) as the test runner, diff --git a/docs/src/content/docs/guides/running-tests.mdx b/docs/src/content/docs/guides/running-tests.mdx index 365579a..7844f52 100644 --- a/docs/src/content/docs/guides/running-tests.mdx +++ b/docs/src/content/docs/guides/running-tests.mdx @@ -79,6 +79,14 @@ const picServer = await PocketIcServer.start({ }); ``` +These include the logs of each instance's replica, which only logs warnings and errors by default. To see more, set its log level with the `logLevel` option when creating the instance, one of `critical`, `error`, `warn`, `info`, `debug` or `trace`: + +```ts +const pic = await PocketIc.create(picServer.getUrl(), { + logLevel: 'debug', +}); +``` + ## Testing interleaved calls While a call waits at an `await`, for example for an inter-canister call, the canister can execute other calls. If the first call checked some state before the `await` and updates it after, another call can act on the same state in between, which can cause reentrancy bugs. @@ -103,4 +111,6 @@ await pic.stopLive(); The gateway can be configured with the `httpGateway` option, for example `pic.makeLive({ httpGateway: { port: 8080 } })`. +To create an instance that is live from the start, set the `autoProgress` option, for example `PocketIc.create(url, { autoProgress: {} })`. Its time follows the real time right away, and `makeLive` starts the HTTP gateway for it. + To test how a client handles slower calls, for example its loading states or timeouts, set a minimum delay between rounds with `artificialDelayMs`, for example `pic.makeLive({ artificialDelayMs: 500 })`. This delays calls that wait for live mode to execute them: calls made through the HTTP gateway, and calls submitted with `pic.submitCall` whose result is checked with `pic.ingressStatus`. Calls made with an actor, a deferred actor, `pic.updateCall` or `pic.awaitCall` execute their rounds themselves and are not delayed. diff --git a/docs/src/content/docs/guides/working-with-bitcoin.mdx b/docs/src/content/docs/guides/working-with-bitcoin.mdx new file mode 100644 index 0000000..1a54f20 --- /dev/null +++ b/docs/src/content/docs/guides/working-with-bitcoin.mdx @@ -0,0 +1,113 @@ +--- +title: Working with Bitcoin +sidebar: + order: 7 +--- + +The `bitcoin` ICP feature deploys the Bitcoin canister under the Bitcoin testnet canister ID `g4xu7-jiaaa-aaaan-aaaaq-cai`, configured for the regtest network. It gets its blocks from a local `bitcoind` node in regtest mode, so your tests control the chain: they mine blocks, send transactions, and check how your canister reacts. The Bitcoin subnet it runs on is created automatically. + +It doesn't deploy ckBTC. To test against ckBTC, deploy the ckBTC ledger yourself and make an identity you control its minter, so the tests can mint ckBTC. + +## Running bitcoind + +Start `bitcoind` in regtest mode, for example with the [Bitcoin Core Docker image](https://hub.docker.com/r/bitcoin/bitcoin), and expose its P2P port `18444`: + +```shell +docker run -d --name bitcoind -p 18444:18444 bitcoin/bitcoin:latest bitcoind -regtest +``` + +Then mine blocks to an address. Coinbase rewards can only be spent after 100 confirmations, so mine at least 101 blocks to be able to spend the first reward: + +```shell +docker exec --user bitcoin bitcoind bitcoin-cli -regtest createwallet test +docker exec --user bitcoin bitcoind bitcoin-cli -regtest getnewaddress +docker exec --user bitcoin bitcoind bitcoin-cli -regtest generatetoaddress 101
+``` + +## Connecting the Bitcoin canister + +Enable the `bitcoin` ICP feature and pass the address of `bitcoind` with `bitcoindAddrs`. The address must be reachable from the machine running the PocketIC server: + +```ts +import { IcpFeaturesConfig, PocketIc } from '@dfinity/pic'; + +const pic = await PocketIc.create(process.env.PIC_URL, { + icpFeatures: { bitcoin: IcpFeaturesConfig.DefaultConfig }, + bitcoindAddrs: ['127.0.0.1:18444'], + initialTime: new Date(), +}); +``` + +`bitcoind` timestamps blocks with the real time, so the instance has to start at the current time to accept them, which `initialTime: new Date()` does. Alternatively, create the instance with `autoProgress: {}`, which makes it live and follow the real time, see [Live mode](./running-tests#live-mode). + +## Waiting for the canister to sync + +The Bitcoin canister fetches blocks from `bitcoind` as the instance executes rounds, and rejects calls with `Canister state is not fully synced` until it has caught up. Before asserting on your canister, tick until the Bitcoin canister reports the expected state, for example the balance of the address you mined to: + +```ts +import { IDL } from '@icp-sdk/core/candid'; +import { Principal } from '@icp-sdk/core/principal'; + +const BITCOIN_CANISTER_ID = Principal.fromText('g4xu7-jiaaa-aaaan-aaaaq-cai'); + +const GetBalanceRequest = IDL.Record({ + address: IDL.Text, + network: IDL.Variant({ + mainnet: IDL.Null, + testnet: IDL.Null, + regtest: IDL.Null, + }), + min_confirmations: IDL.Opt(IDL.Nat32), +}); + +async function getBalance(address: string): Promise { + const res = await pic.queryCall({ + canisterId: BITCOIN_CANISTER_ID, + method: 'bitcoin_get_balance_query', + arg: new Uint8Array( + IDL.encode( + [GetBalanceRequest], + [{ address, network: { regtest: null }, min_confirmations: [] }], + ), + ), + }); + + return IDL.decode([IDL.Nat64], res)[0] as bigint; +} + +async function waitForBalance(address: string, expected: bigint) { + for (;;) { + try { + if ((await getBalance(address)) >= expected) { + return; + } + } catch (error) { + if (!String(error).includes('Canister state is not fully synced')) { + throw error; + } + } + await pic.tick(); + } +} + +// 101 blocks mined to the address, with a reward of 50 BTC each. +await waitForBalance(address, 101n * 50n * 100_000_000n); +``` + +After mining more blocks or sending transactions with `bitcoin-cli`, wait again before the next assertion. + +## Dogecoin + +The `dogecoin` ICP feature works the same way. It deploys the Dogecoin canister under its mainnet canister ID `gordg-fyaaa-aaaan-aaadq-cai`, configured for the regtest network, and gets its blocks from the `dogecoind` nodes given by `dogecoindAddrs`: + +```ts +const pic = await PocketIc.create(process.env.PIC_URL, { + icpFeatures: { dogecoin: IcpFeaturesConfig.DefaultConfig }, + dogecoindAddrs: ['127.0.0.1:18444'], + initialTime: new Date(), +}); +``` + +Start `dogecoind` with `dogecoind -regtest`, using a [Dogecoin Core release](https://github.com/dogecoin/dogecoin/releases), and mine blocks with `dogecoin-cli -regtest generatetoaddress`. The Dogecoin canister's interface differs from the Bitcoin canister's: its methods are prefixed with `dogecoin_`, such as `dogecoin_get_balance_query`, its network is `variant { mainnet; regtest }`, and amounts are `nat` rather than `nat64`. + +The Bitcoin and Dogecoin canisters can run on the same instance, but `bitcoindAddrs` and `dogecoindAddrs` can't be combined, as the PocketIC server fails to create an instance with both. diff --git a/docs/src/content/docs/guides/working-with-the-nns.mdx b/docs/src/content/docs/guides/working-with-the-nns.mdx index f4cbaa7..9a84c36 100644 --- a/docs/src/content/docs/guides/working-with-the-nns.mdx +++ b/docs/src/content/docs/guides/working-with-the-nns.mdx @@ -19,16 +19,19 @@ const pic = await PocketIc.create(process.env.PIC_URL, { `IcpFeaturesConfig.DefaultConfig` configures each feature to resemble mainnet as closely as possible. The available features are: -| Feature | Deploys | -| --------------- | ------------------------------------------------------------------------------------------------ | -| `registry` | The NNS registry canister, kept in sync with PocketIC's internal registry | -| `cyclesMinting` | The cycles minting canister, with the ICP/XDR conversion rate set | -| `icpToken` | The ICP ledger and index canisters; the anonymous principal starts with 1,000,000,000 ICP | -| `cyclesToken` | The cycles ledger and index canisters; the anonymous principal starts with 2^127 - 1 cycles | -| `nnsGovernance` | The NNS governance and root canisters, with a 1 ICP neuron controlled by the anonymous principal | -| `sns` | The SNS-W and aggregator canisters, with the SNS canister WASMs uploaded | -| `ii` | The Internet Identity backend and frontend canisters | -| `nnsUi` | The NNS dapp; requires `cyclesMinting`, `icpToken`, `nnsGovernance`, `sns` and `ii` | +| Feature | Deploys | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `registry` | The NNS registry canister, kept in sync with PocketIC's internal registry | +| `cyclesMinting` | The cycles minting canister, with the ICP/XDR conversion rate set | +| `icpToken` | The ICP ledger and index canisters; the anonymous principal starts with 1,000,000,000 ICP | +| `cyclesToken` | The cycles ledger and index canisters; the anonymous principal starts with 2^127 - 1 cycles | +| `nnsGovernance` | The NNS governance and root canisters, with a 1 ICP neuron controlled by the anonymous principal | +| `sns` | The SNS-W and aggregator canisters, with the SNS canister WASMs uploaded | +| `ii` | The Internet Identity backend and frontend canisters | +| `nnsUi` | The NNS dapp; requires `cyclesMinting`, `icpToken`, `nnsGovernance`, `sns` and `ii` | +| `canisterMigration` | The canister migration orchestrator canister | +| `bitcoin` | The Bitcoin canister for the regtest network, see [Working with Bitcoin](./working-with-bitcoin) | +| `dogecoin` | The Dogecoin canister for the regtest network, see [Working with Bitcoin](./working-with-bitcoin#dogecoin) | `ii` and `nnsUi` also deploy frontends, which PocketIC serves through an HTTP gateway created together with the instance. Set the `httpGateway` option, then make the instance live to serve them: @@ -44,6 +47,8 @@ const iiUrl = `http://uqzsh-gqaaa-aaaaq-qaada-cai.localhost:${gatewayPort}`; Without `httpGateway`, creating an instance with either feature throws an error. -The subnets that host these canisters must be empty: leave them unconfigured or use `SubnetStateType.New`, as PocketIC rejects state loaded with `SubnetStateType.FromPath`. Enabling `cyclesMinting` also moves the instance's default time to 10 May 2021, to match the cycles minting canister's state. +The subnets that host these canisters must be empty: leave them unconfigured or use `SubnetStateType.New`, as PocketIC rejects state loaded with `SubnetStateType.FromPath`. Enabling `cyclesMinting` also moves the instance's default time to 10 May 2021, to match the cycles minting canister's state. To start at a later time, set the `initialTime` option, for example `initialTime: new Date()`. Earlier times are rejected. + +The NNS subnet gets a random subnet ID. If your canisters rely on the ID of the NNS subnet on mainnet, set `mainnetNnsSubnetId: true` to create it with that ID. Check out the [NNS Proxy](https://github.com/dfinity/pic-js/tree/main/examples/nns_proxy) example for a full test that creates neurons and proposals against NNS governance. diff --git a/packages/pic/src/pocket-ic-client-types.ts b/packages/pic/src/pocket-ic-client-types.ts index 7a542a3..8e5fde2 100644 --- a/packages/pic/src/pocket-ic-client-types.ts +++ b/packages/pic/src/pocket-ic-client-types.ts @@ -9,7 +9,12 @@ import { isNotNil, } from './util'; import { HttpGatewayRequiredError, TopologyValidationError } from './error'; -import { CanisterCyclesCostSchedule, SenderInfo } from './pocket-ic-types'; +import { + AutoProgressConfig, + CanisterCyclesCostSchedule, + LogLevel, + SenderInfo, +} from './pocket-ic-types'; export { CanisterCyclesCostSchedule }; @@ -33,6 +38,12 @@ export interface CreateInstanceRequest { icpFeatures?: IcpFeatures; disableIngressValidation?: boolean; httpGateway?: HttpGatewayConfig; + initialTime?: Date | number; + autoProgress?: AutoProgressConfig; + bitcoindAddrs?: string[]; + dogecoindAddrs?: string[]; + mainnetNnsSubnetId?: boolean; + logLevel?: LogLevel; } export interface SubnetConfig< @@ -118,6 +129,9 @@ export interface IcpFeatures { sns?: IcpFeaturesConfig; ii?: IcpFeaturesConfig; nnsUi?: IcpFeaturesConfig; + bitcoin?: IcpFeaturesConfig; + dogecoin?: IcpFeaturesConfig; + canisterMigration?: IcpFeaturesConfig; } export interface HttpGatewayConfig { @@ -136,6 +150,45 @@ export interface EncodedCreateInstanceRequest { icp_features?: EncodedIcpFeatures; disable_ingress_validation?: boolean; http_gateway_config?: EncodedInstanceHttpGatewayConfig; + initial_time?: EncodedInitialTime; + bitcoind_addr?: string[]; + dogecoind_addr?: string[]; + mainnet_nns_subnet_id?: boolean; + log_level?: LogLevel; +} + +export type EncodedInitialTime = + | { Timestamp: { nanos_since_epoch: bigint } } + | { AutoProgress: { artificial_delay_ms?: number } }; + +function encodeInitialTime({ + initialTime, + autoProgress, +}: CreateInstanceRequest): EncodedInitialTime | undefined { + if (!isNil(initialTime) && !isNil(autoProgress)) { + throw new Error( + 'The initialTime and autoProgress options cannot be combined, as an instance created with autoProgress follows the real time', + ); + } + + if (!isNil(initialTime)) { + const millis = + initialTime instanceof Date ? initialTime.getTime() : initialTime; + + return { + Timestamp: { + nanos_since_epoch: BigInt(millis) * NANOS_PER_MILLISECOND, + }, + }; + } + + if (!isNil(autoProgress)) { + return { + AutoProgress: { artificial_delay_ms: autoProgress.artificialDelayMs }, + }; + } + + return undefined; } export interface EncodedInstanceHttpGatewayConfig { @@ -186,6 +239,9 @@ export interface EncodedIcpFeatures { sns?: EncodedIcpFeaturesConfig; ii?: EncodedIcpFeaturesConfig; nns_ui?: EncodedIcpFeaturesConfig; + bitcoin?: EncodedIcpFeaturesConfig; + dogecoin?: EncodedIcpFeaturesConfig; + canister_migration?: EncodedIcpFeaturesConfig; } export interface EncodedSubnetConfig { @@ -327,6 +383,15 @@ function encodeIcpFeatures(icpFeatures: IcpFeatures): EncodedIcpFeatures { nns_ui: icpFeatures.nnsUi ? encodeIcpFeaturesConfig(icpFeatures.nnsUi) : undefined, + bitcoin: icpFeatures.bitcoin + ? encodeIcpFeaturesConfig(icpFeatures.bitcoin) + : undefined, + dogecoin: icpFeatures.dogecoin + ? encodeIcpFeaturesConfig(icpFeatures.dogecoin) + : undefined, + canister_migration: icpFeatures.canisterMigration + ? encodeIcpFeaturesConfig(icpFeatures.canisterMigration) + : undefined, }; } @@ -384,8 +449,23 @@ export function encodeCreateInstanceRequest( http_gateway_config: defaultOptions.httpGateway ? encodeHttpGatewayConfig(defaultOptions.httpGateway) : undefined, + initial_time: encodeInitialTime(defaultOptions), + bitcoind_addr: defaultOptions.bitcoindAddrs, + dogecoind_addr: defaultOptions.dogecoindAddrs, + mainnet_nns_subnet_id: defaultOptions.mainnetNnsSubnetId, + log_level: defaultOptions.logLevel, }; + // The PocketIC server panics when it starts both adapters for one instance. + if ( + !isNil(defaultOptions.bitcoindAddrs) && + !isNil(defaultOptions.dogecoindAddrs) + ) { + throw new Error( + 'The bitcoindAddrs and dogecoindAddrs options cannot be combined, as the PocketIC server fails to create an instance with both', + ); + } + const { ii, nnsUi } = defaultOptions.icpFeatures ?? {}; if ((ii || nnsUi) && isNil(defaultOptions.httpGateway)) { throw new HttpGatewayRequiredError(ii ? 'ii' : 'nnsUi'); diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index 1dd2ba9..43707de 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -115,8 +115,83 @@ export interface CreateInstanceOptions { * Defaults to `false`. */ disableIngressValidation?: boolean; + + /** + * The initial time of the instance, as a `Date` or in milliseconds since the Unix epoch. + * Must be at least 6 May 2021 21:17:10 CEST, or 10 May 2021 10:00:01 CEST + * if the `cyclesMinting` ICP feature is enabled, which is also the default time then. + * Cannot be combined with {@link CreateInstanceOptions.autoProgress}. + */ + initialTime?: Date | number; + + /** + * Makes the instance live from creation: it executes rounds on its own and + * follows the real time, see {@link PocketIc.makeLive}. + * Creating the instance returns once its certified time has been set for the first time. + * Cannot be combined with {@link CreateInstanceOptions.initialTime}. + */ + autoProgress?: AutoProgressConfig; + + /** + * The addresses of `bitcoind` nodes, e.g. `127.0.0.1:18444`, that the Bitcoin + * canister deployed by the `bitcoin` ICP feature syncs with. The addresses + * must be reachable from the machine running the PocketIC server. + * Cannot be combined with {@link CreateInstanceOptions.dogecoindAddrs}, as the + * PocketIC server fails to create an instance with both. + */ + bitcoindAddrs?: string[]; + + /** + * The addresses of `dogecoind` nodes, e.g. `127.0.0.1:18444`, that the Dogecoin + * canister deployed by the `dogecoin` ICP feature syncs with. The addresses + * must be reachable from the machine running the PocketIC server. + * Cannot be combined with {@link CreateInstanceOptions.bitcoindAddrs}, as the + * PocketIC server fails to create an instance with both. + */ + dogecoindAddrs?: string[]; + + /** + * Creates the NNS subnet with the subnet ID of the NNS subnet on mainnet, + * `tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe`. + * Defaults to `false`. + */ + mainnetNnsSubnetId?: boolean; + + /** + * The log level of the PocketIC instance's replica, whose logs are written to the + * PocketIC server's standard output, see {@link StartServerOptions.showRuntimeLogs}. + * Defaults to `warn`. + */ + logLevel?: LogLevel; +} + +/** + * Options for making an instance live when it is created, + * see {@link CreateInstanceOptions.autoProgress}. + * + * @category Types + */ +export interface AutoProgressConfig { + /** + * The minimum delay in milliseconds between consecutive rounds, + * see {@link MakeLiveOptions.artificialDelayMs}. Defaults to no delay. + */ + artificialDelayMs?: number; } +/** + * The log level of a PocketIC instance's replica. + * + * @category Types + */ +export type LogLevel = + | 'critical' + | 'error' + | 'warn' + | 'info' + | 'debug' + | 'trace'; + /** * Options for the HTTP gateway created together with a PocketIC instance. */ @@ -446,6 +521,25 @@ export interface IcpFeatures { * `icpToken`, `nnsGovernance`, `sns` and `ii` features. */ nnsUi?: IcpFeaturesConfig; + + /** + * Deploys the Bitcoin canister under the Bitcoin testnet canister ID + * `g4xu7-jiaaa-aaaan-aaaaq-cai`, configured for the regtest network. + * It syncs with the `bitcoind` nodes given by {@link CreateInstanceOptions.bitcoindAddrs}. + */ + bitcoin?: IcpFeaturesConfig; + + /** + * Deploys the Dogecoin canister under its mainnet canister ID + * `gordg-fyaaa-aaaan-aaadq-cai`, configured for the regtest network. + * It syncs with the `dogecoind` nodes given by {@link CreateInstanceOptions.dogecoindAddrs}. + */ + dogecoin?: IcpFeaturesConfig; + + /** + * Deploys the canister migration orchestrator canister on the NNS subnet. + */ + canisterMigration?: IcpFeaturesConfig; } /** diff --git a/packages/pic/src/pocket-ic.ts b/packages/pic/src/pocket-ic.ts index 87a1845..731b88d 100644 --- a/packages/pic/src/pocket-ic.ts +++ b/packages/pic/src/pocket-ic.ts @@ -2058,7 +2058,8 @@ export class PocketIc { /** * Make the PocketIC instance live by enabling auto progress and starting an HTTP gateway. * If the instance was created with {@link CreateInstanceOptions.httpGateway}, that gateway is used instead. - * If the instance is already live, this method returns the port of its HTTP gateway. + * If the instance is already live, this method returns the port of its HTTP gateway, + * starting one first if the instance was created live with {@link CreateInstanceOptions.autoProgress}. * * @param options Options for making the instance live, see {@link MakeLiveOptions}. * To change them on a live instance, call {@link stopLive} first. @@ -2093,26 +2094,30 @@ export class PocketIc { artificialDelayMs, httpGateway, }: MakeLiveOptions = {}): Promise { + if (!isNil(httpGateway) && !isNil(this.client.instanceHttpGatewayPort)) { + throw new Error( + 'The instance was created with an HTTP gateway, configure it with the httpGateway option of PocketIc.create instead', + ); + } + const isLive = await this.client.autoProgressEnabled(); if (isLive) { - if (isNil(this.httpGatewayPort)) { - throw new Error( - 'Inconsistent state, PocketIC server is live but no HTTP Gateway URL is known', - ); - } - if (!isNil(artificialDelayMs) || !isNil(httpGateway)) { + const hasGateway = !isNil(this.httpGatewayPort); + if (!isNil(artificialDelayMs) || (hasGateway && !isNil(httpGateway))) { throw new Error( 'The instance is already live, call stopLive before making it live with new options', ); } - return this.httpGatewayPort; - } + // An instance created with CreateInstanceOptions.autoProgress is live + // before it has an HTTP gateway. + if (isNil(this.httpGatewayPort)) { + this.httpGatewayPort = + this.client.instanceHttpGatewayPort ?? + (await this.client.startHttpGateway(httpGateway)); + } - if (!isNil(httpGateway) && !isNil(this.client.instanceHttpGatewayPort)) { - throw new Error( - 'The instance was created with an HTTP gateway, configure it with the httpGateway option of PocketIc.create instead', - ); + return this.httpGatewayPort; } await this.client.autoProgress(artificialDelayMs); diff --git a/packages/pic/tests/src/instance-options.spec.ts b/packages/pic/tests/src/instance-options.spec.ts new file mode 100644 index 0000000..88087a9 --- /dev/null +++ b/packages/pic/tests/src/instance-options.spec.ts @@ -0,0 +1,194 @@ +import { Principal } from '@icp-sdk/core/principal'; +import { + CreateInstanceOptions, + IcpFeaturesConfig, + LogLevel, + PocketIc, + SubnetStateType, +} from '../../src'; + +const NEW_SUBNET = { state: { type: SubnetStateType.New } } as const; + +const BITCOIN_CANISTER_ID = 'g4xu7-jiaaa-aaaan-aaaaq-cai'; +const DOGECOIN_CANISTER_ID = 'gordg-fyaaa-aaaan-aaadq-cai'; +const CANISTER_MIGRATION_CANISTER_ID = 'sbzkb-zqaaa-aaaaa-aaaiq-cai'; +const MAINNET_NNS_SUBNET_ID = + 'tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe'; + +describe('instance options', () => { + let pic: PocketIc | undefined; + + async function create(options: CreateInstanceOptions): Promise { + pic = await PocketIc.create(process.env.PIC_URL, options); + + return pic; + } + + afterEach(async () => { + await pic?.stopLive(); + await pic?.tearDown(); + pic = undefined; + }); + + describe('initialTime', () => { + it('should set the initial time from a Date or milliseconds', async () => { + const initialTime = new Date('2030-01-02T03:04:05.000Z'); + + const fromDate = await create({ + application: [NEW_SUBNET], + initialTime, + }); + expect(await fromDate.getTime()).toBe(initialTime.getTime()); + await fromDate.tearDown(); + + const fromMillis = await create({ + application: [NEW_SUBNET], + initialTime: initialTime.getTime(), + }); + expect(await fromMillis.getTime()).toBe(initialTime.getTime()); + }); + + it('should reject a time before the earliest supported one', async () => { + await expect( + create({ + application: [NEW_SUBNET], + initialTime: new Date('2021-05-06T19:17:09.000Z'), + }), + ).rejects.toThrow('must be no earlier than'); + }); + + it('should not be combined with autoProgress', async () => { + await expect( + create({ + application: [NEW_SUBNET], + initialTime: new Date(), + autoProgress: {}, + }), + ).rejects.toThrow( + 'The initialTime and autoProgress options cannot be combined', + ); + }); + }); + + describe('autoProgress', () => { + it('should create a live instance that follows the real time', async () => { + const live = await create({ + application: [NEW_SUBNET], + autoProgress: {}, + }); + + expect(Math.abs((await live.getTime()) - Date.now())).toBeLessThan( + 10_000, + ); + + const port = await live.makeLive(); + const res = await fetch(`http://localhost:${port}/api/v2/status`); + expect(res.status).toBe(200); + }); + + it('should reject a new artificial delay once live', async () => { + const live = await create({ + application: [NEW_SUBNET], + autoProgress: { artificialDelayMs: 100 }, + }); + + await expect(live.makeLive({ artificialDelayMs: 200 })).rejects.toThrow( + 'The instance is already live', + ); + }); + }); + + it('should create the NNS subnet with the mainnet subnet ID', async () => { + const instance = await create({ + nns: NEW_SUBNET, + mainnetNnsSubnetId: true, + }); + + expect((await instance.getNnsSubnet())?.id.toText()).toBe( + MAINNET_NNS_SUBNET_ID, + ); + }); + + it.each(['critical', 'error', 'warn', 'info', 'debug', 'trace'])( + 'should accept the %s log level', + async logLevel => { + const instance = await create({ application: [NEW_SUBNET], logLevel }); + + await instance.tick(); + }, + ); + + describe('ICP features', () => { + async function expectCanister( + instance: PocketIc, + canisterId: string, + subnetId: Principal | undefined, + ): Promise { + const id = Principal.fromText(canisterId); + + expect(await instance.canisterExists(id)).toBe(true); + expect((await instance.getCanisterSubnetId(id))?.toText()).toBe( + subnetId?.toText(), + ); + } + + it('should deploy the Bitcoin canister to the Bitcoin subnet', async () => { + const instance = await create({ + icpFeatures: { bitcoin: IcpFeaturesConfig.DefaultConfig }, + }); + + const bitcoinSubnet = await instance.getBitcoinSubnet(); + await expectCanister(instance, BITCOIN_CANISTER_ID, bitcoinSubnet?.id); + }); + + it('should deploy the Dogecoin canister to the Bitcoin subnet', async () => { + const instance = await create({ + icpFeatures: { dogecoin: IcpFeaturesConfig.DefaultConfig }, + }); + + const bitcoinSubnet = await instance.getBitcoinSubnet(); + await expectCanister(instance, DOGECOIN_CANISTER_ID, bitcoinSubnet?.id); + }); + + it('should deploy the canister migration orchestrator to the NNS subnet', async () => { + const instance = await create({ + icpFeatures: { canisterMigration: IcpFeaturesConfig.DefaultConfig }, + }); + + const nnsSubnet = await instance.getNnsSubnet(); + await expectCanister( + instance, + CANISTER_MIGRATION_CANISTER_ID, + nnsSubnet?.id, + ); + }); + + it.each([ + ['bitcoin', { bitcoindAddrs: ['127.0.0.1:18444'] }], + ['dogecoin', { dogecoindAddrs: ['127.0.0.1:18445'] }], + ] as const)( + 'should accept %s node addresses that nothing listens on yet', + async (feature, addrs) => { + await create({ + icpFeatures: { [feature]: IcpFeaturesConfig.DefaultConfig }, + ...addrs, + }); + }, + ); + + it('should reject bitcoind and dogecoind addresses together', async () => { + await expect( + create({ + icpFeatures: { + bitcoin: IcpFeaturesConfig.DefaultConfig, + dogecoin: IcpFeaturesConfig.DefaultConfig, + }, + bitcoindAddrs: ['127.0.0.1:18444'], + dogecoindAddrs: ['127.0.0.1:18445'], + }), + ).rejects.toThrow( + 'The bitcoindAddrs and dogecoindAddrs options cannot be combined', + ); + }); + }); +}); From db02cdb485550c49b4ce626634567153505d5094 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 2 Oct 2026 16:11:06 +0200 Subject: [PATCH 2/2] docs(pic): note when mainnetNnsSubnetId applies and the keys for signing Bitcoin transactions --- docs/src/content/docs/guides/working-with-bitcoin.mdx | 2 ++ packages/pic/src/pocket-ic-types.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/guides/working-with-bitcoin.mdx b/docs/src/content/docs/guides/working-with-bitcoin.mdx index 1a54f20..1b8440c 100644 --- a/docs/src/content/docs/guides/working-with-bitcoin.mdx +++ b/docs/src/content/docs/guides/working-with-bitcoin.mdx @@ -40,6 +40,8 @@ const pic = await PocketIc.create(process.env.PIC_URL, { `bitcoind` timestamps blocks with the real time, so the instance has to start at the current time to accept them, which `initialTime: new Date()` does. Alternatively, create the instance with `autoProgress: {}`, which makes it live and follow the real time, see [Live mode](./running-tests#live-mode). +If your canister signs Bitcoin transactions with threshold ECDSA or Schnorr, also create the test threshold keys subnet with `testThresholdKeys: { state: { type: SubnetStateType.New } }`, which holds the `test_key_1` keys. + ## Waiting for the canister to sync The Bitcoin canister fetches blocks from `bitcoind` as the instance executes rounds, and rejects calls with `Canister state is not fully synced` until it has caught up. Before asserting on your canister, tick until the Bitcoin canister reports the expected state, for example the balance of the address you mined to: diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index 43707de..6218a5c 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -153,7 +153,7 @@ export interface CreateInstanceOptions { /** * Creates the NNS subnet with the subnet ID of the NNS subnet on mainnet, * `tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe`. - * Defaults to `false`. + * Has no effect if the instance has no NNS subnet. Defaults to `false`. */ mainnetNnsSubnetId?: boolean;