diff --git a/packages/core/src/Cardano/Address/DRepID.ts b/packages/core/src/Cardano/Address/DRepID.ts new file mode 100644 index 00000000000..5ed1332d321 --- /dev/null +++ b/packages/core/src/Cardano/Address/DRepID.ts @@ -0,0 +1,29 @@ +import { Address, AddressType } from './Address'; +import { OpaqueString, assertIsBech32WithPrefix, typedBech32 } from '@cardano-sdk/util'; +/** + * DRepID as bech32 string + */ +export type DRepID = OpaqueString<'DRepID'>; + +/** + * @param {string} value DRepID as bech32 string + * @throws InvalidStringError + */ +export const DRepID = (value: string): DRepID => typedBech32(value, ['drep']); + +DRepID.isValid = (value: string): boolean => { + try { + assertIsBech32WithPrefix(value, 'drep'); + return true; + } catch { + return false; + } +}; + +DRepID.canSign = (value: string): boolean => { + try { + return DRepID.isValid(value) && Address.fromBech32(value).getType() === AddressType.EnterpriseKey; + } catch { + return false; + } +}; diff --git a/packages/core/src/Cardano/Address/PaymentAddress.ts b/packages/core/src/Cardano/Address/PaymentAddress.ts index 9d922ffdd28..f22e5d8c0ab 100644 --- a/packages/core/src/Cardano/Address/PaymentAddress.ts +++ b/packages/core/src/Cardano/Address/PaymentAddress.ts @@ -1,4 +1,5 @@ import { Address, AddressType } from './Address'; +import { DRepID } from './DRepID'; import { HexBlob, InvalidStringError, @@ -37,7 +38,7 @@ const isRewardAccount = (address: string) => { */ export const PaymentAddress = (value: string): PaymentAddress => { if (Address.isValid(value)) { - if (isRewardAccount(value)) { + if (isRewardAccount(value) || DRepID.isValid(value)) { throw new InvalidStringError(value, 'Address type can only be used for payment addresses'); } return value as unknown as PaymentAddress; @@ -87,7 +88,7 @@ export interface InputResolver { * @param address The address to get the network id from. * @returns The network ID. */ -export const addressNetworkId = (address: RewardAccount | PaymentAddress): NetworkId => { +export const addressNetworkId = (address: RewardAccount | PaymentAddress | DRepID): NetworkId => { const addr = Address.fromString(address); return addr!.getNetworkId(); }; diff --git a/packages/core/src/Cardano/Address/index.ts b/packages/core/src/Cardano/Address/index.ts index dae7049ef5e..4c6958ee690 100644 --- a/packages/core/src/Cardano/Address/index.ts +++ b/packages/core/src/Cardano/Address/index.ts @@ -1,6 +1,7 @@ export * from './Address'; export * from './BaseAddress'; export * from './ByronAddress'; +export * from './DRepID'; export * from './EnterpriseAddress'; export * from './PaymentAddress'; export * from './PointerAddress'; diff --git a/packages/core/test/Cardano/Address/DRepID.test.ts b/packages/core/test/Cardano/Address/DRepID.test.ts new file mode 100644 index 00000000000..a9c8760595a --- /dev/null +++ b/packages/core/test/Cardano/Address/DRepID.test.ts @@ -0,0 +1,32 @@ +import { DRepID } from '../../../src/Cardano'; +import { InvalidStringError } from '@cardano-sdk/util'; + +describe('Cardano/Address/DRepID', () => { + it('DRepID() accepts a valid bech32 string with drep as prefix', () => { + expect(() => DRepID('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')).not.toThrow(); + }); + + it('DRepID() throws an error if the bech32 string has the wrong prefix', () => { + expect(() => DRepID('addr_test1vpudzrw5uq46qwl6h5szlc66fydr0l2rlsw4nvaaxfld40g3ys07c')).toThrowError( + InvalidStringError + ); + }); + + describe('isValid', () => { + it('is true if string is a valid DRepID', () => { + expect(DRepID.isValid('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')).toBe(true); + }); + it('is false if string is not a valid DRepID', () => { + expect(DRepID.isValid('addr_test1vpudzrw5uq46qwl6h5szlc66fydr0l2rlsw4nvaaxfld40g3ys07c')).toBe(false); + }); + }); + + describe('canSign', () => { + it('is true if DRepID is a valid type 6 address', () => { + expect(DRepID.canSign('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')).toBe(true); + }); + it('is false if DRepID is not a type 6 address', () => { + expect(DRepID.canSign('drep1wpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9qcluy2z')).toBe(false); + }); + }); +}); diff --git a/packages/core/test/Cardano/Address/PaymentAddress.test.ts b/packages/core/test/Cardano/Address/PaymentAddress.test.ts index d367a3f9fc4..8c87cab59a7 100644 --- a/packages/core/test/Cardano/Address/PaymentAddress.test.ts +++ b/packages/core/test/Cardano/Address/PaymentAddress.test.ts @@ -55,6 +55,12 @@ describe('PaymentAddress', () => { ).toThrowError(InvalidStringError); }); + it('PaymentAddress() throws an error when passing a DRepID', () => { + expect(() => Cardano.PaymentAddress('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')).toThrowError( + InvalidStringError + ); + }); + describe('addressNetworkId', () => { it('parses testnet address', () => { expect( @@ -97,6 +103,18 @@ describe('PaymentAddress', () => { ) ).toBe(Cardano.NetworkId.Mainnet); }); + + it('parses testnet DRepID', () => { + expect( + Cardano.addressNetworkId(Cardano.DRepID('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz')) + ).toBe(Cardano.NetworkId.Testnet); + }); + + it('parses mainnet DRepID', () => { + expect( + Cardano.addressNetworkId(Cardano.DRepID('drep1v9gkc6jge96t40w46592tahq94n2rzhdhk2puvtz3dsfzys04jeym')) + ).toBe(Cardano.NetworkId.Mainnet); + }); }); describe('from hex-encoded bytes', () => { diff --git a/packages/dapp-connector/src/WalletApi/types.ts b/packages/dapp-connector/src/WalletApi/types.ts index c0739ac02f3..9727131ce28 100644 --- a/packages/dapp-connector/src/WalletApi/types.ts +++ b/packages/dapp-connector/src/WalletApi/types.ts @@ -145,7 +145,10 @@ export type SignTx = (tx: Cbor, partialSign?: Boolean) => Promise; * @throws ApiError * @throws DataSignError */ -export type SignData = (addr: Cardano.PaymentAddress | Bytes, payload: Bytes) => Promise; +export type SignData = ( + addr: Cardano.PaymentAddress | Cardano.DRepID | Bytes, + payload: Bytes +) => Promise; /** * As wallets should already have this ability, we allow dApps to request that a transaction be sent through it. diff --git a/packages/e2e/test/web-extension/extension/const.ts b/packages/e2e/test/web-extension/extension/const.ts index 2649634dbef..d23a0b5159a 100644 --- a/packages/e2e/test/web-extension/extension/const.ts +++ b/packages/e2e/test/web-extension/extension/const.ts @@ -17,10 +17,12 @@ export const selectors = { btnDelegate: '#multiDelegation .delegate button', btnGrantAccess: '#requestAccessGrant', btnSignAndBuildTx: '#buildAndSignTx', + btnSignDataWithDRepId: '#signDataWithDRepId', deactivateWallet: '#deactivateWallet', destroyWallet: '#destroyWallet', divAdaPrice: '#adaPrice', divBgPortDisconnectStatus: '#remoteApiPortDisconnect .bgPortDisconnect', + divDataSignature: '#dataSignature', divSignature: '#signature', divUiPortDisconnectStatus: '#remoteApiPortDisconnect .uiPortDisconnect', liPercents: '#multiDelegation .distribution li .percent', diff --git a/packages/e2e/test/web-extension/extension/ui.html b/packages/e2e/test/web-extension/extension/ui.html index 192e3744641..77f2b2931cf 100644 --- a/packages/e2e/test/web-extension/extension/ui.html +++ b/packages/e2e/test/web-extension/extension/ui.html @@ -44,6 +44,9 @@

Delegation distribution:

Signature: -
+ + +
Signature: -
diff --git a/packages/e2e/test/web-extension/extension/ui.ts b/packages/e2e/test/web-extension/extension/ui.ts index e18ece5d32d..1b307254960 100644 --- a/packages/e2e/test/web-extension/extension/ui.ts +++ b/packages/e2e/test/web-extension/extension/ui.ts @@ -24,6 +24,7 @@ import { import { bip32Ed25519Factory, keyManagementFactory } from '../../../src'; import { Cardano } from '@cardano-sdk/core'; +import { HexBlob } from '@cardano-sdk/util'; import { combineLatest, firstValueFrom, of } from 'rxjs'; import { runtime } from 'webextension-polyfill'; import { setupWallet } from '@cardano-sdk/wallet'; @@ -113,6 +114,23 @@ const sendDelegationTx = async (portfolio: { pool: Cardano.StakePool; weight: nu document.querySelector('#multiDelegation .delegateTxId')!.textContent = msg; }; +const signDataWithDRepID = async (): Promise => { + let msg: string; + const dRepId = 'drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz'; + try { + const signature = await wallet.signData({ + payload: HexBlob('abc123'), + signWith: Cardano.PaymentAddress(dRepId) + }); + msg = JSON.stringify(signature); + } catch (error) { + msg = `ERROR signing data with DRepID: ${JSON.stringify(error)}`; + } + + // Set text with signature or error + document.querySelector(selectors.divDataSignature)!.textContent = msg; +}; + const setAddresses = ({ address, stakeAddress }: { address: string; stakeAddress: string }): void => { document.querySelector(selectors.spanAddress)!.textContent = address; document.querySelector(selectors.spanStakeAddress)!.textContent = stakeAddress; @@ -262,6 +280,10 @@ document.querySelector(selectors.btnSignAndBuildTx)!.addEventListener('click', a setSignature(signedTx.witness.signatures.values().next().value); }); +document + .querySelector(selectors.btnSignDataWithDRepId)! + .addEventListener('click', async () => await signDataWithDRepID()); + // Code below tests that a disconnected port in background script will result in the consumed API method call promise to reject // UI consumes API -> BG exposes fake API that closes port const disconnectPortTestObj = consumeRemoteApi( diff --git a/packages/e2e/test/web-extension/specs/dapp-cip95.spec.ts b/packages/e2e/test/web-extension/specs/dapp-cip95.spec.ts index 47656028ed5..97224e7dfc2 100644 --- a/packages/e2e/test/web-extension/specs/dapp-cip95.spec.ts +++ b/packages/e2e/test/web-extension/specs/dapp-cip95.spec.ts @@ -80,7 +80,7 @@ describe('dapp/cip95', () => { it('getPubDRepKey gets the DRep key from cip95 wallet api', async () => { const dappDrepKey = await $(dappGetPubDrepKey).getText(); expect(dappDrepKey.length).toBeGreaterThan(0); - await expect($(dappDrepId)).toHaveTextContaining('drep_id'); + await expect($(dappDrepId)).toHaveTextContaining('drep'); }); }); }); diff --git a/packages/e2e/test/web-extension/specs/wallet.spec.ts b/packages/e2e/test/web-extension/specs/wallet.spec.ts index 30c75b3f6be..6d94629dba9 100644 --- a/packages/e2e/test/web-extension/specs/wallet.spec.ts +++ b/packages/e2e/test/web-extension/specs/wallet.spec.ts @@ -23,7 +23,9 @@ describe('wallet', () => { liPools, liPercents, divBgPortDisconnectStatus, - divUiPortDisconnectStatus + divUiPortDisconnectStatus, + btnSignDataWithDRepId, + divDataSignature } = selectors; // The address is filled in by the tests, which are order dependent @@ -135,6 +137,18 @@ describe('wallet', () => { await buildAndSign(); }); + it('can sign data with a DRepID', async () => { + (await $(btnSignDataWithDRepId)).click(); + const signature = await $(divDataSignature).getText(); + expect(signature).toHaveTextContaining( + JSON.stringify({ + key: 'a5010102581d60a7484b9d9185af363f9412627c42f47c7ae14e95b3a4603f4c34860403272006215820a76722da33bcd685429f4aca04e57fd1366a0b3410770fc0f5c161934b8ba1af', + signature: + '84584aa3012704581d60a7484b9d9185af363f9412627c42f47c7ae14e95b3a4603f4c3486046761646472657373581d60a7484b9d9185af363f9412627c42f47c7ae14e95b3a4603f4c348604a166686173686564f443abc1235840ea25fdcd108a591e67987de272b8c822cd2f9cf621ff1938db594cafb1cfdb879de42a81dab5698c41dd968515583a50d12abc4bbee356a2d6ac97e54e3a680f' + }) + ); + }); + it('can destroy second wallet before switching back to the first wallet', async () => { // Destroy also clears associated store. Store will be rebuilt during future activation of same wallet await $(destroyWallet).click(); diff --git a/packages/key-management/src/cip8/cip30signData.ts b/packages/key-management/src/cip8/cip30signData.ts index 34c4335c6af..f1e6e4f04b4 100644 --- a/packages/key-management/src/cip8/cip30signData.ts +++ b/packages/key-management/src/cip8/cip30signData.ts @@ -18,12 +18,12 @@ import { Cardano, util } from '@cardano-sdk/core'; import { Cip30DataSignature } from '@cardano-sdk/dapp-connector'; import { ComposableError, HexBlob } from '@cardano-sdk/util'; import { CoseLabel } from './util'; -import { STAKE_KEY_DERIVATION_PATH } from '../util'; +import { DREP_KEY_DERIVATION_PATH, STAKE_KEY_DERIVATION_PATH } from '../util'; import { filter, firstValueFrom } from 'rxjs'; export interface Cip30SignDataRequest { keyAgent: AsyncKeyAgent; - signWith: Cardano.PaymentAddress | Cardano.RewardAccount; + signWith: Cardano.PaymentAddress | Cardano.RewardAccount | Cardano.DRepID; payload: HexBlob; } @@ -39,7 +39,7 @@ export class Cip30DataSignError extends ComposableError { +const getAddressBytes = (signWith: Cardano.PaymentAddress | Cardano.RewardAccount | Cardano.DRepID) => { const address = Cardano.Address.fromString(signWith); if (!address) { @@ -49,7 +49,14 @@ const getAddressBytes = (signWith: Cardano.PaymentAddress | Cardano.RewardAccoun return Buffer.from(address.toBytes(), 'hex'); }; -const getDerivationPath = async (signWith: Cardano.PaymentAddress | Cardano.RewardAccount, keyAgent: AsyncKeyAgent) => { +const getDerivationPath = async ( + signWith: Cardano.PaymentAddress | Cardano.RewardAccount | Cardano.DRepID, + keyAgent: AsyncKeyAgent +) => { + if (Cardano.DRepID.isValid(signWith)) { + return DREP_KEY_DERIVATION_PATH; + } + const isRewardAccount = signWith.startsWith('stake'); const knownAddresses = await firstValueFrom( @@ -114,6 +121,9 @@ export const cip30signData = async ({ signWith, payload }: Cip30SignDataRequest): Promise => { + if (Cardano.DRepID.isValid(signWith) && !Cardano.DRepID.canSign(signWith)) { + throw new Cip30DataSignError(Cip30DataSignErrorCode.AddressNotPK, 'Invalid address'); + } const addressBytes = getAddressBytes(signWith); const derivationPath = await getDerivationPath(signWith, keyAgent); diff --git a/packages/wallet/src/cip30.ts b/packages/wallet/src/cip30.ts index 5fcbed024fe..65129758f5e 100644 --- a/packages/wallet/src/cip30.ts +++ b/packages/wallet/src/cip30.ts @@ -35,7 +35,7 @@ export enum Cip30ConfirmationCallbackType { export type SignDataCallbackParams = { type: Cip30ConfirmationCallbackType.SignData; data: { - addr: Cardano.PaymentAddress; + addr: Cardano.PaymentAddress | Cardano.DRepID; payload: HexBlob; }; }; @@ -397,10 +397,13 @@ const baseCip30WalletApi = ( scope.dispose(); } }, - signData: async (addr: Cardano.PaymentAddress | Bytes, payload: Bytes): Promise => { + signData: async ( + addr: Cardano.PaymentAddress | Cardano.DRepID | Bytes, + payload: Bytes + ): Promise => { logger.debug('signData'); const hexBlobPayload = HexBlob(payload); - const signWith = Cardano.PaymentAddress(addr); + const signWith = Cardano.DRepID.isValid(addr) ? Cardano.DRepID(addr) : Cardano.PaymentAddress(addr); const shouldProceed = await confirmationCallback .signData({ diff --git a/packages/wallet/test/PersonalWallet/methods.test.ts b/packages/wallet/test/PersonalWallet/methods.test.ts index f1bc74d1c8c..01ebbbae2ba 100644 --- a/packages/wallet/test/PersonalWallet/methods.test.ts +++ b/packages/wallet/test/PersonalWallet/methods.test.ts @@ -12,9 +12,9 @@ import { CML, Cardano, CardanoNodeErrors, ProviderError, ProviderFailure, TxCBOR import { HexBlob } from '@cardano-sdk/util'; import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction'; import { PersonalWallet, TxInFlight, setupWallet } from '../../src'; +import { buildDRepIDFromDRepKey, toOutgoingTx, waitForWalletStateSettle } from '../util'; import { getPassphrase, stakeKeyDerivationPath, testAsyncKeyAgent } from '../../../key-management/test/mocks'; import { dummyLogger as logger } from 'ts-log'; -import { toOutgoingTx, waitForWalletStateSettle } from '../util'; import delay from 'delay'; const { mockChainHistoryProvider, mockRewardsProvider, utxo } = mocks; @@ -547,9 +547,29 @@ describe('PersonalWallet methods', () => { }); }); - it('signData calls cip30signData', async () => { - const response = await wallet.signData({ payload: HexBlob('abc123'), signWith: address }); - expect(response).toHaveProperty('signature'); + describe('signData', () => { + it('calls cip30signData', async () => { + const response = await wallet.signData({ payload: HexBlob('abc123'), signWith: address }); + expect(response).toHaveProperty('signature'); + }); + + it('signs with bech32 DRepID', async () => { + const response = await wallet.signData({ + payload: HexBlob('abc123'), + signWith: Cardano.DRepID('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz') + }); + expect(response).toHaveProperty('signature'); + }); + + test('rejects if bech32 DRepID is not a type 6 address', async () => { + const dRepKey = await wallet.getPubDRepKey(); + 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(wallet.signData({ payload: HexBlob('abc123'), signWith: drepid })).rejects.toThrow(); + } + } + }); }); it('getPubDRepKey', async () => { diff --git a/packages/wallet/test/integration/cip30mapping.test.ts b/packages/wallet/test/integration/cip30mapping.test.ts index 669d1faf27a..94b2fee9ada 100644 --- a/packages/wallet/test/integration/cip30mapping.test.ts +++ b/packages/wallet/test/integration/cip30mapping.test.ts @@ -21,10 +21,10 @@ import { InMemoryUnspendableUtxoStore, createInMemoryWalletStores } from '../../ import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction'; import { PersonalWallet, cip30, setupWallet } from '../../src'; import { Providers, createWallet } from './util'; +import { buildDRepIDFromDRepKey, waitForWalletStateSettle } from '../util'; import { firstValueFrom, of } from 'rxjs'; import { dummyLogger as logger } from 'ts-log'; import { stakeKeyDerivationPath, testAsyncKeyAgent } from '../../../key-management/test/mocks'; -import { waitForWalletStateSettle } from '../util'; const { mockChainHistoryProvider, @@ -471,11 +471,32 @@ describe('cip30', () => { expect(() => Serialization.TransactionWitnessSet.fromCbor(HexBlob(cip30witnessSet))).not.toThrow(); }); - test('api.signData', async () => { - const [{ address }] = await firstValueFrom(wallet.addresses$); - const cip30dataSignature = await api.signData(address, HexBlob('abc123')); - expect(typeof cip30dataSignature.key).toBe('string'); - expect(typeof cip30dataSignature.signature).toBe('string'); + describe('api.signData', () => { + test('sign with address', async () => { + const [{ address }] = await firstValueFrom(wallet.addresses$); + const cip30dataSignature = await api.signData(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 drepid = buildDRepIDFromDRepKey(dRepKey); + + const cip95dataSignature = await api.signData(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(); + 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(); + } + } + }); }); describe('api.submitTx', () => { diff --git a/packages/wallet/test/util.ts b/packages/wallet/test/util.ts index 9f8f98e7b44..acfd694f734 100644 --- a/packages/wallet/test/util.ts +++ b/packages/wallet/test/util.ts @@ -1,7 +1,9 @@ /* eslint-disable func-style */ /* eslint-disable jsdoc/require-jsdoc */ +import * as Crypto from '@cardano-sdk/crypto'; import { Cardano, TxCBOR } from '@cardano-sdk/core'; +import { HexBlob } from '@cardano-sdk/util'; import { Observable, catchError, filter, firstValueFrom, throwError, timeout } from 'rxjs'; import { ObservableWallet, OutgoingTx } from '../src'; @@ -37,3 +39,25 @@ export const toOutgoingTx = (tx: Cardano.Tx): OutgoingTx => ({ }); export const dummyCbor = TxCBOR('123'); + +/** + * Construct a type 6 address for a DRepKey + * using an appropriate Network Tag and a hash of a public DRep Key. + */ +export const buildDRepIDFromDRepKey = ( + dRepKey: Crypto.Ed25519PublicKeyHex, + networkId: Cardano.NetworkId = Cardano.NetworkId.Testnet, + addressType: Cardano.AddressType = Cardano.AddressType.EnterpriseKey +) => { + const dRepKeyBytes = Buffer.from(dRepKey, 'hex'); + const dRepIdHex = Crypto.blake2b(28).update(dRepKeyBytes).digest('hex'); + const paymentAddress = Cardano.EnterpriseAddress.packParts({ + networkId, + paymentPart: { + hash: Crypto.Hash28ByteBase16(dRepIdHex), + type: Cardano.CredentialType.KeyHash + }, + type: addressType + }); + return HexBlob.toTypedBech32('drep', HexBlob.fromBytes(paymentAddress)); +};