From d9030b76a57280e37f7d70ff8299b9710634dd62 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 8 Jun 2026 13:14:43 -0700 Subject: [PATCH 1/3] Update to v14 and support disableIngressValidation and senderInfo --- CHANGELOG.md | 1 + packages/pic/postinstall.mjs | 2 +- packages/pic/src/pocket-ic-client-types.ts | 20 +++- packages/pic/src/pocket-ic-types.ts | 45 +++++++++ packages/pic/src/pocket-ic.ts | 4 + .../src/disable-ingress-validation.spec.ts | 94 +++++++++++++++++++ packages/pic/tests/src/sender-info.spec.ts | 74 +++++++++++++++ 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 packages/pic/tests/src/disable-ingress-validation.spec.ts create mode 100644 packages/pic/tests/src/sender-info.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2eda5f..b629028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Feat +- **pic**: bump PocketIC to v14, add disableIngressValidation and senderInfo options - **pic**: add costSchedule option and bump PocketIC to v13 (#264) ## 0.21.0 (2026-03-18) diff --git a/packages/pic/postinstall.mjs b/packages/pic/postinstall.mjs index 2992a44..8827607 100644 --- a/packages/pic/postinstall.mjs +++ b/packages/pic/postinstall.mjs @@ -17,7 +17,7 @@ if (!IS_LINUX && !IS_DARWIN) { const IS_ARM = process.arch === 'arm64' || process.arch === 'aarch64'; const ARCH = IS_ARM ? 'arm64' : 'x86_64'; const PLATFORM = IS_LINUX ? `${ARCH}-linux` : `${ARCH}-darwin`; -const DEFAULT_VERSION = 'package:13.0.0'; +const DEFAULT_VERSION = 'package:14.0.0'; const TARGET_PATH = resolve(__dirname, 'pocket-ic'); diff --git a/packages/pic/src/pocket-ic-client-types.ts b/packages/pic/src/pocket-ic-client-types.ts index 337b261..a2c770e 100644 --- a/packages/pic/src/pocket-ic-client-types.ts +++ b/packages/pic/src/pocket-ic-client-types.ts @@ -9,7 +9,7 @@ import { isNotNil, } from './util'; import { TopologyValidationError } from './error'; -import { CanisterCyclesCostSchedule } from './pocket-ic-types'; +import { CanisterCyclesCostSchedule, SenderInfo } from './pocket-ic-types'; export { CanisterCyclesCostSchedule }; @@ -30,6 +30,7 @@ export interface CreateInstanceRequest { ingressMaxRetries?: number; icpConfig?: IcpConfig; icpFeatures?: IcpFeatures; + disableIngressValidation?: boolean; } export interface SubnetConfig< @@ -117,6 +118,7 @@ export interface EncodedCreateInstanceRequest { subnet_config_set: EncodedCreateInstanceSubnetConfig; icp_config?: EncodedIcpConfig; icp_features?: EncodedIcpFeatures; + disable_ingress_validation?: boolean; } export interface EncodedCreateInstanceSubnetConfig { @@ -331,6 +333,7 @@ export function encodeCreateInstanceRequest( icp_features: defaultOptions.icpFeatures ? encodeIcpFeatures(defaultOptions.icpFeatures) : undefined, + disable_ingress_validation: defaultOptions.disableIngressValidation, }; if ( @@ -981,6 +984,7 @@ export interface CanisterCallRequest { method: string; payload: Uint8Array; effectivePrincipal?: EffectivePrincipal; + senderInfo?: SenderInfo; } export type EffectivePrincipal = @@ -997,6 +1001,12 @@ export interface EncodedCanisterCallRequest { method: string; payload: string; effective_principal?: EncodedEffectivePrincipal; + sender_info?: EncodedSenderInfo; +} + +export interface EncodedSenderInfo { + info: string; + signer: string; } export type EncodedEffectivePrincipal = @@ -1051,6 +1061,14 @@ export function encodeCanisterCallRequest( method: req.method, payload: base64Encode(req.payload), effective_principal: encodeEffectivePrincipal(req.effectivePrincipal), + sender_info: req.senderInfo ? encodeSenderInfo(req.senderInfo) : undefined, + }; +} + +function encodeSenderInfo(senderInfo: SenderInfo): EncodedSenderInfo { + return { + info: base64Encode(senderInfo.info), + signer: base64EncodePrincipal(senderInfo.signer), }; } diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index 77692db..4cc7f6c 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -85,6 +85,18 @@ export interface CreateInstanceOptions { * Determines what ICP features should be enabled for the PocketIC instance. */ icpFeatures?: IcpFeatures; + + /** + * Disables ingress message validation on the PocketIC instance. + * + * When enabled, the PocketIC server skips the validation that would normally + * reject malformed or otherwise invalid ingress messages. This is useful for + * testing canister behavior against ingress messages that the replica would + * ordinarily refuse to process. + * + * Defaults to `false`. + */ + disableIngressValidation?: boolean; } /** @@ -895,6 +907,29 @@ export interface CanisterStatusResult { //#region CanisterCall +/** + * Signed sender information attached to a canister call. + * + * This is passed through to the canister, which can inspect it via the + * `msg_caller_info_data` and `msg_caller_info_signer` system APIs. PocketIC + * does not validate the signature over `info`, but preserves both fields for + * canister inspection. + * + * @category Types + * @see [Principal](https://js.icp.build/core/latest/libs/principal/api/classes/principal/) + */ +export interface SenderInfo { + /** + * An arbitrary binary blob of sender information. + */ + info: Uint8Array; + + /** + * The Principal of the signer of {@link info}. + */ + signer: Principal; +} + /** * Options for making a query call to a given canister. * @@ -928,6 +963,11 @@ export interface QueryCallOptions { * The ID of the subnet that the canister resides on. */ targetSubnetId?: Principal; + + /** + * Signed sender information to attach to the call, see {@link SenderInfo}. + */ + senderInfo?: SenderInfo; } /** @@ -964,6 +1004,11 @@ export interface UpdateCallOptions { * The ID of the subnet that the canister resides on. */ targetSubnetId?: Principal; + + /** + * Signed sender information to attach to the call, see {@link SenderInfo}. + */ + senderInfo?: SenderInfo; } //#endregion CanisterCall diff --git a/packages/pic/src/pocket-ic.ts b/packages/pic/src/pocket-ic.ts index d550763..13d537d 100644 --- a/packages/pic/src/pocket-ic.ts +++ b/packages/pic/src/pocket-ic.ts @@ -843,6 +843,7 @@ export class PocketIc { arg = new Uint8Array(), sender = Principal.anonymous(), targetSubnetId, + senderInfo, }: QueryCallOptions): Promise { const res = await this.client.queryCall({ canisterId, @@ -854,6 +855,7 @@ export class PocketIc { subnetId: targetSubnetId, } : undefined, + senderInfo, }); return res.body; @@ -896,6 +898,7 @@ export class PocketIc { arg = new Uint8Array(), sender = Principal.anonymous(), targetSubnetId, + senderInfo, }: UpdateCallOptions): Promise { const res = await this.client.updateCall({ canisterId, @@ -907,6 +910,7 @@ export class PocketIc { subnetId: targetSubnetId, } : undefined, + senderInfo, }); return res.body; diff --git a/packages/pic/tests/src/disable-ingress-validation.spec.ts b/packages/pic/tests/src/disable-ingress-validation.spec.ts new file mode 100644 index 0000000..3a3a4e8 --- /dev/null +++ b/packages/pic/tests/src/disable-ingress-validation.spec.ts @@ -0,0 +1,94 @@ +import { Cbor } from '@icp-sdk/core/agent'; +import { Principal } from '@icp-sdk/core/principal'; +import { PocketIc, SubnetStateType, generateRandomIdentity } from '../../src'; + +// A minimal Candid-encoded empty argument tuple (`DIDL` magic + 0 type/arg counts). +const EMPTY_CANDID_ARG = new Uint8Array([0x44, 0x49, 0x44, 0x4c, 0x00, 0x00]); + +// Comfortably within the replica's 5 minute MAX_INGRESS_TTL, measured from the +// instance's own clock so the only validation failure is the missing signature. +const INGRESS_EXPIRY_OFFSET_NANOS = 4n * 60n * 1_000_000_000n; +const NANOS_PER_MILLISECOND = 1_000_000n; + +/** + * POSTs a deliberately invalid ingress message to the instance's mainnet-like + * `/api/v2/.../call` endpoint: a non-anonymous sender with no `sender_pubkey` + * or `sender_sig`. The replica's ingress validation rejects this with a missing + * signature error unless ingress validation has been disabled. + */ +async function submitUnsignedNonAnonymousCall( + pic: PocketIc, + gatewayPort: number, +): Promise { + const canisterId = await pic.getDefaultEffectiveCanisterId(); + const sender = generateRandomIdentity().getPrincipal(); + expect(sender.isAnonymous()).toBe(false); + + const instanceTimeMs = await pic.getTime(); + const ingressExpiry = + BigInt(instanceTimeMs) * NANOS_PER_MILLISECOND + + INGRESS_EXPIRY_OFFSET_NANOS; + + // An anonymous envelope (no sender_pubkey / sender_sig) carrying a + // non-anonymous sender. This is exactly what ingress validation forbids. + const envelope = { + content: { + request_type: 'call', + canister_id: canisterId, + method_name: 'get_time', + arg: EMPTY_CANDID_ARG, + sender, + ingress_expiry: ingressExpiry, + }, + }; + + return await fetch( + `http://localhost:${gatewayPort}/api/v2/canister/${canisterId.toText()}/call`, + { + method: 'POST', + headers: { 'Content-Type': 'application/cbor' }, + body: Cbor.encode(envelope), + }, + ); +} + +describe('CreateInstanceOptions.disableIngressValidation', () => { + it('rejects an unsigned non-anonymous call when validation is enabled', async () => { + const pic = await PocketIc.create(process.env.PIC_URL, { + nns: { state: { type: SubnetStateType.New } }, + application: [{ state: { type: SubnetStateType.New } }], + }); + try { + const gatewayPort = await pic.makeLive(); + const res = await submitUnsignedNonAnonymousCall(pic, gatewayPort); + + // The replica refuses the message because the non-anonymous sender did + // not provide a signature. + expect(res.ok).toBe(false); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + } finally { + await pic.stopLive(); + await pic.tearDown(); + } + }); + + it('accepts an unsigned non-anonymous call when validation is disabled', async () => { + const pic = await PocketIc.create(process.env.PIC_URL, { + nns: { state: { type: SubnetStateType.New } }, + application: [{ state: { type: SubnetStateType.New } }], + disableIngressValidation: true, + }); + try { + const gatewayPort = await pic.makeLive(); + const res = await submitUnsignedNonAnonymousCall(pic, gatewayPort); + + // With validation disabled the same forbidden message is accepted into + // the ingress pool. + expect(res.ok).toBe(true); + } finally { + await pic.stopLive(); + await pic.tearDown(); + } + }); +}); diff --git a/packages/pic/tests/src/sender-info.spec.ts b/packages/pic/tests/src/sender-info.spec.ts new file mode 100644 index 0000000..f44621b --- /dev/null +++ b/packages/pic/tests/src/sender-info.spec.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { gunzipSync } from 'node:zlib'; +import path from 'node:path'; +import { IDL } from '@icp-sdk/core/candid'; +import { PocketIc, SubnetStateType, generateRandomIdentity } from '../../src'; +import { + _SERVICE as TestCanister, + idlFactory, +} from '../test-canister/declarations/test_canister.did'; + +const WASM_PATH = path.resolve( + __dirname, + '..', + 'test-canister', + 'test_canister.wasm.gz', +); + +function loadWasm(): Uint8Array { + return new Uint8Array(gunzipSync(readFileSync(WASM_PATH))); +} + +const CONTROLLER = generateRandomIdentity(); +const CONTROLLER_PRINCIPAL = CONTROLLER.getPrincipal(); + +describe('senderInfo', () => { + let wasm: Uint8Array; + + beforeAll(() => { + wasm = loadWasm(); + }); + + it('accepts senderInfo on query and update calls', async () => { + const pic = await PocketIc.create(process.env.PIC_URL, { + application: [{ state: { type: SubnetStateType.New } }], + }); + try { + const canisterId = await pic.createCanister({ + sender: CONTROLLER_PRINCIPAL, + controllers: [CONTROLLER_PRINCIPAL], + }); + await pic.installCode({ + canisterId, + wasm, + sender: CONTROLLER_PRINCIPAL, + }); + + const senderInfo = { + info: new Uint8Array([1, 2, 3, 4]), + // The signer must be a valid canister id; the target canister is one. + signer: canisterId, + }; + + const arg = new Uint8Array(IDL.encode([], [])); + + const queryRes = await pic.queryCall({ + canisterId, + method: 'get_time', + arg, + senderInfo, + }); + expect(IDL.decode([IDL.Int], queryRes)[0]).toBeGreaterThan(0n); + + const updateRes = await pic.updateCall({ + canisterId, + method: 'get_time', + arg, + senderInfo, + }); + expect(IDL.decode([IDL.Int], updateRes)[0]).toBeGreaterThan(0n); + } finally { + await pic.tearDown(); + } + }); +}); From 6a76ef9d4d98af5430b309ff021021b4beb8b7b0 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 8 Jun 2026 13:23:05 -0700 Subject: [PATCH 2/3] copilot comments --- packages/pic/tests/src/disable-ingress-validation.spec.ts | 1 - packages/pic/tests/src/sender-info.spec.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/packages/pic/tests/src/disable-ingress-validation.spec.ts b/packages/pic/tests/src/disable-ingress-validation.spec.ts index 3a3a4e8..7a4e6eb 100644 --- a/packages/pic/tests/src/disable-ingress-validation.spec.ts +++ b/packages/pic/tests/src/disable-ingress-validation.spec.ts @@ -1,5 +1,4 @@ import { Cbor } from '@icp-sdk/core/agent'; -import { Principal } from '@icp-sdk/core/principal'; import { PocketIc, SubnetStateType, generateRandomIdentity } from '../../src'; // A minimal Candid-encoded empty argument tuple (`DIDL` magic + 0 type/arg counts). diff --git a/packages/pic/tests/src/sender-info.spec.ts b/packages/pic/tests/src/sender-info.spec.ts index f44621b..9d97dfc 100644 --- a/packages/pic/tests/src/sender-info.spec.ts +++ b/packages/pic/tests/src/sender-info.spec.ts @@ -3,10 +3,6 @@ import { gunzipSync } from 'node:zlib'; import path from 'node:path'; import { IDL } from '@icp-sdk/core/candid'; import { PocketIc, SubnetStateType, generateRandomIdentity } from '../../src'; -import { - _SERVICE as TestCanister, - idlFactory, -} from '../test-canister/declarations/test_canister.did'; const WASM_PATH = path.resolve( __dirname, From 8b5271493aac63a8a97f98c8ddc2c88a8e3e8da9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 9 Jun 2026 08:47:12 -0700 Subject: [PATCH 3/3] Clarify docs --- packages/pic/src/pocket-ic-types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index 4cc7f6c..43fd887 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -908,12 +908,12 @@ export interface CanisterStatusResult { //#region CanisterCall /** - * Signed sender information attached to a canister call. + * Sender information attached to a canister call. * * This is passed through to the canister, which can inspect it via the * `msg_caller_info_data` and `msg_caller_info_signer` system APIs. PocketIC - * does not validate the signature over `info`, but preserves both fields for - * canister inspection. + * does not validate or verify anything; it simply forwards both fields for + * canister inspection, mocking the signer's signature. * * @category Types * @see [Principal](https://js.icp.build/core/latest/libs/principal/api/classes/principal/) @@ -925,7 +925,7 @@ export interface SenderInfo { info: Uint8Array; /** - * The Principal of the signer of {@link info}. + * The Principal of the canister whose signature will be mocked. */ signer: Principal; } @@ -965,7 +965,7 @@ export interface QueryCallOptions { targetSubnetId?: Principal; /** - * Signed sender information to attach to the call, see {@link SenderInfo}. + * Sender information to attach to the call, see {@link SenderInfo}. */ senderInfo?: SenderInfo; } @@ -1006,7 +1006,7 @@ export interface UpdateCallOptions { targetSubnetId?: Principal; /** - * Signed sender information to attach to the call, see {@link SenderInfo}. + * Sender information to attach to the call, see {@link SenderInfo}. */ senderInfo?: SenderInfo; }