Skip to content
Draft
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
15 changes: 15 additions & 0 deletions src/frontend/src/lib/api/signer.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
SignWithSchnorrParams
} from '$lib/types/api';
import type { CanisterApiFunctionParams } from '$lib/types/canister';
import { simulateSignerFailureIfEnabled } from '$lib/utils/signer-failure-simulator.utils';
import { assertNonNullish } from '@dfinity/utils';

const signerApi = new CanisterApi<SignerCanister>();
Expand Down Expand Up @@ -74,6 +75,8 @@ export const signTransaction = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.ETH_SIGN_TRANSACTION,
fn: async () => {
simulateSignerFailureIfEnabled();

const { signTransaction } = await signerCanister({ identity });

return signTransaction({ transaction });
Expand All @@ -87,6 +90,8 @@ export const signBtc = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.BTC_CALLER_SIGN,
fn: async () => {
simulateSignerFailureIfEnabled();

const { signBtc } = await signerCanister({ identity });

return signBtc(params);
Expand All @@ -100,6 +105,8 @@ export const signMessage = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.ETH_PERSONAL_SIGN,
fn: async () => {
simulateSignerFailureIfEnabled();

const { personalSign } = await signerCanister({ identity });

return personalSign({ message });
Expand All @@ -115,6 +122,8 @@ export const signPrehash = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.ETH_SIGN_PREHASH,
fn: async () => {
simulateSignerFailureIfEnabled();

const { signPrehash } = await signerCanister({ identity });

return signPrehash({ hash });
Expand All @@ -128,6 +137,8 @@ export const sendBtc = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.BTC_CALLER_SEND,
fn: async () => {
simulateSignerFailureIfEnabled();

const { sendBtc } = await signerCanister({ identity });

return sendBtc(params);
Expand All @@ -154,6 +165,8 @@ export const signWithSchnorr = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.SCHNORR_SIGN,
fn: async () => {
simulateSignerFailureIfEnabled();

const { signWithSchnorr } = await signerCanister({ identity });

return await signWithSchnorr(rest);
Expand All @@ -167,6 +180,8 @@ export const genericSignWithEcdsa = ({
withCfsSignTracking({
method: PLAUSIBLE_EVENT_SUBCONTEXT_CFS.GENERIC_SIGN_WITH_ECDSA,
fn: async () => {
simulateSignerFailureIfEnabled();

const { genericSignWithEcdsa } = await signerCanister({ identity });

return await genericSignWithEcdsa(rest);
Expand Down
90 changes: 90 additions & 0 deletions src/frontend/src/lib/utils/signer-failure-simulator.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { browser } from '$app/environment';
import type { PaymentError } from '$declarations/signer/signer.did';
import { CanisterInternalError } from '$lib/canisters/errors';
import { SignerCanisterPaymentError } from '$lib/canisters/signer.errors';
import { LOCAL, STAGING } from '$lib/constants/app.constants';
import { Principal } from '@dfinity/principal';
import { isNullish } from '@dfinity/utils';

/**
* DEMO ONLY — this file ships on a branch that is NOT intended to be merged.
*
* Forces the chain-fusion-signer API (`signer.api.ts`) to throw a chosen error so the
* signer-unavailable toast (PR #13145) and the fallback error paths can be reproduced
* end-to-end — including the `cfs_sign` Plausible event (PR #13144), which is emitted
* because the error is thrown from inside the tracked signer call — without needing an
* actual backend cycles outage.
*
* Only active locally or on a test/staging build (never on the `ic` production build),
* and only when explicitly opted in via:
* - URL query param: `?simulate_signer_failure=payment`
* - or localStorage: `localStorage.setItem('OISY_SIMULATE_SIGNER_FAILURE', 'payment')`
*
* The query param wins over localStorage. Set the value to `off` (or clear it) to disable.
*
* Modes: `payment` (backend out of cycles → "signer unavailable"), `allowance` (exhausted
* per-user allowance → "signing limit reached"), `internal` / `signing` (generic non-payment
* signer errors → fallback toast).
*/
export type SimulatedSignerFailureMode = 'payment' | 'allowance' | 'internal' | 'signing';

const SIMULATED_SIGNER_FAILURE_MODES: SimulatedSignerFailureMode[] = [
'payment',
'allowance',
'internal',
'signing'
];

const SIMULATE_SIGNER_FAILURE_KEY = 'OISY_SIMULATE_SIGNER_FAILURE';

const readSimulatedSignerFailureMode = (): SimulatedSignerFailureMode | undefined => {
// Never simulate on production builds, in non-browser contexts, or when not opted in.
if (!browser || !(LOCAL || STAGING)) {
return undefined;
}

const fromQuery = new URLSearchParams(window.location.search).get('simulate_signer_failure');
const fromStorage = localStorage.getItem(SIMULATE_SIGNER_FAILURE_KEY);
const value = fromQuery ?? fromStorage ?? undefined;

return SIMULATED_SIGNER_FAILURE_MODES.find((mode) => mode === value);
};

/**
* Throws the configured simulated signer error, or does nothing when simulation is off.
* Call this at the start of a paid signer call so the thrown error follows the exact same
* code path a real chain-fusion-signer error would.
*/
export const simulateSignerFailureIfEnabled = () => {
const mode = readSimulatedSignerFailureMode();

if (isNullish(mode)) {
return;
}

if (mode === 'payment') {
// `SignerCanisterPaymentError` → the friendly "signer unavailable" toast.
// The InsufficientFunds figures mirror the real incident (≈0.046 TC available).
throw new SignerCanisterPaymentError({
InsufficientFunds: { needed: 100_000_000n, available: 45_738_950n }
});
}

if (mode === 'allowance') {
// Exhausted per-user ICRC-2 allowance → the "signing limit reached" toast.
throw new SignerCanisterPaymentError({
LedgerWithdrawFromError: {
error: { InsufficientAllowance: { allowance: 1_000n } },
ledger: Principal.anonymous()
}
} as unknown as PaymentError);
}

if (mode === 'signing') {
// A non-payment signer error (e.g. a threshold-signing failure) → fallback toast.
throw new CanisterInternalError('Simulated signer SigningError (demo)');
}

// `internal` → a generic signer InternalError → fallback toast.
throw new CanisterInternalError('Simulated signer InternalError (demo)');
};
Loading