Skip to content

Commit

Permalink
feat(dapp-connector): custom inject key, extensions and api
Browse files Browse the repository at this point in the history
Add support for:
- inject api under custom key, instead of wallet name
- configure the supported extensions when constructing the Cip30Wallet object
- deviation from standard cip30 api. Currently overrides getCollateral to return
empty array when no collaterals are found
  • Loading branch information
mirceahasegan committed Oct 2, 2024
1 parent 81b52f5 commit 15b68e6
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 52 deletions.
121 changes: 71 additions & 50 deletions packages/dapp-connector/src/WalletApi/Cip30Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,54 +32,6 @@ export const CipMethodsMapping: Record<number, WalletMethod[]> = {
};
export const WalletApiMethodNames: WalletMethod[] = Object.values(CipMethodsMapping).flat();

/**
* Wrap the proxy API object with a regular javascript object to avoid interop issues with some dApps.
*
* Only return the allowed API methods.
*/
const wrapAndEnableApi = (
walletApi: WalletApi,
enabledExtensions?: WalletApiExtension[]
): Cip30WalletApiWithPossibleExtensions => {
const baseApi: Cip30WalletApiWithPossibleExtensions = {
// Add experimental.getCollateral to CIP-30 API
experimental: {
getCollateral: (params?: { amount?: Cbor }) => walletApi.getCollateral(params)
},
getBalance: () => walletApi.getBalance(),
getChangeAddress: () => walletApi.getChangeAddress(),
getCollateral: (params?: { amount?: Cbor }) => walletApi.getCollateral(params),
getExtensions: () => Promise.resolve(enabledExtensions || []),
getNetworkId: () => walletApi.getNetworkId(),
getRewardAddresses: () => walletApi.getRewardAddresses(),
getUnusedAddresses: () => walletApi.getUnusedAddresses(),
getUsedAddresses: (paginate?: Paginate) => walletApi.getUsedAddresses(paginate),
getUtxos: (amount?: Cbor, paginate?: Paginate) => walletApi.getUtxos(amount, paginate),
signData: (addr: Cardano.PaymentAddress | Bytes, payload: Bytes) => walletApi.signData(addr, payload),
signTx: (tx: Cbor, partialSign?: Boolean) => walletApi.signTx(tx, partialSign),
submitTx: (tx: Cbor) => walletApi.submitTx(tx)
};

const additionalCipApis: CipExtensionApis = {
cip95: {
getPubDRepKey: () => walletApi.getPubDRepKey(),
getRegisteredPubStakeKeys: () => walletApi.getRegisteredPubStakeKeys(),
getUnregisteredPubStakeKeys: () => walletApi.getUnregisteredPubStakeKeys()
}
};

if (enabledExtensions) {
for (const extension of enabledExtensions) {
const cipName = `cip${extension.cip}` as keyof CipExtensionApis;
if (additionalCipApis[cipName]) {
baseApi[cipName] = additionalCipApis[cipName];
}
}
}

return baseApi;
};

/** CIP30 API version */
export type ApiVersion = string;

Expand All @@ -93,7 +45,19 @@ export type WalletName = string;
*/
export type WalletIcon = string;

export type WalletProperties = { icon: WalletIcon; walletName: WalletName };
export type WalletProperties = {
icon: WalletIcon;
walletName: WalletName;
supportedExtensions?: WalletApiExtension[];
/** Deviations from the CIP30 spec */
cip30ApiDeviations?: {
/**
* Instead of throwing an error when no collateral is found, return empty array.
* This is the way the Nami wallet works and some DApps rely on it (i.e. https://app.indigoprotocol.io/)
*/
getCollateralEmptyArray?: boolean;
};
};

