Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/pic/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
20 changes: 19 additions & 1 deletion packages/pic/src/pocket-ic-client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -30,6 +30,7 @@ export interface CreateInstanceRequest {
ingressMaxRetries?: number;
icpConfig?: IcpConfig;
icpFeatures?: IcpFeatures;
disableIngressValidation?: boolean;
}

export interface SubnetConfig<
Expand Down Expand Up @@ -117,6 +118,7 @@ export interface EncodedCreateInstanceRequest {
subnet_config_set: EncodedCreateInstanceSubnetConfig;
icp_config?: EncodedIcpConfig;
icp_features?: EncodedIcpFeatures;
disable_ingress_validation?: boolean;
}

export interface EncodedCreateInstanceSubnetConfig {
Expand Down Expand Up @@ -331,6 +333,7 @@ export function encodeCreateInstanceRequest(
icp_features: defaultOptions.icpFeatures
? encodeIcpFeatures(defaultOptions.icpFeatures)
: undefined,
disable_ingress_validation: defaultOptions.disableIngressValidation,
};

if (
Expand Down Expand Up @@ -981,6 +984,7 @@ export interface CanisterCallRequest {
method: string;
payload: Uint8Array;
effectivePrincipal?: EffectivePrincipal;
senderInfo?: SenderInfo;
}

export type EffectivePrincipal =
Expand All @@ -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 =
Expand Down Expand Up @@ -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),
};
}

Expand Down
45 changes: 45 additions & 0 deletions packages/pic/src/pocket-ic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -895,6 +907,29 @@ export interface CanisterStatusResult {

//#region CanisterCall

/**
* 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 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/)
*/
export interface SenderInfo {
/**
* An arbitrary binary blob of sender information.
*/
info: Uint8Array;

/**
* The Principal of the canister whose signature will be mocked.
*/
signer: Principal;
}

/**
* Options for making a query call to a given canister.
*
Expand Down Expand Up @@ -928,6 +963,11 @@ export interface QueryCallOptions {
* The ID of the subnet that the canister resides on.
*/
targetSubnetId?: Principal;

/**
* Sender information to attach to the call, see {@link SenderInfo}.
*/
senderInfo?: SenderInfo;
}

/**
Expand Down Expand Up @@ -964,6 +1004,11 @@ export interface UpdateCallOptions {
* The ID of the subnet that the canister resides on.
*/
targetSubnetId?: Principal;

/**
* Sender information to attach to the call, see {@link SenderInfo}.
*/
senderInfo?: SenderInfo;
}

//#endregion CanisterCall
Expand Down
4 changes: 4 additions & 0 deletions packages/pic/src/pocket-ic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ export class PocketIc {
arg = new Uint8Array(),
sender = Principal.anonymous(),
targetSubnetId,
senderInfo,
}: QueryCallOptions): Promise<Uint8Array> {
const res = await this.client.queryCall({
canisterId,
Expand All @@ -854,6 +855,7 @@ export class PocketIc {
subnetId: targetSubnetId,
}
: undefined,
senderInfo,
});

return res.body;
Expand Down Expand Up @@ -896,6 +898,7 @@ export class PocketIc {
arg = new Uint8Array(),
sender = Principal.anonymous(),
targetSubnetId,
senderInfo,
}: UpdateCallOptions): Promise<Uint8Array> {
const res = await this.client.updateCall({
canisterId,
Expand All @@ -907,6 +910,7 @@ export class PocketIc {
subnetId: targetSubnetId,
}
: undefined,
senderInfo,
});

return res.body;
Expand Down
93 changes: 93 additions & 0 deletions packages/pic/tests/src/disable-ingress-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Cbor } from '@icp-sdk/core/agent';
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<Response> {
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();
}
});
});
70 changes: 70 additions & 0 deletions packages/pic/tests/src/sender-info.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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';

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();
}
});
});
Loading