diff --git a/packages/dapp-connector/src/AuthenticatorApi/PersistentAuthenticator.ts b/packages/dapp-connector/src/AuthenticatorApi/PersistentAuthenticator.ts index d35c748ca5c..427a8731bd3 100644 --- a/packages/dapp-connector/src/AuthenticatorApi/PersistentAuthenticator.ts +++ b/packages/dapp-connector/src/AuthenticatorApi/PersistentAuthenticator.ts @@ -1,6 +1,8 @@ import { AuthenticatorApi, Origin, RequestAccess } from './types'; import { Logger } from 'ts-log'; import { PersistentAuthenticatorStorage } from './PersistentAuthenticatorStorage'; +import { Runtime } from 'webextension-polyfill'; +import { senderOrigin } from '../util'; export interface PersistentAuhenticatorOptions { requestAccess: RequestAccess; @@ -32,13 +34,18 @@ export class PersistentAuthenticator implements AuthenticatorApi { this.#originsReady = storage.get(); } - async requestAccess(origin: Origin) { + async requestAccess(sender: Runtime.MessageSender) { + const origin = senderOrigin(sender); + if (!origin) { + this.#logger.warn('Invalid sender url', sender); + return false; + } const origins = await this.#originsReady; if (origins.includes(origin)) { return true; } try { - const accessGranted = await this.#requestAccess(origin); + const accessGranted = await this.#requestAccess(sender); if (accessGranted) { const newOrigins = [...origins, origin]; if (await this.#store(newOrigins)) { @@ -52,7 +59,12 @@ export class PersistentAuthenticator implements AuthenticatorApi { return false; } - async revokeAccess(origin: Origin) { + async revokeAccess(sender: Runtime.MessageSender) { + const origin = senderOrigin(sender); + if (!origin) { + this.#logger.warn('Invalid sender url', sender); + return false; + } const origins = await this.#originsReady; const idx = origins.indexOf(origin); if (idx >= 0) { @@ -67,7 +79,8 @@ export class PersistentAuthenticator implements AuthenticatorApi { return false; } - async haveAccess(origin: Origin) { + async haveAccess(sender: Runtime.MessageSender) { + const origin = senderOrigin(sender); if (!origin) return false; const origins = await this.#originsReady; return origins.includes(origin); diff --git a/packages/dapp-connector/src/AuthenticatorApi/types.ts b/packages/dapp-connector/src/AuthenticatorApi/types.ts index d759700d99d..04d68b2002b 100644 --- a/packages/dapp-connector/src/AuthenticatorApi/types.ts +++ b/packages/dapp-connector/src/AuthenticatorApi/types.ts @@ -1,7 +1,9 @@ +import { Runtime } from 'webextension-polyfill'; + export type Origin = string; /** Resolve true to authorise access to the WalletAPI, or resolve false to deny. Errors: `ApiError` */ -export type RequestAccess = (origin: Origin) => Promise; +export type RequestAccess = (sender: Runtime.MessageSender) => Promise; export type RevokeAccess = RequestAccess; export type HaveAccess = RequestAccess; diff --git a/packages/dapp-connector/src/WalletApi/types.ts b/packages/dapp-connector/src/WalletApi/types.ts index cd82c3a80c9..b75a246712e 100644 --- a/packages/dapp-connector/src/WalletApi/types.ts +++ b/packages/dapp-connector/src/WalletApi/types.ts @@ -1,6 +1,7 @@ import { Cardano } from '@cardano-sdk/core'; import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto'; import { HexBlob } from '@cardano-sdk/util'; +import { Runtime } from 'webextension-polyfill'; /** A hex-encoded string of the corresponding bytes. */ export type Bytes = string; @@ -212,3 +213,10 @@ export interface CipExtensionApis { } export type Cip30WalletApiWithPossibleExtensions = Cip30WalletApi & Partial; + +export type SenderContext = { sender: Runtime.MessageSender }; +type FnWithSender = T extends (...args: infer Args) => infer R ? (context: SenderContext, ...args: Args) => R : T; + +export type WithSenderContext = { + [K in keyof T]: FnWithSender; +}; diff --git a/packages/dapp-connector/src/index.ts b/packages/dapp-connector/src/index.ts index 88914a06af7..6381a0b4780 100644 --- a/packages/dapp-connector/src/index.ts +++ b/packages/dapp-connector/src/index.ts @@ -2,3 +2,4 @@ export * from './errors'; export * from './WalletApi'; export * from './AuthenticatorApi'; export * from './injectGlobal'; +export * from './util'; diff --git a/packages/dapp-connector/src/util.ts b/packages/dapp-connector/src/util.ts new file mode 100644 index 00000000000..33e34977850 --- /dev/null +++ b/packages/dapp-connector/src/util.ts @@ -0,0 +1,10 @@ +import { Runtime } from 'webextension-polyfill'; + +export const senderOrigin = (sender?: Runtime.MessageSender): string | null => { + try { + const { origin } = new URL(sender?.url || 'throw'); + return origin; + } catch { + return null; + } +}; diff --git a/packages/dapp-connector/test/AuthenticatorApi/PersistentAuthenticator.test.ts b/packages/dapp-connector/test/AuthenticatorApi/PersistentAuthenticator.test.ts index 70fb772a1f1..13f3abf6a69 100644 --- a/packages/dapp-connector/test/AuthenticatorApi/PersistentAuthenticator.test.ts +++ b/packages/dapp-connector/test/AuthenticatorApi/PersistentAuthenticator.test.ts @@ -1,4 +1,5 @@ -import { Origin, PersistentAuthenticator } from '../../src'; +import { Origin, PersistentAuthenticator, senderOrigin } from '../../src'; +import { Runtime } from 'webextension-polyfill'; import { dummyLogger } from 'ts-log'; const createStubStorage = () => { @@ -12,8 +13,8 @@ const createStubStorage = () => { }; describe('PersistentAuthenticator', () => { - const origin1: Origin = 'origin1'; - const origin2: Origin = 'origin2'; + const sender1: Runtime.MessageSender = { url: 'https://sender1.com' }; + const sender2: Runtime.MessageSender = { url: 'https://sender2.com' }; let requestAccess: jest.Mock; let storage: ReturnType; let authenticator: PersistentAuthenticator; @@ -28,35 +29,35 @@ describe('PersistentAuthenticator', () => { describe('requestAccess', () => { it('resolves to true if allowed and persists the decision', async () => { requestAccess.mockResolvedValueOnce(true); - expect(await authenticator.requestAccess(origin1)).toBe(true); - expect(await authenticator.requestAccess(origin1)).toBe(true); + expect(await authenticator.requestAccess(sender1)).toBe(true); + expect(await authenticator.requestAccess(sender1)).toBe(true); expect(requestAccess).toBeCalledTimes(1); - expect(await storage.get()).toContain(origin1); + expect(await storage.get()).toContain(senderOrigin(sender1)); }); it('resolves to false if denied an error and does not persist the decision', async () => { requestAccess.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - expect(await authenticator.requestAccess(origin1)).toBe(false); - expect(await storage.get()).not.toContain(origin1); - expect(await authenticator.requestAccess(origin1)).toBe(true); - expect(await storage.get()).toContain(origin1); + expect(await authenticator.requestAccess(sender1)).toBe(false); + expect(await storage.get()).not.toContain(senderOrigin(sender1)); + expect(await authenticator.requestAccess(sender1)).toBe(true); + expect(await storage.get()).toContain(senderOrigin(sender1)); expect(requestAccess).toBeCalledTimes(2); }); it('resolves to false if any error is encountered and does not persist the decision', async () => { requestAccess.mockResolvedValue(true).mockRejectedValueOnce(new Error('any error')); - expect(await authenticator.requestAccess(origin1)).toBe(false); - expect(await storage.get()).not.toContain(origin1); + expect(await authenticator.requestAccess(sender1)).toBe(false); + expect(await storage.get()).not.toContain(senderOrigin(sender1)); storage.set.mockResolvedValue(void 0).mockRejectedValueOnce(new Error('any error')); - expect(await authenticator.requestAccess(origin1)).toBe(false); - expect(await storage.get()).not.toContain(origin1); + expect(await authenticator.requestAccess(sender1)).toBe(false); + expect(await storage.get()).not.toContain(senderOrigin(sender1)); }); it('caches storage by origin', async () => { requestAccess.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - expect(await authenticator.requestAccess(origin1)).toBe(true); - expect(await authenticator.requestAccess(origin2)).toBe(false); + expect(await authenticator.requestAccess(sender1)).toBe(true); + expect(await authenticator.requestAccess(sender2)).toBe(false); expect(requestAccess).toBeCalledTimes(2); }); }); @@ -64,24 +65,24 @@ describe('PersistentAuthenticator', () => { describe('revokeAccess', () => { beforeEach(async () => { requestAccess.mockResolvedValueOnce(true); - await authenticator.requestAccess(origin1); + await authenticator.requestAccess(sender1); storage.set.mockReset(); }); it('unknown origin => returns false', async () => { - expect(await authenticator.revokeAccess(origin2)).toBe(false); + expect(await authenticator.revokeAccess(sender2)).toBe(false); }); describe('allowed origin', () => { it('returns true and removes origin from cache', async () => { - expect(await authenticator.revokeAccess(origin1)).toBe(true); - expect(await authenticator.revokeAccess(origin1)).toBe(false); + expect(await authenticator.revokeAccess(sender1)).toBe(true); + expect(await authenticator.revokeAccess(sender1)).toBe(false); expect(storage.set).toBeCalledTimes(1); }); it('returns false if storage throws', async () => { storage.set.mockRejectedValueOnce(new Error('any error')); - expect(await authenticator.revokeAccess(origin1)).toBe(false); + expect(await authenticator.revokeAccess(sender1)).toBe(false); }); }); }); @@ -89,26 +90,26 @@ describe('PersistentAuthenticator', () => { describe('haveAccess', () => { beforeEach(async () => { requestAccess.mockResolvedValueOnce(true); - await authenticator.requestAccess(origin1); + await authenticator.requestAccess(sender1); }); it('unknown origin => returns false', async () => { - expect(await authenticator.haveAccess(origin2)).toBe(false); + expect(await authenticator.haveAccess(sender2)).toBe(false); }); it('allowed origin => returns true', async () => { - expect(await authenticator.haveAccess(origin1)).toBe(true); + expect(await authenticator.haveAccess(sender1)).toBe(true); }); }); describe('clear', () => { it('removes all origins', async () => { requestAccess.mockResolvedValue(true); - await authenticator.requestAccess(origin1); - await authenticator.requestAccess(origin2); + await authenticator.requestAccess(sender1); + await authenticator.requestAccess(sender2); await authenticator.clear(); - expect(await authenticator.haveAccess(origin1)).toBe(false); - expect(await authenticator.haveAccess(origin2)).toBe(false); + expect(await authenticator.haveAccess(sender1)).toBe(false); + expect(await authenticator.haveAccess(sender2)).toBe(false); }); }); }); diff --git a/packages/dapp-connector/test/util.test.ts b/packages/dapp-connector/test/util.test.ts new file mode 100644 index 00000000000..ff890d424f0 --- /dev/null +++ b/packages/dapp-connector/test/util.test.ts @@ -0,0 +1,13 @@ +import { senderOrigin } from '../src'; + +describe('util', () => { + describe('senderOrigin', () => { + it('returns null when origin url is not present', () => { + expect(senderOrigin()).toBe(null); + expect(senderOrigin({ id: 'id' })).toBe(null); + }); + it('returns origin url it is present', () => { + expect(senderOrigin({ url: 'http://origin' })).toBe('http://origin'); + }); + }); +}); diff --git a/packages/e2e/test/web-extension/extension/background/cip30.ts b/packages/e2e/test/web-extension/extension/background/cip30.ts index 54cec1aed69..90fc6e04c46 100644 --- a/packages/e2e/test/web-extension/extension/background/cip30.ts +++ b/packages/e2e/test/web-extension/extension/background/cip30.ts @@ -9,8 +9,16 @@ import { walletName } from '../const'; // this should come from remote api const confirmationCallback: walletCip30.CallbackConfirmation = { - signData: async () => true, - signTx: async () => true, + signData: async ({ sender }) => { + if (!sender) throw new Error('No sender context'); + logger.info('signData request from', sender); + return true; + }, + signTx: async ({ sender }) => { + if (!sender) throw new Error('No sender context'); + logger.info('signTx request', sender); + return true; + }, submitTx: async () => true }; diff --git a/packages/e2e/test/web-extension/extension/background/requestAccess.ts b/packages/e2e/test/web-extension/extension/background/requestAccess.ts index 3e54d1a72c4..4f904180f58 100644 --- a/packages/e2e/test/web-extension/extension/background/requestAccess.ts +++ b/packages/e2e/test/web-extension/extension/background/requestAccess.ts @@ -1,5 +1,5 @@ import { RemoteApiPropertyType, consumeRemoteApi } from '@cardano-sdk/web-extension'; -import { RequestAccess } from '@cardano-sdk/dapp-connector'; +import { RequestAccess, senderOrigin } from '@cardano-sdk/dapp-connector'; import { UserPromptService, logger } from '../util'; import { ensureUiIsOpenAndLoaded } from './windowManager'; import { runtime } from 'webextension-polyfill'; @@ -15,7 +15,9 @@ const userPromptService = consumeRemoteApi( { logger, runtime } ); -export const requestAccess: RequestAccess = async (origin) => { +export const requestAccess: RequestAccess = async (sender) => { + const origin = senderOrigin(sender); + if (!origin) throw new Error('Invalid requestAccess request: unknown sender origin'); await ensureUiIsOpenAndLoaded(); return await userPromptService.allowOrigin(origin); }; diff --git a/packages/key-management/package.json b/packages/key-management/package.json index 1930fd16fda..0505de39146 100644 --- a/packages/key-management/package.json +++ b/packages/key-management/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@types/lodash": "^4.14.182", "@types/pbkdf2": "^3.1.0", + "@types/webextension-polyfill": "^0.8.0", "eslint": "^7.32.0", "jest": "^28.1.3", "madge": "^5.0.1", diff --git a/packages/key-management/src/cip8/cip30signData.ts b/packages/key-management/src/cip8/cip30signData.ts index b0b1a59e1be..f555de706a1 100644 --- a/packages/key-management/src/cip8/cip30signData.ts +++ b/packages/key-management/src/cip8/cip30signData.ts @@ -1,5 +1,5 @@ import * as Crypto from '@cardano-sdk/crypto'; -import { AccountKeyDerivationPath, GroupedAddress, KeyRole } from '../types'; +import { AccountKeyDerivationPath, GroupedAddress, KeyRole, MessageSender } from '../types'; import { AlgorithmId, CBORValue, @@ -24,6 +24,7 @@ export interface Cip30SignDataRequest { witnesser: Bip32Ed25519Witnesser; signWith: Cardano.PaymentAddress | Cardano.RewardAccount | Cardano.DRepID; payload: HexBlob; + sender?: MessageSender; } export enum Cip30DataSignErrorCode { @@ -87,10 +88,11 @@ const createSigStructureHeaders = (addressBytes: Uint8Array) => { const signSigStructure = ( witnesser: Bip32Ed25519Witnesser, derivationPath: AccountKeyDerivationPath, - sigStructure: SigStructure + sigStructure: SigStructure, + sender?: MessageSender ) => { try { - return witnesser.signBlob(derivationPath, util.bytesToHex(sigStructure.to_bytes())); + return witnesser.signBlob(derivationPath, util.bytesToHex(sigStructure.to_bytes()), sender); } catch (error) { throw new Cip30DataSignError(Cip30DataSignErrorCode.UserDeclined, 'Failed to sign', error); } @@ -115,7 +117,8 @@ export const cip30signData = async ({ knownAddresses, witnesser, signWith, - payload + payload, + sender }: Cip30SignDataRequest): Promise => { if (Cardano.DRepID.isValid(signWith) && !Cardano.DRepID.canSign(signWith)) { throw new Cip30DataSignError(Cip30DataSignErrorCode.AddressNotPK, 'Invalid address'); @@ -129,7 +132,7 @@ export const cip30signData = async ({ false ); const sigStructure = builder.make_data_to_sign(); - const { signature, publicKey } = await signSigStructure(witnesser, derivationPath, sigStructure); + const { signature, publicKey } = await signSigStructure(witnesser, derivationPath, sigStructure, sender); const coseSign1 = builder.build(Buffer.from(signature, 'hex')); const coseKey = createCoseKey(addressBytes, publicKey); diff --git a/packages/key-management/src/types.ts b/packages/key-management/src/types.ts index c2127d91b17..9a73dad8f35 100644 --- a/packages/key-management/src/types.ts +++ b/packages/key-management/src/types.ts @@ -2,6 +2,9 @@ import * as Crypto from '@cardano-sdk/crypto'; import { Cardano } from '@cardano-sdk/core'; import { HexBlob, OpaqueString, Shutdown } from '@cardano-sdk/util'; import { Logger } from 'ts-log'; +import type { Runtime } from 'webextension-polyfill'; + +export type MessageSender = Runtime.MessageSender; export interface SignBlobResult { publicKey: Crypto.Ed25519PublicKeyHex; @@ -141,6 +144,7 @@ export interface SignTransactionOptions { export interface SignTransactionContext { txInKeyPathMap: TxInKeyPathMap; knownAddresses: GroupedAddress[]; + sender?: MessageSender; } export interface KeyAgent { @@ -231,5 +235,5 @@ export interface Witnesser { /** * @throws AuthenticationError */ - signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob): Promise; + signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob, sender?: MessageSender): Promise; } diff --git a/packages/key-management/src/util/createWitnesser.ts b/packages/key-management/src/util/createWitnesser.ts index e424c37710c..336883adc5a 100644 --- a/packages/key-management/src/util/createWitnesser.ts +++ b/packages/key-management/src/util/createWitnesser.ts @@ -1,6 +1,7 @@ import { AccountKeyDerivationPath, AsyncKeyAgent, + MessageSender, SignBlobResult, SignTransactionContext, WitnessOptions, @@ -25,7 +26,11 @@ export class Bip32Ed25519Witnesser implements Witnesser { return { signatures: await this.#keyAgent.signTransaction(txInternals, context, options) }; } - async signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob): Promise { + async signBlob( + derivationPath: AccountKeyDerivationPath, + blob: HexBlob, + _sender?: MessageSender + ): Promise { return this.#keyAgent.signBlob(derivationPath, blob); } } diff --git a/packages/key-management/test/cip8/cip30signData.test.ts b/packages/key-management/test/cip8/cip30signData.test.ts index b5b3ab09199..159489a5856 100644 --- a/packages/key-management/test/cip8/cip30signData.test.ts +++ b/packages/key-management/test/cip8/cip30signData.test.ts @@ -6,8 +6,10 @@ import { KeyAgent, util as KeyManagementUtil, KeyRole, + MessageSender, cip8 } from '../../src'; +import { Bip32Ed25519Witnesser } from '../../src/util'; import { COSEKey, COSESign1, SigStructure } from '@emurgo/cardano-message-signing-nodejs'; import { Cardano, util } from '@cardano-sdk/core'; import { CoseLabel } from '../../src/cip8/util'; @@ -17,6 +19,7 @@ import { testAsyncKeyAgent, testKeyAgent } from '../mocks'; describe('cip30signData', () => { const addressDerivationPath = { index: 0, type: AddressType.External }; let keyAgent: KeyAgent; + let witnesser: Bip32Ed25519Witnesser; let asyncKeyAgent: AsyncKeyAgent; let address: GroupedAddress; const cryptoProvider = new Crypto.SodiumBip32Ed25519(); @@ -26,17 +29,20 @@ describe('cip30signData', () => { keyAgent = await keyAgentReady; asyncKeyAgent = await testAsyncKeyAgent(undefined, keyAgentReady); address = await asyncKeyAgent.deriveAddress(addressDerivationPath, 0); + witnesser = KeyManagementUtil.createBip32Ed25519Witnesser(asyncKeyAgent); }); const signAndDecode = async ( signWith: Cardano.PaymentAddress | Cardano.RewardAccount, - knownAddresses: GroupedAddress[] + knownAddresses: GroupedAddress[], + sender?: MessageSender ) => { const dataSignature = await cip8.cip30signData({ knownAddresses, payload: HexBlob('abc123'), + sender, signWith, - witnesser: KeyManagementUtil.createBip32Ed25519Witnesser(asyncKeyAgent) + witnesser }); const coseKey = COSEKey.from_bytes(Buffer.from(dataSignature.key, 'hex')); @@ -102,4 +108,11 @@ describe('cip30signData', () => { ) ).toBe(true); }); + + it('passes through sender to witnesser', async () => { + const signBlobSpy = jest.spyOn(witnesser, 'signBlob'); + const sender = { url: 'https://lace.io' }; + await signAndDecode(address.address, [address], sender); + expect(signBlobSpy).toBeCalledWith(expect.anything(), expect.anything(), sender); + }); }); diff --git a/packages/wallet/src/PersonalWallet/PersonalWallet.ts b/packages/wallet/src/PersonalWallet/PersonalWallet.ts index 9b66c0f1718..e35cc9bc8ca 100644 --- a/packages/wallet/src/PersonalWallet/PersonalWallet.ts +++ b/packages/wallet/src/PersonalWallet/PersonalWallet.ts @@ -533,7 +533,7 @@ export class PersonalWallet implements ObservableWallet { return initializeTx(props, this.getTxBuilderDependencies()); } - async finalizeTx({ tx, ...rest }: FinalizeTxProps, stubSign = false): Promise { + async finalizeTx({ tx, sender, ...rest }: FinalizeTxProps, stubSign = false): Promise { const knownAddresses = await firstValueFrom(this.addresses$); const { tx: signedTx } = await finalizeTx( tx, @@ -541,6 +541,7 @@ export class PersonalWallet implements ObservableWallet { ...rest, signingContext: { knownAddresses, + sender, txInKeyPathMap: await util.createTxInKeyPathMap(tx.body, knownAddresses, this.util) } }, diff --git a/packages/wallet/src/cip30.ts b/packages/wallet/src/cip30.ts index 6e7e21d029d..80d2927e0f6 100644 --- a/packages/wallet/src/cip30.ts +++ b/packages/wallet/src/cip30.ts @@ -8,17 +8,20 @@ import { DataSignError, DataSignErrorCode, Paginate, + SenderContext, TxSendError, TxSendErrorCode, TxSignError, TxSignErrorCode, WalletApi, - WalletApiExtension + WalletApiExtension, + WithSenderContext } from '@cardano-sdk/dapp-connector'; import { Cardano, Serialization, TxCBOR, coalesceValueQuantities } from '@cardano-sdk/core'; import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util'; import { InputSelectionError, InputSelectionFailure } from '@cardano-sdk/input-selection'; import { Logger } from 'ts-log'; +import { MessageSender } from '@cardano-sdk/key-management'; import { Observable, firstValueFrom, map } from 'rxjs'; import { ObservableWallet } from './types'; import { requiresForeignSignatures } from './services'; @@ -36,6 +39,7 @@ export enum Cip30ConfirmationCallbackType { export type SignDataCallbackParams = { type: Cip30ConfirmationCallbackType.SignData; + sender: MessageSender; data: { addr: Cardano.PaymentAddress | Cardano.DRepID; payload: HexBlob; @@ -43,6 +47,7 @@ export type SignDataCallbackParams = { }; export type SignTxCallbackParams = { + sender: MessageSender; type: Cip30ConfirmationCallbackType.SignTx; data: Cardano.Tx; }; @@ -279,9 +284,10 @@ const baseCip30WalletApi = ( } }, // eslint-disable-next-line max-statements, sonarjs/cognitive-complexity,complexity - getCollateral: async ({ - amount = new Serialization.Value(MAX_COLLATERAL_AMOUNT).toCbor() - }: { amount?: Cbor } = {}): Promise< + getCollateral: async ( + _: SenderContext, + { amount = new Serialization.Value(MAX_COLLATERAL_AMOUNT).toCbor() }: { amount?: Cbor } = {} + ): Promise< Cbor[] | null // eslint-disable-next-line sonarjs/cognitive-complexity, max-statements > => { @@ -371,7 +377,7 @@ const baseCip30WalletApi = ( logger.debug('getting unused addresses'); return Promise.resolve([]); }, - getUsedAddresses: async (_paginate?: Paginate): Promise => { + getUsedAddresses: async (): Promise => { logger.debug('getting used addresses'); const wallet = await firstValueFrom(wallet$); @@ -383,7 +389,7 @@ const baseCip30WalletApi = ( return addresses.map((groupAddresses) => cardanoAddressToCbor(groupAddresses.address)); } }, - getUtxos: async (amount?: Cbor, paginate?: Paginate): Promise => { + getUtxos: async (_: SenderContext, amount?: Cbor, paginate?: Paginate): Promise => { const scope = new ManagedFreeableScope(); try { const wallet = await firstValueFrom(wallet$); @@ -402,6 +408,7 @@ const baseCip30WalletApi = ( } }, signData: async ( + { sender }: SenderContext, addr: Cardano.PaymentAddress | Cardano.DRepID | Bytes, payload: Bytes ): Promise => { @@ -415,6 +422,7 @@ const baseCip30WalletApi = ( addr: signWith, payload: hexBlobPayload }, + sender, type: Cip30ConfirmationCallbackType.SignData }) .catch((error) => mapCallbackFailure(error, logger)); @@ -423,13 +431,14 @@ const baseCip30WalletApi = ( const wallet = await firstValueFrom(wallet$); return wallet.signData({ payload: hexBlobPayload, + sender, signWith }); } logger.debug('sign data declined'); throw new DataSignError(DataSignErrorCode.UserDeclined, 'user declined signing'); }, - signTx: async (tx: Cbor, partialSign?: Boolean): Promise => { + signTx: async ({ sender }: SenderContext, tx: Cbor, partialSign?: Boolean): Promise => { const scope = new ManagedFreeableScope(); logger.debug('signTx'); const txDecoded = Serialization.Transaction.fromCbor(TxCBOR(tx)); @@ -439,6 +448,7 @@ const baseCip30WalletApi = ( const shouldProceed = await confirmationCallback .signTx({ data: coreTx, + sender, type: Cip30ConfirmationCallbackType.SignTx }) .catch((error) => mapCallbackFailure(error, logger)); @@ -455,7 +465,7 @@ const baseCip30WalletApi = ( ); const { witness: { signatures } - } = await wallet.finalizeTx({ tx: { ...coreTx, hash } }); + } = await wallet.finalizeTx({ sender, tx: { ...coreTx, hash } }); // If partialSign is true, the wallet only tries to sign what it can. However, if // signatures size is 0 then throw. @@ -484,7 +494,7 @@ const baseCip30WalletApi = ( throw new TxSignError(TxSignErrorCode.UserDeclined, 'user declined signing tx'); } }, - submitTx: async (input: Cbor): Promise => { + submitTx: async (_: SenderContext, input: Cbor): Promise => { logger.debug('submitting tx'); const { cbor, tx } = processTxInput(input); const shouldProceed = await confirmationCallback @@ -570,7 +580,7 @@ export const createWalletApi = ( wallet$: Observable, confirmationCallback: CallbackConfirmation, { logger }: Cip30WalletDependencies -): WalletApi => ({ +): WithSenderContext => ({ ...baseCip30WalletApi(wallet$, confirmationCallback, { logger }), ...extendedCip95WalletApi(wallet$, { logger }) }); diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index a09f44046bc..c1bbc46fadd 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -10,7 +10,7 @@ import { import { BalanceTracker, DelegationTracker, TransactionsTracker, UtxoTracker } from './services'; import { Cip30DataSignature } from '@cardano-sdk/dapp-connector'; import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto'; -import { GroupedAddress, SignTransactionOptions, cip8 } from '@cardano-sdk/key-management'; +import { GroupedAddress, MessageSender, SignTransactionOptions, cip8 } from '@cardano-sdk/key-management'; import { InitializeTxProps, InitializeTxResult, SignedTx, TxBuilder, TxContext } from '@cardano-sdk/tx-construction'; import { Observable } from 'rxjs'; import { PubStakeKeyAndStatus } from './services/PublicStakeKeysTracker'; @@ -46,6 +46,7 @@ export interface SyncStatus extends Shutdown { export type FinalizeTxProps = Omit & { tx: Cardano.TxBodyWithHash; signingOptions?: SignTransactionOptions; + sender?: MessageSender; }; export type HandleInfo = HandleResolution & Asset.AssetInfo; diff --git a/packages/wallet/test/PersonalWallet/methods.test.ts b/packages/wallet/test/PersonalWallet/methods.test.ts index aa2647716cf..7ebe8fa6ba9 100644 --- a/packages/wallet/test/PersonalWallet/methods.test.ts +++ b/packages/wallet/test/PersonalWallet/methods.test.ts @@ -219,12 +219,22 @@ describe('PersonalWallet methods', () => { expect(getPassphrase).not.toBeCalled(); }); - it('finalizeTx', async () => { - const txInternals = await wallet.initializeTx(props); - const tx = await wallet.finalizeTx({ tx: txInternals }); - expect(tx.body).toBe(txInternals.body); - expect(tx.id).toBe(txInternals.hash); - expect(tx.witness.signatures.size).toBe(2); // spending key and stake key for withdrawal + describe('finalizeTx', () => { + it('resolves with TransactionWitnessSet', async () => { + const txInternals = await wallet.initializeTx(props); + const tx = await wallet.finalizeTx({ tx: txInternals }); + expect(tx.body).toBe(txInternals.body); + expect(tx.id).toBe(txInternals.hash); + expect(tx.witness.signatures.size).toBe(2); // spending key and stake key for withdrawal + }); + + it('passes through sender to witnesser', async () => { + const sender = { url: 'https://lace.io' }; + const witnessSpy = jest.spyOn(witnesser, 'witness'); + const txInternals = await wallet.initializeTx(props); + await wallet.finalizeTx({ sender, tx: txInternals }); + expect(witnessSpy).toBeCalledWith(expect.anything(), expect.objectContaining({ sender }), void 0); + }); }); describe('submitTx', () => { @@ -424,6 +434,13 @@ describe('PersonalWallet methods', () => { expect(response).toHaveProperty('signature'); }); + it('passes through sender to witnesser', async () => { + const sender = { url: 'https://lace.io' }; + const signBlobSpy = jest.spyOn(witnesser, 'signBlob'); + await wallet.signData({ payload: HexBlob('abc123'), sender, signWith: address }); + expect(signBlobSpy).toBeCalledWith(expect.anything(), expect.anything(), sender); + }); + test('rejects if bech32 DRepID is not a type 6 address', async () => { const dRepKey = await wallet.getPubDRepKey(); for (const type in Cardano.AddressType) { diff --git a/packages/wallet/test/integration/cip30mapping.test.ts b/packages/wallet/test/integration/cip30mapping.test.ts index c8c5de52a2f..f7417417223 100644 --- a/packages/wallet/test/integration/cip30mapping.test.ts +++ b/packages/wallet/test/integration/cip30mapping.test.ts @@ -8,9 +8,11 @@ import { DataSignError, DataSignErrorCode, Paginate, + SenderContext, TxSendError, TxSignError, - WalletApi + WalletApi, + WithSenderContext } from '@cardano-sdk/dapp-connector'; import { AddressType, Bip32Account, GroupedAddress, util } from '@cardano-sdk/key-management'; import { AssetId, createStubStakePoolProvider, mockProviders as mocks } from '@cardano-sdk/util-dev'; @@ -47,7 +49,7 @@ const { type TestProviders = Required>; const mockCollateralCallback = jest.fn().mockResolvedValue([mockUtxo[3]]); -const mockGenericCallback = jest.fn().mockResolvedValue(true); +const createMockGenericCallback = () => jest.fn().mockResolvedValue(true); const createWalletAndApiWithStores = async ( unspendableUtxos: Cardano.Utxo[], @@ -68,9 +70,9 @@ const createWalletAndApiWithStores = async ( wallet.utxo.available$ = of(availableUtxos); } const confirmationCallback = { - signData: mockGenericCallback, - signTx: mockGenericCallback, - submitTx: mockGenericCallback, + signData: createMockGenericCallback(), + signTx: createMockGenericCallback(), + submitTx: createMockGenericCallback(), ...(!!getCollateralCallback && { getCollateral: getCollateralCallback }) }; wallet.getPubDRepKey = jest.fn(wallet.getPubDRepKey); @@ -81,8 +83,9 @@ const createWalletAndApiWithStores = async ( }; describe('cip30', () => { + const context: SenderContext = { sender: { url: 'https://lace.io' } }; let wallet: PersonalWallet; - let api: WalletApi; + let api: WithSenderContext; let confirmationCallback: CallbackConfirmation; const simpleTxProps: InitializeTxProps = { @@ -138,7 +141,7 @@ describe('cip30', () => { // Validity interval of serializedTx is 20263284 <= n <= 20266884 slot: Cardano.Slot(20_263_285) }); - await expect(api.submitTx(serializedTx)).resolves.not.toThrow(); + await expect(api.submitTx(context, serializedTx)).resolves.not.toThrow(); expect(providers.txSubmitProvider.submitTx).toHaveBeenCalledWith({ signedTransaction: serializedTx }); }); }); @@ -167,13 +170,13 @@ describe('cip30', () => { describe('createWalletApi', () => { test('api.getNetworkId', async () => { - const cip30NetworkId = await api.getNetworkId(); + const cip30NetworkId = await api.getNetworkId(context); expect(cip30NetworkId).toEqual(Cardano.NetworkId.Testnet); }); describe('api.getUtxos', () => { it('returns all utxo without arguments', async () => { - const utxos = await api.getUtxos(); + const utxos = await api.getUtxos(context); expect(utxos?.length).toBe((await firstValueFrom(wallet.utxo.available$)).length); expect(() => utxos!.map((utxo) => Serialization.TransactionUnspentOutput.fromCbor(HexBlob(utxo))) @@ -189,7 +192,7 @@ describe('cip30', () => { multiAsset.set(AssetId.TSLA, tslaQuantity); filterAmountValue.setMultiasset(multiAsset); } - const utxoCbor = await api.getUtxos(filterAmountValue.toCbor(), paginate); + const utxoCbor = await api.getUtxos(context, filterAmountValue.toCbor(), paginate); if (!utxoCbor) return null; return utxoCbor.map((utxo) => Serialization.TransactionUnspentOutput.fromCbor(HexBlob(utxo)).toCore()); }; @@ -264,27 +267,27 @@ describe('cip30', () => { describe('api.getCollateral', () => { // Wallet 2 let wallet2: PersonalWallet; - let api2: WalletApi; + let api2: WithSenderContext; // Wallet 3 let wallet3: PersonalWallet; - let api3: WalletApi; + let api3: WithSenderContext; // Wallet 4 let wallet4: PersonalWallet; - let api4: WalletApi; + let api4: WithSenderContext; // Wallet 5 let wallet5: PersonalWallet; - let api5: WalletApi; + let api5: WithSenderContext; // Wallet 6 let wallet6: PersonalWallet; - let api6: WalletApi; + let api6: WithSenderContext; // Wallet 7 let wallet7: PersonalWallet; - let api7: WalletApi; + let api7: WithSenderContext; beforeAll(async () => { // CREATE A WALLET WITH LOW COINS UTxOs @@ -336,11 +339,11 @@ describe('cip30', () => { test('can handle serialization errors', async () => { // YYYY is invalid hex that will throw at serialization - await expect(api.getCollateral({ amount: 'YYYY' })).rejects.toThrowError(ApiError); + await expect(api.getCollateral(context, { amount: 'YYYY' })).rejects.toThrowError(ApiError); }); it('executes collateral callback if provided and unspendable UTxOs do not meet amount required', async () => { - const collateral = await api5.getCollateral(); + const collateral = await api5.getCollateral(context); expect(mockCollateralCallback).toHaveBeenCalledWith({ data: { amount: 5_000_000n, @@ -354,7 +357,7 @@ describe('cip30', () => { }); it('executes collateral callback if provided and no unspendable UTxOs are available', async () => { - const collateral = await api6.getCollateral(); + const collateral = await api6.getCollateral(context); expect(mockCollateralCallback).toHaveBeenCalledWith({ data: { amount: 5_000_000n, @@ -368,23 +371,23 @@ describe('cip30', () => { }); it('does not execute collateral callback if provided with no available UTxOs', async () => { - await expect(api7.getCollateral()).rejects.toThrow(ApiError); + await expect(api7.getCollateral(context)).rejects.toThrow(ApiError); expect(mockCollateralCallback).not.toHaveBeenCalled(); wallet7.shutdown(); }); it('does not execute collateral callback if not provided', async () => { - await expect(api2.getCollateral()).rejects.toThrow(ApiError); + await expect(api2.getCollateral(context)).rejects.toThrow(ApiError); expect(mockCollateralCallback).not.toHaveBeenCalled(); }); test('accepts amount as tagged integer', async () => { - await expect(api.getCollateral({ amount: 'c2434c4b40' })).resolves.not.toThrow(); + await expect(api.getCollateral(context, { amount: 'c2434c4b40' })).resolves.not.toThrow(); }); test('returns multiple UTxOs when more than 1 utxo needed to satisfy amount', async () => { // 1a003d0900 Represents a BigNum object of 4 ADA - const utxos = await api2.getCollateral({ amount: '1a003d0900' }); + const utxos = await api2.getCollateral(context, { amount: '1a003d0900' }); // eslint-disable-next-line sonarjs/no-identical-functions expect(() => @@ -395,23 +398,23 @@ describe('cip30', () => { test('throws when there are not enough UTxOs', async () => { // 1a004c4b40 Represents a BigNum object of 5 ADA - await expect(api2.getCollateral({ amount: '1a004c4b40' })).rejects.toThrow(ApiError); + await expect(api2.getCollateral(context, { amount: '1a004c4b40' })).rejects.toThrow(ApiError); }); test('returns null when there are no "unspendable" UTxOs in the wallet', async () => { // 1a003d0900 Represents a BigNum object of 4 ADA - expect(await api3.getCollateral({ amount: '1a003d0900' })).toBe(null); + expect(await api3.getCollateral(context, { amount: '1a003d0900' })).toBe(null); wallet3.shutdown(); }); test('throws when the given amount is greater than max amount', async () => { // 1a005b8d80 Represents a BigNum object of 6 ADA - await expect(api2.getCollateral({ amount: '1a005b8d80' })).rejects.toThrow(ApiError); + await expect(api2.getCollateral(context, { amount: '1a005b8d80' })).rejects.toThrow(ApiError); }); test('returns first UTxO when amount is 0', async () => { // 00 Represents a BigNum object of 0 ADA - const utxos = await api2.getCollateral({ amount: '00' }); + const utxos = await api2.getCollateral(context, { amount: '00' }); // eslint-disable-next-line sonarjs/no-identical-functions expect(() => utxos!.map((utxo) => Serialization.TransactionUnspentOutput.fromCbor(HexBlob(utxo))) @@ -419,7 +422,7 @@ describe('cip30', () => { }); test('returns all UTxOs when there is no given amount', async () => { - const utxos = await api.getCollateral(); + const utxos = await api.getCollateral(context); // eslint-disable-next-line sonarjs/no-identical-functions expect(() => utxos!.map((utxo) => Serialization.TransactionUnspentOutput.fromCbor(HexBlob(utxo))) @@ -428,21 +431,21 @@ describe('cip30', () => { }); test('returns null when there is no given amount and wallet has no UTxOs', async () => { - expect(await api3.getCollateral()).toBe(null); + expect(await api3.getCollateral(context)).toBe(null); }); test('throws when unspendable UTxOs contain assets', async () => { - await expect(api4.getCollateral()).rejects.toThrow(ApiError); + await expect(api4.getCollateral(context)).rejects.toThrow(ApiError); }); }); test('api.getBalance', async () => { - const balanceCborBytes = await api.getBalance(); + const balanceCborBytes = await api.getBalance(context); expect(() => Serialization.Value.fromCbor(HexBlob(balanceCborBytes))).not.toThrow(); }); test('api.getUsedAddresses', async () => { - const cipUsedAddressess = await api.getUsedAddresses(); + const cipUsedAddressess = await api.getUsedAddresses(context); const usedAddresses = (await firstValueFrom(wallet.addresses$)).map((grouped) => grouped.address); expect(cipUsedAddressess.length).toBeGreaterThan(1); @@ -450,18 +453,18 @@ describe('cip30', () => { }); test('api.getUnusedAddresses', async () => { - const cipUsedAddressess = await api.getUnusedAddresses(); + const cipUsedAddressess = await api.getUnusedAddresses(context); expect(cipUsedAddressess).toEqual([]); }); test('api.getChangeAddress', async () => { - const cipChangeAddress = await api.getChangeAddress(); + const cipChangeAddress = await api.getChangeAddress(context); const [{ address: walletAddress }] = await firstValueFrom(wallet.addresses$); expect(Cardano.PaymentAddress(cipChangeAddress)).toEqual(walletAddress); }); test('api.getRewardAddresses', async () => { - const cipRewardAddressesCbor = await api.getRewardAddresses(); + const cipRewardAddressesCbor = await api.getRewardAddresses(context); const cipRewardAddresses = cipRewardAddressesCbor.map((cipAddr) => Cardano.Address.fromBytes(HexBlob(cipAddr)).toBech32() ); @@ -470,41 +473,75 @@ describe('cip30', () => { expect(cipRewardAddresses).toEqual([walletRewardAccount]); }); - test('api.signTx', async () => { - const txInternals = await wallet.initializeTx(simpleTxProps); - const finalizedTx = await wallet.finalizeTx({ tx: txInternals }); - const hexTx = Serialization.Transaction.fromCore(finalizedTx).toCbor(); + describe('api.signTx', () => { + let finalizedTx: Cardano.Tx; + let hexTx: TxCBOR; + + beforeEach(async () => { + const txInternals: InitializeTxResult = await wallet.initializeTx(simpleTxProps); + finalizedTx = await wallet.finalizeTx({ tx: txInternals }); + hexTx = Serialization.Transaction.fromCore(finalizedTx).toCbor(); + }); + + it('resolves with TransactionWitnessSet', async () => { + const cip30witnessSet = await api.signTx(context, hexTx); + expect(() => Serialization.TransactionWitnessSet.fromCbor(HexBlob(cip30witnessSet))).not.toThrow(); + }); - const cip30witnessSet = await api.signTx(hexTx); - expect(() => Serialization.TransactionWitnessSet.fromCbor(HexBlob(cip30witnessSet))).not.toThrow(); + it('passes through sender from dapp connector context', async () => { + const finalizeTxSpy = jest.spyOn(wallet, 'finalizeTx'); + await api.signTx(context, hexTx); + expect(finalizeTxSpy).toBeCalledWith( + expect.objectContaining({ + sender: { + url: context.sender.url + } + }) + ); + expect(confirmationCallback.signTx).toBeCalledWith(expect.objectContaining({ sender: context.sender })); + }); }); describe('api.signData', () => { test('sign with address', async () => { const [{ address }] = await firstValueFrom(wallet.addresses$); - const cip30dataSignature = await api.signData(address, HexBlob('abc123')); + const cip30dataSignature = await api.signData(context, address, HexBlob('abc123')); expect(typeof cip30dataSignature.key).toBe('string'); expect(typeof cip30dataSignature.signature).toBe('string'); }); test('sign with bech32 DRepID', async () => { - const dRepKey = await api.getPubDRepKey(); + const dRepKey = await api.getPubDRepKey(context); const drepid = buildDRepIDFromDRepKey(dRepKey); - const cip95dataSignature = await api.signData(drepid, HexBlob('abc123')); + const cip95dataSignature = await api.signData(context, drepid, HexBlob('abc123')); expect(typeof cip95dataSignature.key).toBe('string'); expect(typeof cip95dataSignature.signature).toBe('string'); }); test('rejects if bech32 DRepID is not a type 6 address', async () => { - const dRepKey = await api.getPubDRepKey(); + const dRepKey = await api.getPubDRepKey(context); for (const type in Cardano.AddressType) { if (!Number.isNaN(Number(type)) && Number(type) !== Cardano.AddressType.EnterpriseKey) { const drepid = buildDRepIDFromDRepKey(dRepKey, 0, type as unknown as Cardano.AddressType); - await expect(api.signData(drepid, HexBlob('abc123'))).rejects.toThrow(); + await expect(api.signData(context, drepid, HexBlob('abc123'))).rejects.toThrow(); } } }); + + it('passes through sender from dapp connector context', async () => { + const [{ address }] = await firstValueFrom(wallet.addresses$); + const signDataSpy = jest.spyOn(wallet, 'signData'); + await api.signData(context, address, HexBlob('abc123')); + expect(signDataSpy).toBeCalledWith( + expect.objectContaining({ + sender: { + url: context.sender.url + } + }) + ); + expect(confirmationCallback.signData).toBeCalledWith(expect.objectContaining({ sender: context.sender })); + }); }); describe('api.submitTx', () => { @@ -520,7 +557,7 @@ describe('cip30', () => { }); it('resolves with transaction id when submitting a valid transaction', async () => { - const txId = await api.submitTx(hexTx); + const txId = await api.submitTx(context, hexTx); expect(txId).toBe(finalizedTx.id); }); @@ -528,11 +565,11 @@ describe('cip30', () => { it.todo('resolves with original transactionId (not the one computed when re-serializing the transaction)'); it('throws ApiError when submitting a transaction that has invalid encoding', async () => { - await expect(api.submitTx(Buffer.from(txBytes).toString('base64'))).rejects.toThrowError(ApiError); + await expect(api.submitTx(context, Buffer.from(txBytes).toString('base64'))).rejects.toThrowError(ApiError); }); it('throws ApiError when submitting a hex string that is not a serialized transaction', async () => { - await expect(api.submitTx(Buffer.from([0, 1, 3]).toString('hex'))).rejects.toThrowError(ApiError); + await expect(api.submitTx(context, Buffer.from([0, 1, 3]).toString('hex'))).rejects.toThrowError(ApiError); }); it('throws TxSendError when submission fails', async () => { @@ -546,19 +583,19 @@ describe('cip30', () => { 'Outside of validity interval' ) ); - await expect(api.submitTx(hexTx)).rejects.toThrowError(TxSendError); + await expect(api.submitTx(context, hexTx)).rejects.toThrowError(TxSendError); }); }); describe('api.getPubDRepKey', () => { test("returns the DRep key derived from the wallet's public key", async () => { - const cip95PubDRepKey = await api.getPubDRepKey(); + const cip95PubDRepKey = await api.getPubDRepKey(context); expect(cip95PubDRepKey).toEqual(await wallet.getPubDRepKey()); }); test('throws an ApiError on unexpected error', async () => { (wallet.getPubDRepKey as jest.Mock).mockRejectedValueOnce(new Error('unexpected error')); try { - await api.getPubDRepKey(); + await api.getPubDRepKey(context); } catch (error) { expect(error instanceof ApiError).toBe(true); expect((error as ApiError).code).toEqual(APIErrorCode.InternalError); @@ -570,7 +607,7 @@ describe('cip30', () => { }); test('api.getExtensions', async () => { - const extensions = await api.getExtensions(); + const extensions = await api.getExtensions(context); expect(extensions).toEqual([{ cip: 95 }]); }); }); @@ -587,17 +624,17 @@ describe('cip30', () => { test('resolves true', async () => { confirmationCallback.signData = jest.fn().mockResolvedValueOnce(true); - await expect(api.signData(address, payload)).resolves.not.toThrow(); + await expect(api.signData(context, address, payload)).resolves.not.toThrow(); }); test('resolves false', async () => { confirmationCallback.signData = jest.fn().mockResolvedValueOnce(false); - await expect(api.signData(address, payload)).rejects.toThrowError(DataSignError); + await expect(api.signData(context, address, payload)).rejects.toThrowError(DataSignError); }); test('rejects', async () => { confirmationCallback.signData = jest.fn().mockRejectedValue(1); - await expect(api.signData(address, payload)).rejects.toThrowError(DataSignError); + await expect(api.signData(context, address, payload)).rejects.toThrowError(DataSignError); }); test('gets the Cardano.Address equivalent of the hex address', async () => { @@ -605,7 +642,7 @@ describe('cip30', () => { const hexAddr = Cardano.Address.fromBech32(address).toBytes(); - await api.signData(hexAddr, payload); + await api.signData(context, hexAddr, payload); expect(confirmationCallback.signData).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ addr: address }) }) ); @@ -622,17 +659,17 @@ describe('cip30', () => { test('resolves true', async () => { confirmationCallback.signTx = jest.fn().mockResolvedValueOnce(true); - await expect(api.signTx(hexTx)).resolves.not.toThrow(); + await expect(api.signTx(context, hexTx)).resolves.not.toThrow(); }); test('resolves false', async () => { confirmationCallback.signTx = jest.fn().mockResolvedValueOnce(false); - await expect(api.signTx(hexTx)).rejects.toThrowError(TxSignError); + await expect(api.signTx(context, hexTx)).rejects.toThrowError(TxSignError); }); test('rejects', async () => { confirmationCallback.signTx = jest.fn().mockRejectedValue(1); - await expect(api.signTx(hexTx)).rejects.toThrowError(TxSignError); + await expect(api.signTx(context, hexTx)).rejects.toThrowError(TxSignError); }); }); @@ -649,17 +686,17 @@ describe('cip30', () => { test('resolves true', async () => { confirmationCallback.submitTx = jest.fn().mockResolvedValueOnce(true); - await expect(api.submitTx(serializedTx)).resolves.toBe(finalizedTx.id); + await expect(api.submitTx(context, serializedTx)).resolves.toBe(finalizedTx.id); }); test('resolves false', async () => { confirmationCallback.submitTx = jest.fn().mockResolvedValueOnce(false); - await expect(api.submitTx(serializedTx)).rejects.toThrowError(TxSendError); + await expect(api.submitTx(context, serializedTx)).rejects.toThrowError(TxSendError); }); test('rejects', async () => { confirmationCallback.submitTx = jest.fn().mockRejectedValue(1); - await expect(api.submitTx(serializedTx)).rejects.toThrowError(TxSendError); + await expect(api.submitTx(context, serializedTx)).rejects.toThrowError(TxSendError); }); }); }); @@ -671,7 +708,7 @@ describe('cip30', () => { let mockWallet: PersonalWallet; let utxoProvider: mocks.UtxoProviderStub; let tx: Cardano.Tx; - let mockApi: WalletApi; + let mockApi: WithSenderContext; beforeEach(async () => { txSubmitProvider = mocks.mockTxSubmitProvider(); @@ -758,7 +795,7 @@ describe('cip30', () => { // Inputs are selected by input selection algorithm expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(1); - await expect(mockApi.signTx(cbor, false)).resolves.not.toThrow(); + await expect(mockApi.signTx(context, cbor, false)).resolves.not.toThrow(); } ); @@ -785,7 +822,7 @@ describe('cip30', () => { // Inputs are selected by input selection algorithm expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(1); - await expect(mockApi.signTx(cbor, true)).resolves.not.toThrow(); + await expect(mockApi.signTx(context, cbor, true)).resolves.not.toThrow(); } ); @@ -820,7 +857,7 @@ describe('cip30', () => { // Inputs are selected by input selection algorithm expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(1); - await expect(mockApi.signTx(cbor, true)).resolves.not.toThrow(); + await expect(mockApi.signTx(context, cbor, true)).resolves.not.toThrow(); } ); @@ -862,7 +899,7 @@ describe('cip30', () => { expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(2); - await expect(mockApi.signTx(cbor, true)).resolves.not.toThrow(); + await expect(mockApi.signTx(context, cbor, true)).resolves.not.toThrow(); } ); @@ -879,7 +916,7 @@ describe('cip30', () => { expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(1); - await expect(mockApi.signTx(cbor, true)).rejects.toMatchObject( + await expect(mockApi.signTx(context, cbor, true)).rejects.toMatchObject( new DataSignError( DataSignErrorCode.ProofGeneration, 'The wallet does not have the secret key associated with any of the inputs and certificates.' @@ -906,7 +943,7 @@ describe('cip30', () => { expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(1); - await expect(mockApi.signTx(cbor, false)).rejects.toMatchObject( + await expect(mockApi.signTx(context, cbor, false)).rejects.toMatchObject( new DataSignError( DataSignErrorCode.ProofGeneration, 'The wallet does not have the secret key associated with some of the inputs or certificates.' @@ -941,7 +978,7 @@ describe('cip30', () => { expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1); expect(tx.body.certificates!.length).toBe(2); - await expect(mockApi.signTx(cbor, false)).rejects.toMatchObject( + await expect(mockApi.signTx(context, cbor, false)).rejects.toMatchObject( new DataSignError( DataSignErrorCode.ProofGeneration, 'The wallet does not have the secret key associated with some of the inputs or certificates.' diff --git a/packages/web-extension/src/cip30/exposeAuthenticatorApi.ts b/packages/web-extension/src/cip30/exposeAuthenticatorApi.ts index 4e4110f52b3..e398ef23141 100644 --- a/packages/web-extension/src/cip30/exposeAuthenticatorApi.ts +++ b/packages/web-extension/src/cip30/exposeAuthenticatorApi.ts @@ -4,10 +4,10 @@ import { RemoteApiMethod, RemoteApiProperties, RemoteApiPropertyType, - exposeApi, - senderOrigin + exposeApi } from '../messaging'; import { RemoteAuthenticatorMethodNames } from './consumeRemoteAuthenticatorApi'; +import { cloneSender } from './util'; import { of } from 'rxjs'; export interface ExposeAuthenticatorApiOptions { @@ -35,10 +35,13 @@ export const exposeAuthenticatorApi = ( { propType: RemoteApiPropertyType.MethodReturningPromise, requestOptions: { - transform: ({ method }, sender) => ({ - args: [senderOrigin(sender)], - method - }) + transform: ({ method }, sender) => { + if (!sender) throw new Error('Unknown sender'); + return { + args: [cloneSender(sender)], + method + }; + } } } as RemoteApiMethod ]) diff --git a/packages/web-extension/src/cip30/exposeWalletApi.ts b/packages/web-extension/src/cip30/exposeWalletApi.ts index ebb03fd9294..26fefac60e8 100644 --- a/packages/web-extension/src/cip30/exposeWalletApi.ts +++ b/packages/web-extension/src/cip30/exposeWalletApi.ts @@ -1,14 +1,20 @@ -import { APIErrorCode, ApiError, AuthenticatorApi, WalletApi, WalletApiMethodNames } from '@cardano-sdk/dapp-connector'; +import { + APIErrorCode, + ApiError, + AuthenticatorApi, + WalletApi, + WalletApiMethodNames, + WithSenderContext +} from '@cardano-sdk/dapp-connector'; import { MessengerDependencies, RemoteApiMethod, RemoteApiProperties, RemoteApiPropertyType, - exposeApi, - senderOrigin + exposeApi } from '../messaging'; +import { cloneSender, walletApiChannel } from './util'; import { of } from 'rxjs'; -import { walletApiChannel } from './util'; export interface BackgroundWalletApiOptions { walletName: string; @@ -16,7 +22,7 @@ export interface BackgroundWalletApiOptions { export interface BackgroundWalletDependencies extends MessengerDependencies { authenticator: AuthenticatorApi; - walletApi: WalletApi; + walletApi: WithSenderContext; } // tested in e2e tests @@ -34,9 +40,15 @@ export const exposeWalletApi = ( { propType: RemoteApiPropertyType.MethodReturningPromise, requestOptions: { + transform: ({ method, args }, sender) => { + if (!sender) throw new Error('"sender" is undefined'); + return { + args: [{ sender: cloneSender(sender) }, ...args], + method + }; + }, validate: async (_, sender) => { - const origin = sender && senderOrigin(sender); - const haveAccess = origin && (await dependencies.authenticator.haveAccess(origin)); + const haveAccess = sender && (await dependencies.authenticator.haveAccess(cloneSender(sender))); if (!haveAccess) { throw new ApiError(APIErrorCode.Refused, 'Call cardano.{walletName}.enable() first'); } diff --git a/packages/web-extension/src/cip30/initializeBackgroundScript.ts b/packages/web-extension/src/cip30/initializeBackgroundScript.ts index c4328153bde..79ac7647bca 100644 --- a/packages/web-extension/src/cip30/initializeBackgroundScript.ts +++ b/packages/web-extension/src/cip30/initializeBackgroundScript.ts @@ -1,4 +1,4 @@ -import { AuthenticatorApi, WalletApi, WalletName } from '@cardano-sdk/dapp-connector'; +import { AuthenticatorApi, WalletApi, WalletName, WithSenderContext } from '@cardano-sdk/dapp-connector'; import { Logger } from 'ts-log'; import { Runtime } from 'webextension-polyfill'; import { exposeAuthenticatorApi } from './exposeAuthenticatorApi'; @@ -12,7 +12,7 @@ export interface InitializeBackgroundScriptDependencies { logger: Logger; runtime: Runtime.Static; authenticator: AuthenticatorApi; - walletApi: WalletApi; + walletApi: WithSenderContext; } // tested in e2e tests diff --git a/packages/web-extension/src/cip30/util.ts b/packages/web-extension/src/cip30/util.ts index 69fdf9902d7..72671a7f363 100644 --- a/packages/web-extension/src/cip30/util.ts +++ b/packages/web-extension/src/cip30/util.ts @@ -1,3 +1,47 @@ +import { MessageSender } from '@cardano-sdk/key-management'; + export const walletApiChannel = (walletName: string) => `wallet-api-${walletName}`; export const authenticatorChannel = (walletName: string) => `authenticator-${walletName}`; + +/** + * sender object is intended to be used as a parameter in APIs exposed via extension messaging. + * This function clones the sender object provided by the browser in order to ensure it's a pojo. + */ +export const cloneSender = ({ frameId, id, tab, url }: MessageSender): MessageSender => ({ + frameId, + id, + tab: tab + ? { + active: tab.active, + attention: tab.attention, + audible: tab.audible, + autoDiscardable: tab.autoDiscardable, + cookieStoreId: tab.cookieStoreId, + discarded: tab.discarded, + favIconUrl: tab.favIconUrl, + height: tab.height, + hidden: tab.hidden, + highlighted: tab.highlighted, + id: tab.id, + incognito: tab.incognito, + index: tab.index, + isArticle: tab.isArticle, + isInReaderMode: tab.isInReaderMode, + lastAccessed: tab.lastAccessed, + mutedInfo: tab.mutedInfo ? { ...tab.mutedInfo } : void 0, + openerTabId: tab.openerTabId, + pendingUrl: tab.pendingUrl, + pinned: tab.pinned, + sessionId: tab.sessionId, + sharingState: tab.sharingState ? { ...tab.sharingState } : void 0, + status: tab.status, + successorTabId: tab.successorTabId, + title: tab.title, + url: tab.url, + width: tab.width, + windowId: tab.windowId + } + : void 0, + url +}); diff --git a/packages/web-extension/src/messaging/util.ts b/packages/web-extension/src/messaging/util.ts index 3c58063a619..bf06c7293ae 100644 --- a/packages/web-extension/src/messaging/util.ts +++ b/packages/web-extension/src/messaging/util.ts @@ -13,7 +13,6 @@ import { ResponseMessage } from './types'; import { Logger } from 'ts-log'; -import { Runtime } from 'webextension-polyfill'; import { v4 as uuidv4 } from 'uuid'; const isRequestLike = (message: any): message is MethodRequest & Partial> => @@ -43,15 +42,6 @@ export const isCompletionMessage = (message: any): message is CompletionMessage export const isEmitMessage = (message: any): message is EmitMessage => looksLikeMessage(message) && message.hasOwnProperty('emit'); -export const senderOrigin = (sender?: Runtime.MessageSender): string | null => { - try { - const { origin } = new URL(sender?.url || 'throw'); - return origin; - } catch { - return null; - } -}; - export const newMessageId = uuidv4; export const deriveChannelName = (channel: ChannelName, path: string): ChannelName => `${channel}-${path}`; diff --git a/packages/web-extension/test/messaging/util.test.ts b/packages/web-extension/test/messaging/util.test.ts index 1bb423b45b4..2f1b0fbd0d1 100644 --- a/packages/web-extension/test/messaging/util.test.ts +++ b/packages/web-extension/test/messaging/util.test.ts @@ -9,8 +9,7 @@ import { isRequest, isRequestMessage, isResponseMessage, - newMessageId, - senderOrigin + newMessageId } from '../../src'; describe('messaging/util', () => { @@ -71,13 +70,4 @@ describe('messaging/util', () => { expect(newMessageId()).not.toEqual(newMessageId()); }); }); - describe('senderOrigin', () => { - it('returns null when origin url is not present', () => { - expect(senderOrigin()).toBe(null); - expect(senderOrigin({ id: 'id' })).toBe(null); - }); - it('returns origin url it is present', () => { - expect(senderOrigin({ url: 'http://origin' })).toBe('http://origin'); - }); - }); }); diff --git a/yarn.lock b/yarn.lock index 701e2bf9f36..67bdea27e12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3525,6 +3525,7 @@ __metadata: "@trezor/connect-web": 9.0.11 "@types/lodash": ^4.14.182 "@types/pbkdf2": ^3.1.0 + "@types/webextension-polyfill": ^0.8.0 bip39: ^3.0.4 chacha: ^2.1.0 eslint: ^7.32.0