Skip to content

Commit 9bc6d44

Browse files
authored
Merge pull request #1464 from input-output-hk/fix/messaging-disconnect-recover
fix: expect disconnects during remote api method call
2 parents 6209d90 + 1171fed commit 9bc6d44

File tree

7 files changed

+105
-51
lines changed

7 files changed

+105
-51
lines changed

packages/e2e/test/web-extension/extension/background/cip30.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cip30 } from '@cardano-sdk/web-extension';
22
import { runtime } from 'webextension-polyfill';
33
import { cip30 as walletCip30 } from '@cardano-sdk/wallet';
44

5+
import { NEVER } from 'rxjs';
56
import { authenticator } from './authenticator';
67
import { logger } from '../util';
78
import { wallet$ } from './walletManager';
@@ -12,12 +13,12 @@ const confirmationCallback: walletCip30.CallbackConfirmation = {
1213
signData: async ({ sender }) => {
1314
if (!sender) throw new Error('No sender context');
1415
logger.info('signData request from', sender);
15-
return true;
16+
return { cancel$: NEVER };
1617
},
1718
signTx: async ({ sender }) => {
1819
if (!sender) throw new Error('No sender context');
1920
logger.info('signTx request', sender);
20-
return true;
21+
return { cancel$: NEVER };
2122
},
2223
submitTx: async () => true
2324
};