export type WalletDependencies = {
logger: Logger;
Expand All @@ -108,20 +72,26 @@ export class Cip30Wallet {
readonly apiVersion: ApiVersion = '0.1.0';
readonly name: WalletName;
readonly icon: WalletIcon;
/** Support the full api by default */
readonly supportedExtensions: WalletApiExtension[] = [{ cip: 95 }];

readonly #logger: Logger;
readonly #api: WalletApi;
readonly #authenticator: RemoteAuthenticator;
readonly #deviations: WalletProperties['cip30ApiDeviations'];

constructor(properties: WalletProperties, { api, authenticator, logger }: WalletDependencies) {
this.icon = properties.icon;
this.name = properties.walletName;
this.#api = api;
this.#logger = logger;
this.#authenticator = authenticator;
this.#deviations = properties.cip30ApiDeviations;
this.enable = this.enable.bind(this);
this.isEnabled = this.isEnabled.bind(this);
if (properties.supportedExtensions) {
this.supportedExtensions = properties.supportedExtensions;
}
}

#validateExtensions(extensions: WalletApiExtension[] = []): void {
Expand Down Expand Up @@ -173,9 +143,60 @@ export class Cip30Wallet {
const extensions = options?.extensions?.filter(({ cip: requestedCip }) =>
this.supportedExtensions.some(({ cip: supportedCip }) => supportedCip === requestedCip)
);
return wrapAndEnableApi(this.#api, extensions);
return this.#wrapAndEnableApi(extensions);
}
this.#logger.debug(`${location.origin} not authorized to access wallet api`);
throw new ApiError(APIErrorCode.Refused, 'wallet not authorized.');
}

async #wrapGetCollateral(params?: { amount?: Cbor }) {
const collateral = await this.#api.getCollateral(params);
return this.#deviations?.getCollateralEmptyArray ? collateral ?? [] : collateral;
}

/**
* Wrap the proxy API object with a regular javascript object to avoid interop issues with some dApps.
*
* Only return the allowed API methods.
*/
#wrapAndEnableApi(enabledExtensions?: WalletApiExtension[]): Cip30WalletApiWithPossibleExtensions {
const walletApi = this.#api;
const baseApi: Cip30WalletApiWithPossibleExtensions = {
// Add experimental.getCollateral to CIP-30 API
experimental: {
getCollateral: async (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params)
},
getBalance: () => walletApi.getBalance(),
getChangeAddress: () => walletApi.getChangeAddress(),
getCollateral: (params?: { amount?: Cbor }) => this.#wrapGetCollateral(params),
getExtensions: () => Promise.resolve(enabledExtensions || []),
getNetworkId: () => walletApi.getNetworkId(),
getRewardAddresses: () => walletApi.getRewardAddresses(),
getUnusedAddresses: () => walletApi.getUnusedAddresses(),
getUsedAddresses: (paginate?: Paginate) => walletApi.getUsedAddresses(paginate),
getUtxos: (amount?: Cbor, paginate?: Paginate) => walletApi.getUtxos(amount, paginate),
signData: (addr: Cardano.PaymentAddress | Bytes, payload: Bytes) => walletApi.signData(addr, payload),
signTx: (tx: Cbor, partialSign?: Boolean) => walletApi.signTx(tx, partialSign),
submitTx: (tx: Cbor) => walletApi.submitTx(tx)
};

const additionalCipApis: CipExtensionApis = {
cip95: {
getPubDRepKey: () => walletApi.getPubDRepKey(),
getRegisteredPubStakeKeys: () => walletApi.getRegisteredPubStakeKeys(),
getUnregisteredPubStakeKeys: () => walletApi.getUnregisteredPubStakeKeys()
}
};

if (enabledExtensions) {
for (const extension of enabledExtensions) {
const cipName = `cip${extension.cip}` as keyof CipExtensionApis;
if (additionalCipApis[cipName]) {
baseApi[cipName] = additionalCipApis[cipName];
}
}
}

return baseApi;
}
}
10 changes: 8 additions & 2 deletions packages/dapp-connector/src/injectGlobal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { Logger } from 'ts-log';

export type WindowMaybeWithCardano = Window & { cardano?: { [k: string]: Cip30Wallet } };

export const injectGlobal = (window: WindowMaybeWithCardano, wallet: Cip30Wallet, logger: Logger): void => {
export const injectGlobal = (
window: WindowMaybeWithCardano,
wallet: Cip30Wallet,
logger: Logger,
injectKey?: string
): void => {
injectKey = injectKey ?? wallet.name;
if (!window.cardano) {
logger.debug(
{
Expand All @@ -22,7 +28,7 @@ export const injectGlobal = (window: WindowMaybeWithCardano, wallet: Cip30Wallet
'Cardano global scope exists'
);
}
window.cardano[wallet.name] = window.cardano[wallet.name] || wallet;
window.cardano[injectKey] = window.cardano[injectKey] || wallet;
logger.debug(
{
module: 'injectWindow',
Expand Down
44 changes: 44 additions & 0 deletions packages/dapp-connector/test/WalletApi/Cip30Wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe('Wallet', () => {
let authenticator: RemoteAuthenticator;

let wallet: Cip30Wallet;
let walletNoExtensions: Cip30Wallet;
let walletWithApiDeviations: Cip30Wallet;

beforeEach(async () => {
await browser.storage.local.clear();
Expand All @@ -26,6 +28,24 @@ describe('Wallet', () => {
authenticator,
logger
});

walletNoExtensions = new Cip30Wallet(
{ ...testWallet.properties, supportedExtensions: [] },
{
api: testWallet.api,
authenticator,
logger
}
);

walletWithApiDeviations = new Cip30Wallet(
{ ...testWallet.properties, cip30ApiDeviations: { getCollateralEmptyArray: true } },
{
api: testWallet.api,
authenticator,
logger
}
);
});

test('constructed state', async () => {
Expand All @@ -41,6 +61,11 @@ describe('Wallet', () => {
expect(typeof wallet.enable).toBe('function');
});

test('constructed state without extensions', async () => {
expect(walletNoExtensions.name).toBe(testWallet.properties.walletName);
expect(walletNoExtensions.supportedExtensions).toEqual<WalletApiExtension[]>([]);
});

it('should return initial api as plain javascript object', () => {
// Verbose to enable easy detection of which are missing
expect(wallet.hasOwnProperty('apiVersion')).toBe(true);
Expand Down Expand Up @@ -73,6 +98,13 @@ describe('Wallet', () => {
expect(await api.getExtensions()).toEqual([{ cip: 95 }]);
});

test('no extensions wallet cannot enable cip95 extension', async () => {
const api = await walletNoExtensions.enable({ extensions: [{ cip: 95 }] });
expect(await walletNoExtensions.isEnabled()).toBe(true);
expect(await api.getExtensions()).toEqual([]);
expect(api.cip95).toBeUndefined();
});

test('change extensions after enabling once', async () => {
const cip30api = await wallet.enable();
const cip30methods = new Set(Object.keys(cip30api));
Expand Down Expand Up @@ -158,9 +190,11 @@ describe('Wallet', () => {

describe('api', () => {
let api: Cip30WalletApiWithPossibleExtensions;
let apiWithDeviations: Cip30WalletApiWithPossibleExtensions;

beforeAll(async () => {
api = await wallet.enable();
apiWithDeviations = await walletWithApiDeviations.enable();
});

test('getNetworkId', async () => {
Expand Down Expand Up @@ -262,5 +296,15 @@ describe('Wallet', () => {
const extensions = await api.getExtensions();
expect(extensions).toEqual([]);
});

test('getCollateral', async () => {
expect(api.getCollateral).toBeDefined();
expect(apiWithDeviations.getCollateral).toBeDefined();
expect(typeof api.getCollateral).toBe('function');

const collateral = await api.getCollateral();
expect(collateral).toEqual(null);
expect(await apiWithDeviations.getCollateral()).toEqual([]);
});
});
});
14 changes: 14 additions & 0 deletions packages/dapp-connector/test/injectGlobal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,19 @@ describe('injectGlobal', () => {
]);
expect(window.cardano['another-obj']).toBe(anotherObj);
});

it('injects the wallet public API using custom injection name', () => {
const wallet = new Cip30Wallet(properties, { api, authenticator: stubAuthenticator(), logger });
injectGlobal(window, wallet, logger, 'customKey');
expect(window.cardano.customKey.name).toBe(properties.walletName);
expect(Object.keys(window.cardano.customKey)).toEqual([
'apiVersion',
'supportedExtensions',
'icon',
'name',
'enable',
'isEnabled'
]);
});
});
});

0 comments on commit 15b68e6

Please sign in to comment.