packages/wallet/src/cip30.ts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
2222
import { InputSelectionError, InputSelectionFailure } from '@cardano-sdk/input-selection';
2323
import { Logger } from 'ts-log';
2424
import { MessageSender } from '@cardano-sdk/key-management';
25-
import { Observable, firstValueFrom, map } from 'rxjs';
25+
import { Observable, firstValueFrom, from, map, mergeMap, race, throwError } from 'rxjs';
2626
import { ObservableWallet, isKeyHashAddress, isScriptAddress } from './types';
2727
import { requiresForeignSignatures } from './services';
2828
import uniq from 'lodash/uniq.js';
@@ -70,14 +70,20 @@ export type GetCollateralCallbackParams = {
7070

7171
type GetCollateralCallback = (args: GetCollateralCallbackParams) => Promise<Cardano.Utxo[]>;
7272

73+
export type SignConfirmationOk = { cancel$: Observable<void> };
74+
export type SignConfirmationResult = SignConfirmationOk | false;
75+
76+
const signOrCancel = <T>(result: Promise<T>, { cancel$ }: SignConfirmationOk, createError: () => Error) =>
77+
firstValueFrom(race(from(result), cancel$.pipe(mergeMap(() => throwError(createError)))));
78+
7379
export type CallbackConfirmation = {
74-
signData: (args: SignDataCallbackParams) => Promise<boolean>;
75-
signTx: (args: SignTxCallbackParams) => Promise<boolean>;
80+
signData: (args: SignDataCallbackParams) => Promise<SignConfirmationResult>;
81+
signTx: (args: SignTxCallbackParams) => Promise<SignConfirmationResult>;
7682
submitTx: (args: SubmitTxCallbackParams) => Promise<boolean>;
7783
getCollateral?: GetCollateralCallback;
7884
};
7985

80-
const mapCallbackFailure = (err: unknown, logger: Logger) => {
86+
const mapCallbackFailure = (err: unknown, logger: Logger): false => {
8187
logger.error(err);
8288
return false;
8389
};
@@ -444,7 +450,7 @@ const baseCip30WalletApi = (
444450
const hexBlobPayload = HexBlob(payload);
445451
const signWith = Cardano.DRepID.isValid(addr) ? Cardano.DRepID(addr) : Cardano.PaymentAddress(addr);
446452

447-
const shouldProceed = await confirmationCallback
453+
const confirmationResult = await confirmationCallback
448454
.signData({
449455
data: {
450456
addr: signWith,
@@ -455,13 +461,17 @@ const baseCip30WalletApi = (
455461
})
456462
.catch((error) => mapCallbackFailure(error, logger));
457463

458-
if (shouldProceed) {
464+
if (confirmationResult) {
459465
const wallet = await firstValueFrom(wallet$);
460-
return wallet.signData({
461-
payload: hexBlobPayload,
462-
sender,
463-
signWith
464-
});
466+
return signOrCancel(
467+
wallet.signData({
468+
payload: hexBlobPayload,
469+
sender,
470+
signWith
471+
}),
472+
confirmationResult,
473+
() => new DataSignError(DataSignErrorCode.UserDeclined, 'user declined signing')
474+
);
465475
}
466476
logger.debug('sign data declined');
467477
throw new DataSignError(DataSignErrorCode.UserDeclined, 'user declined signing');
@@ -478,44 +488,48 @@ const baseCip30WalletApi = (
478488

479489
// If partialSign is false and the wallet could not sign the entire transaction
480490
if (!partialSign && needsForeignSignature)
481-
throw new DataSignError(
482-
DataSignErrorCode.ProofGeneration,
491+
throw new TxSignError(
492+
TxSignErrorCode.ProofGeneration,
483493
'The wallet does not have the secret key associated with some of the inputs or certificates.'
484494
);
485495

486-
const shouldProceed = await confirmationCallback
496+
const confirmationResult = await confirmationCallback
487497
.signTx({
488498
data: coreTx,
489499
sender,
490500
type: Cip30ConfirmationCallbackType.SignTx
491501
})
492502
.catch((error) => mapCallbackFailure(error, logger));
493-
if (shouldProceed) {
503+
if (confirmationResult) {
494504
try {
495505
const {
496506
witness: { signatures }
497-
} = await wallet.finalizeTx({
498-
bodyCbor: txDecoded.body().toCbor(),
499-
signingContext: { sender },
500-
tx: { ...coreTx, hash }
501-
});
507+
} = await signOrCancel(
508+
wallet.finalizeTx({
509+
bodyCbor: txDecoded.body().toCbor(),
510+
signingContext: { sender },
511+
tx: { ...coreTx, hash }
512+
}),
513+
confirmationResult,
514+
() => new TxSignError(TxSignErrorCode.UserDeclined, 'user declined signing tx')
515+
);
502516

503517
// If partialSign is true, the wallet only tries to sign what it can. However, if
504518
// signatures size is 0 then throw.
505519
if (partialSign && signatures.size === 0) {
506-
throw new DataSignError(
507-
DataSignErrorCode.ProofGeneration,
520+
throw new TxSignError(
521+
TxSignErrorCode.ProofGeneration,
508522
'The wallet does not have the secret key associated with any of the inputs and certificates.'
509523
);
510524
}
511525

512526
const cbor = Serialization.TransactionWitnessSet.fromCore({ signatures }).toCbor();
513527
return Promise.resolve(cbor);
514528
} catch (error) {
515-
logger.error(error);
516-
if (error instanceof DataSignError) {
529+
if (error instanceof TxSignError) {
517530
throw error;
518531
} else {
532+
logger.error(error);
519533
const message = formatUnknownError(error);
520534
throw new TxSignError(TxSignErrorCode.UserDeclined, message);
521535
}

packages/wallet/test/integration/cip30mapping.test.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SenderContext,
1212
TxSendError,
1313
TxSignError,
14+
TxSignErrorCode,
1415
WalletApi,
1516
WithSenderContext
1617
} from '@cardano-sdk/dapp-connector';
@@ -29,10 +30,10 @@ import {
2930
import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
3031
import { InMemoryUnspendableUtxoStore, createInMemoryWalletStores } from '../../src/persistence';
3132
import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction';
33+
import { NEVER, firstValueFrom, of } from 'rxjs';
3234
import { Providers, createWallet } from './util';
3335
import { address_0_0, address_1_0, rewardAccount_0, rewardAccount_1 } from '../services/ChangeAddress/testData';
3436
import { buildDRepIDFromDRepKey, signTx, waitForWalletStateSettle } from '../util';
35-
import { firstValueFrom, of } from 'rxjs';
3637
import { dummyLogger as logger } from 'ts-log';
3738
import { stakeKeyDerivationPath, testAsyncKeyAgent } from '../../../key-management/test/mocks';
3839
import uniq from 'lodash/uniq.js';
@@ -50,7 +51,7 @@ const {
5051

5152
type TestProviders = Required<Pick<Providers, 'txSubmitProvider' | 'networkInfoProvider'>>;
5253
const mockCollateralCallback = jest.fn().mockResolvedValue([mockUtxo[3]]);
53-
const createMockGenericCallback = () => jest.fn().mockResolvedValue(true);
54+
const createMockGenericCallback = <T>(result: T) => jest.fn().mockResolvedValue(result);
5455
const foreignTx = Serialization.TxCBOR(
5556
'84a70081825820dce442e983f3f5cd5b2644bc57f749075390f1fbae9ab55bf454342959c885db00018182583900d161d64eef0eeb59f9124f520f8c8f3b717ed04198d54c8b17e604aea63c153fb3ea8a4ea4f165574ea91173756de0bf30222ca0e95a649a1a0082607b021a0016360509a1581cb77934706fa311b6568d1070c2d23f092324b35ad623aa571a0e3726a14e4d6573685f476966745f43617264200b5820d8175f3b1276a48939a6ccee220a7f81b6422167317ba3ff6325cba1fb6ccbe70d818258208d68748457cd0f1a8596f41fd2125a415315897d2da4a4b94335829cee7198ae001281825820dce442e983f3f5cd5b2644bc57f749075390f1fbae9ab55bf454342959c885db00a2068259016b590168010000333232323232323223223222253330083232533300d3010002132533300b3370e6eb4c034009200113371e0020122940dd718058008b180700099299980499b8748008c028dd50008a5eb7bdb1804dd5980718059baa001323300100132330010013756601e602060206020602060186ea8c03cc030dd50019129998070008a5eb7bdb1804c8c8c8c94ccc03ccdc8a45000021533300f3371e91010000210031005133013337606ea4008dd3000998030030019bab3010003375c601c0046024004602000244a66601a002298103d87a8000132323232533300e337220140042a66601c66e3c0280084cdd2a4000660246e980052f5c02980103d87a80001330060060033756601e0066eb8c034008c044008c03c00452613656375c0026eb80055cd2ab9d5573caae7d5d02ba157449810f4e4d6573685f476966745f43617264004c011e581cb77934706fa311b6568d1070c2d23f092324b35ad623aa571a0e3726000159023c59023901000033323232323232322322232323225333009323232533300c3007300d3754002264646464a666026602c00426464a666024601a60266ea803854ccc048c034c04cdd5191980080080311299980b8008a60103d87a80001323253330163375e603660306ea800804c4cdd2a40006603400497ae0133004004001301b002301900115333012300c00113371e00402029405854ccc048cdc3800a4002266e3c0080405281bad3013002375c60220022c602800264a66601e601260206ea800452f5bded8c026eacc050c044dd500099191980080099198008009bab3016301730173017301700522533301500114bd6f7b630099191919299980b19b91488100002153330163371e9101000021003100513301a337606ea4008dd3000998030030019bab3017003375c602a0046032004602e00244a666028002298103d87a800013232323253330153372200e0042a66602a66e3c01c0084cdd2a4000660326e980052f5c02980103d87a80001330060060033756602c0066eb8c050008c060008c058004dd7180998081baa00337586024002601c6ea800858c040c044008c03c004c02cdd50008a4c26cac64a66601060060022a66601660146ea8010526161533300830020011533300b300a37540082930b0b18041baa003370e90011b8748000dd7000ab9a5573aaae7955cfaba05742ae8930010f4e4d6573685f476966745f43617264004c012bd8799fd8799f58203159a6f2ae24c5bfbed947fe0ecfe936f088c8d265484e6979cacb607d33c811ff05ff0001058284000040821a006acfc01ab2d05e00840100d87a80821a006acfc01ab2d05e00f5f6'
5657
);
@@ -74,9 +75,9 @@ const createWalletAndApiWithStores = async (
7475
wallet.utxo.available$ = of(availableUtxos);
7576
}
7677
const confirmationCallback = {
77-
signData: createMockGenericCallback(),
78-
signTx: createMockGenericCallback(),
79-
submitTx: createMockGenericCallback(),
78+
signData: createMockGenericCallback({ cancel$: NEVER }),
79+
signTx: createMockGenericCallback({ cancel$: NEVER }),
80+
submitTx: createMockGenericCallback(true),
8081
...(!!getCollateralCallback && { getCollateral: getCollateralCallback })
8182
};
8283
wallet.governance.getPubDRepKey = jest.fn(wallet.governance.getPubDRepKey);
@@ -613,12 +614,25 @@ describe('cip30', () => {
613614

614615
it('doesnt invoke confirmationCallback.signTx if an error occurs', async () => {
615616
const finalizeTxSpy = jest.spyOn(wallet, 'finalizeTx').mockClear();
616-
confirmationCallback.signTx = jest.fn().mockResolvedValueOnce(true).mockClear();
617+
confirmationCallback.signTx = jest.fn().mockResolvedValueOnce({ cancel$: NEVER }).mockClear();
617618

618619
await expect(api.signTx(context, foreignTx, false)).rejects.toThrowError();
619620
expect(finalizeTxSpy).not.toHaveBeenCalled();
620621
expect(confirmationCallback.signTx).not.toHaveBeenCalled();
621622
});
623+
624+
it('rejects with UserDeclined error if cancel$ emits before finalizeTx resolves', async () => {
625+
jest.spyOn(wallet, 'finalizeTx').mockResolvedValueOnce(
626+
new Promise(() => {
627+
// never resolves or rejects
628+
})
629+
);
630+
confirmationCallback.signTx = jest.fn().mockResolvedValueOnce({ cancel$: of(void 0) });
631+
632+
await expect(api.signTx(context, hexTx)).rejects.toThrowError(
633+
expect.objectContaining({ code: TxSignErrorCode.UserDeclined })
634+
);
635+
});
622636
});
623637

624638
describe('api.signData', () => {
@@ -661,6 +675,20 @@ describe('cip30', () => {
661675
);
662676
expect(confirmationCallback.signData).toBeCalledWith(expect.objectContaining({ sender: context.sender }));
663677
});
678+
679+
it('rejects with UserDeclined error if cancel$ emits before finalizeTx resolves', async () => {
680+
const [{ address }] = await firstValueFrom(wallet.addresses$);
681+
jest.spyOn(wallet, 'signData').mockResolvedValueOnce(
682+
new Promise(() => {
683+
// never resolves or rejects
684+
})
685+
);
686+
confirmationCallback.signData = jest.fn().mockResolvedValueOnce({ cancel$: of(void 0) });
687+
688+
await expect(api.signData(context, address, HexBlob('abc123'))).rejects.toThrowError(
689+
expect.objectContaining({ code: DataSignErrorCode.UserDeclined })
690+
);
691+
});
664692
});
665693

666694
describe('api.submitTx', () => {
@@ -741,8 +769,8 @@ describe('cip30', () => {
741769
describe('signData', () => {
742770
const payload = 'abc123';
743771

744-
test('resolves true', async () => {
745-
confirmationCallback.signData = jest.fn().mockResolvedValueOnce(true);
772+
test('resolves ok', async () => {
773+
confirmationCallback.signData = jest.fn().mockResolvedValueOnce({ cancel$: NEVER });
746774
await expect(api.signData(context, address, payload)).resolves.not.toThrow();
747775
});
748776

@@ -757,7 +785,7 @@ describe('cip30', () => {
757785
});
758786

759787
test('gets the Cardano.Address equivalent of the hex address', async () => {
760-
confirmationCallback.signData = jest.fn().mockResolvedValueOnce(true);
788+
confirmationCallback.signData = jest.fn().mockResolvedValueOnce({ cancel$: NEVER });
761789

762790
const hexAddr = Cardano.Address.fromBech32(address).toBytes();
763791

@@ -776,8 +804,8 @@ describe('cip30', () => {
776804
hexTx = Serialization.Transaction.fromCore(finalizedTx).toCbor();
777805
});
778806

779-
test('resolves true', async () => {
780-
confirmationCallback.signTx = jest.fn().mockResolvedValueOnce(true);
807+
test('resolves ok', async () => {
808+
confirmationCallback.signTx = jest.fn().mockResolvedValueOnce({ cancel$: NEVER });
781809
await expect(api.signTx(context, hexTx)).resolves.not.toThrow();
782810
});
783811

@@ -869,8 +897,8 @@ describe('cip30', () => {
869897
mockApi = cip30.createWalletApi(
870898
of(mockWallet),
871899
{
872-
signData: jest.fn().mockResolvedValue(true),
873-
signTx: jest.fn().mockResolvedValue(true),
900+
signData: jest.fn().mockResolvedValue({ cancel$: NEVER }),
901+
signTx: jest.fn().mockResolvedValue({ cancel$: NEVER }),
874902
submitTx: jest.fn().mockResolvedValue(true)
875903
},
876904
{ logger }

packages/web-extension/src/messaging/BackgroundMessenger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export const createBackgroundMessenger = ({ logger, runtime }: MessengerDependen
5353
message$.next({ data, port });
5454
};
5555
const onPortDisconnected = (port: MessengerPort) => {
56+
if (runtime.lastError) {
57+
logger.warn(`[BackgroundMessenger(${port.name})] Last runtime error`, runtime.lastError);
58+
}
5659
port.onMessage.removeListener(onPortMessage);
5760
port.onDisconnect.removeListener(onPortDisconnected);
5861
const { ports$ } = channels.get(port.name)!;

packages/web-extension/src/messaging/NonBackgroundMessenger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ export const createNonBackgroundMessenger = (
7373
message$.next({ data, port });
7474
};
7575
const onDisconnect = (port: MessengerPort) => {
76+
if (runtime.lastError) {
77+
logger.warn(`[NonBackgroundMessenger(${channel})] Last runtime error`, runtime.lastError);
78+
}
7679
disconnect$.next({
7780
disconnected: port,
7881
remaining: []

packages/web-extension/src/messaging/remoteApi.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import {
2121
import { CustomError } from 'ts-custom-error';
2222
import {
2323
EMPTY,
24-
EmptyError,
2524
NEVER,
2625
Observable,
2726
Subscription,
2827
TeardownLogic,
2928
concat,
29+
defaultIfEmpty,
3030
filter,
3131
firstValueFrom,
3232
from,
@@ -39,10 +39,10 @@ import {
3939
switchMap,
4040
takeUntil,
4141
tap,
42-
throwError
42+
timeout
4343
} from 'rxjs';
4444
import { ErrorClass, Shutdown, fromSerializableObject, isPromise, toSerializableObject } from '@cardano-sdk/util';
45-
import { NotImplementedError } from '@cardano-sdk/core';
45+
import { Milliseconds, NotImplementedError } from '@cardano-sdk/core';
4646
import { TrackerSubject } from '@cardano-sdk/util-rxjs';
4747
import { WrongTargetError } from './errors';
4848
import {
@@ -66,7 +66,7 @@ export class RemoteApiShutdownError extends CustomError {
6666
const consumeMethod =
6767
(
6868
{ propName, errorTypes }: { propName: string; errorTypes?: ErrorClass[]; options?: MethodRequestOptions },
69-
{ messenger: { message$, postMessage, channel, disconnect$ } }: MessengerApiDependencies
69+
{ messenger: { message$, channel, postMessage, disconnect$, connect$ }, logger }: MessengerApiDependencies
7070
) =>
7171
async (...args: unknown[]) => {
7272
const requestMessage: RequestMessage = {
@@ -87,17 +87,21 @@ const consumeMethod =
8787
map(({ response }) => response),
8888
filter((response) => !(response instanceof WrongTargetError))
8989
),
90+
// We might encounter unexpected disconnects between method call and response
9091
disconnect$.pipe(
9192
filter((dc) => dc.remaining.length === 0),
92-
mergeMap(() => throwError(() => new EmptyError()))
93+
tap(() => logger.warn(`API disconnected before "${propName}" resolved. Expecting reconnect.`)),
94+
switchMap(() =>
95+
connect$.pipe(
96+
tap(() => logger.warn(`Reconnected. Waiting for "${propName}" response...`)),
97+
// It usually reconnects in about 1 second. 10 should be more than enough to know that it won't re-connect.
98+
timeout({ first: Milliseconds(10_000), with: () => of(new RemoteApiShutdownError(channel)) }),
99+
mergeMap((value) => (value instanceof RemoteApiShutdownError ? of(value) : EMPTY))
100+
)
101+
)
93102
)
94-
)
95-
).catch((error) => {
96-
if (error instanceof EmptyError) {
97-
throw new RemoteApiShutdownError(channel);
98-
}
99-
throw error;
100-
});
103+
).pipe(defaultIfEmpty(new RemoteApiShutdownError(channel)))
104+
);
101105

102106
if (result instanceof Error) {
103107
throw result;

packages/web-extension/src/messaging/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface MessengerPort {
6060
export interface MinimalRuntime {
6161
connect(connectInfo: Runtime.ConnectConnectInfoType): MessengerPort;
6262
onConnect: MinimalEvent<(port: MessengerPort) => void>;
63+
lastError?: unknown;
6364
}
6465

6566
export interface MessengerDependencies {

0 commit comments

Comments
 (0)