Merge pull request #919 from input-output-hk/feat/lw-7973-cip95-signTx
Feat/lw 7973 cip95 sign tx
Feat/lw 7973 cip95 sign tx
"prepack": "yarn build"
},
"devDependencies": {
"@cardano-sdk/util-dev": "workspace:~",
"@types/lodash": "^4.14.182",
"@types/pbkdf2": "^3.1.0",
"eslint": "^7.32.0",
SignTransactionOptions
} from './types';
import { Cardano } from '@cardano-sdk/core';
import { HexBlob } from '@cardano-sdk/util';
import { KeyAgentBase } from './KeyAgentBase';
import {
DREP_KEY_DERIVATION_PATH,
deriveAccountPrivateKey,
harden,
joinMnemonicWords,
mnemonicWordsToEntropy,
ownSignatureKeyPaths,
validateMnemonic
} from './util';
import { HexBlob } from '@cardano-sdk/util';
import { KeyAgentBase } from './KeyAgentBase';
import { emip3decrypt, emip3encrypt } from './emip3';
import uniqBy from 'lodash/uniqBy';
): Promise<Cardano.Signatures> {
// Possible optimization is casting strings to OpaqueString types directly and skipping validation
const blob = HexBlob(hash);
const derivationPaths = await ownSignatureKeyPaths(body, this.knownAddresses, this.inputResolver);
const dRepKeyHash = (
await Crypto.Ed25519PublicKey.fromHex(await this.derivePublicKey(DREP_KEY_DERIVATION_PATH)).hash()
).hex();
const derivationPaths = await ownSignatureKeyPaths(body, this.knownAddresses, this.inputResolver, dRepKeyHash);
const keyPaths = uniqBy([...derivationPaths, ...additionalKeyPaths], ({ role, index }) => `${role}.${index}`);
// TODO:
// if (keyPaths.length === 0) {
import * as Crypto from '@cardano-sdk/crypto';
import { AccountKeyDerivationPath, GroupedAddress } from '../types';
import { Cardano } from '@cardano-sdk/core';
import { DREP_KEY_DERIVATION_PATH } from './key';
import { isNotNil } from '@cardano-sdk/util';
import isEqual from 'lodash/isEqual';
import uniq from 'lodash/uniq';
import uniqBy from 'lodash/uniqBy';
import uniqWith from 'lodash/uniqWith';
export type StakeKeySignerData = {
poolId: Cardano.PoolId;
rewardAccount: Cardano.RewardAccount;
stakeKeyHash: Crypto.Ed25519KeyHashHex;
derivationPath: AccountKeyDerivationPath;
};
/** Return type of functions that inspect transaction parts for signatures */
type SignatureCheck = {
/** Signature derivation paths */
derivationPaths: AccountKeyDerivationPath[];
/** Whether foreign signatures (not owned by wallet) are needed */
requiresForeignSignatures: boolean;
};
/** Checks whether the transaction withdrawals contain foreign signatures and returns known derivation paths */
const checkWithdrawals = (
{ withdrawals }: Pick<Cardano.TxBody, 'withdrawals'>,
accounts: StakeKeySignerData[]
): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
if (withdrawals) {
for (const withdrawal of withdrawals) {
const account = accounts.find((acct) => acct.rewardAccount === withdrawal.stakeAddress);
if (account) {
signatureCheck.derivationPaths.push(account.derivationPath);
} else {
signatureCheck.requiresForeignSignatures = true;
}
}
}
return signatureCheck;
};
const checkStakeKeyHashCertificate = (
certificate: Cardano.Certificate,
accounts: StakeKeySignerData[]
): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
switch (certificate.__typename) {
case Cardano.CertificateType.VoteDelegation:
case Cardano.CertificateType.StakeVoteDelegation:
case Cardano.CertificateType.StakeRegistrationDelegation:
case Cardano.CertificateType.VoteRegistrationDelegation:
case Cardano.CertificateType.StakeVoteRegistrationDelegation:
case Cardano.CertificateType.Unregistration:
case Cardano.CertificateType.StakeKeyDeregistration:
case Cardano.CertificateType.StakeDelegation: {
const account = accounts.find((acct) => acct.stakeKeyHash === certificate.stakeKeyHash);
if (account) {
signatureCheck.derivationPaths = [account.derivationPath];
} else {
signatureCheck.requiresForeignSignatures = true;
}
}
}
return signatureCheck;
};
const checkPoolRegistrationCertificate = (
certificate: Cardano.Certificate,
accounts: StakeKeySignerData[]
): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
if (certificate.__typename === Cardano.CertificateType.PoolRegistration) {
for (const owner of certificate.poolParameters.owners) {
const account = accounts.find((acct) => acct.rewardAccount === owner);
if (account) {
signatureCheck.derivationPaths.push(account.derivationPath);
} else {
signatureCheck.requiresForeignSignatures = true;
}
}
}
return signatureCheck;
};
const checkPoolRetirementCertificate = (
certificate: Cardano.Certificate,
accounts: StakeKeySignerData[]
): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
if (certificate.__typename === Cardano.CertificateType.PoolRetirement) {
const account = accounts.find((acct) => acct.poolId === certificate.poolId);
if (account) {
signatureCheck.derivationPaths.push(account.derivationPath);
} else {
signatureCheck.requiresForeignSignatures = true;
}
}
return signatureCheck;
};
const checkMirCertificate = (certificate: Cardano.Certificate, accounts: StakeKeySignerData[]): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
if (certificate.__typename === Cardano.CertificateType.MIR) {
if (certificate.kind === Cardano.MirCertificateKind.ToStakeCreds) {
const account = accounts.find(
(acct) => Crypto.Hash28ByteBase16(acct.stakeKeyHash) === certificate.stakeCredential!.hash
);
if (account) {
signatureCheck.derivationPaths.push(account.derivationPath);
} else {
signatureCheck.requiresForeignSignatures = true;
}
} else {
signatureCheck.requiresForeignSignatures = true;
}
}
return signatureCheck;
};
/**
* Gets whether any certificate in the provided certificate list requires a stake key signature.
* Inspect certificates against own stake credentials. Determines the signature derivation paths, and wether there
* are certificates it cannot sign (foreign signatures).
*/
export const checkStakeCredentialCertificates = (
accounts: StakeKeySignerData[],
{ certificates }: Pick<Cardano.TxBody, 'certificates'>
): SignatureCheck => {
const signatureCheck: SignatureCheck = { derivationPaths: [], requiresForeignSignatures: false };
if (!certificates?.length) {
return signatureCheck;
}
for (const certificate of certificates) {
const stakeKeyHashCheck = checkStakeKeyHashCertificate(certificate, accounts);
signatureCheck.requiresForeignSignatures ||= stakeKeyHashCheck.requiresForeignSignatures;
signatureCheck.derivationPaths.push(...stakeKeyHashCheck.derivationPaths);
const poolOwnerCheck = checkPoolRegistrationCertificate(certificate, accounts);
signatureCheck.requiresForeignSignatures ||= poolOwnerCheck.requiresForeignSignatures;
signatureCheck.derivationPaths.push(...poolOwnerCheck.derivationPaths);
const poolIdCheck = checkPoolRetirementCertificate(certificate, accounts);
signatureCheck.requiresForeignSignatures ||= poolIdCheck.requiresForeignSignatures;
signatureCheck.derivationPaths.push(...poolIdCheck.derivationPaths);
const mirCheck = checkMirCertificate(certificate, accounts);
signatureCheck.requiresForeignSignatures ||= mirCheck.requiresForeignSignatures;
signatureCheck.derivationPaths.push(...mirCheck.derivationPaths);
// StakeKeyRegistration and GenesisKeyDelegation do not require signing
}
signatureCheck.derivationPaths = uniqWith(signatureCheck.derivationPaths, isEqual);
return signatureCheck;
};
const getSignersData = (groupedAddresses: GroupedAddress[]): StakeKeySignerData[] =>
uniqBy(groupedAddresses, 'rewardAccount')
.map((groupedAddress) => {
const stakeKeyHash = Cardano.RewardAccount.toHash(groupedAddress.rewardAccount);
const poolId = Cardano.PoolId.fromKeyHash(stakeKeyHash);
return {
derivationPath: groupedAddress.stakeKeyDerivationPath,
poolId,
rewardAccount: groupedAddress.rewardAccount,
stakeKeyHash
};
})
.filter((acct): acct is StakeKeySignerData => acct.derivationPath !== undefined);
/**
* Gets whether withdrawals or any certificate in the provided certificate list requires a stake key signature.
*
* @param groupedAddresses The known grouped addresses.
* @param txBody The transaction body.
*/
// eslint-disable-next-line complexity
const getStakeKeyPaths = (
groupedAddresses: GroupedAddress[],
txBody: Cardano.TxBody
// eslint-disable-next-line sonarjs/cognitive-complexity
) => {
const paths: Set<AccountKeyDerivationPath> = new Set();
const uniqueAccounts = uniqBy(groupedAddresses, 'rewardAccount');
for (const account of uniqueAccounts) {
const stakeKeyHash = Cardano.RewardAccount.toHash(account.rewardAccount);
const poolId = Cardano.PoolId.fromKeyHash(stakeKeyHash);
if (!account.stakeKeyDerivationPath) continue;
if (txBody.withdrawals?.some((withdrawal) => account.rewardAccount === withdrawal.stakeAddress))
paths.add(account.stakeKeyDerivationPath);
const randomHexChar = () => Math.floor(Math.random() * 16).toString(16);
const randomPublicKey = () => Crypto.Ed25519PublicKeyHex(Array.from({ length: 64 }).map(randomHexChar).join(''));
export const stubSignTransaction = async (
txBody: Cardano.TxBody,
knownAddresses: GroupedAddress[],
inputResolver: Cardano.InputResolver,
extraSigners?: TransactionSigner[],
{ additionalKeyPaths = [] }: SignTransactionOptions = {}
): Promise<Cardano.Signatures> => {
export interface StubSignTransactionProps {
txBody: Cardano.TxBody;
knownAddresses: GroupedAddress[];
inputResolver: Cardano.InputResolver;
extraSigners?: TransactionSigner[];
dRepPublicKey: Crypto.Ed25519PublicKeyHex;
signTransactionOptions?: SignTransactionOptions;
}
export const stubSignTransaction = async ({
txBody,
knownAddresses,
inputResolver,
extraSigners,
dRepPublicKey,
signTransactionOptions = { additionalKeyPaths: [] }
}: StubSignTransactionProps): Promise<Cardano.Signatures> => {
const { additionalKeyPaths = [] } = signTransactionOptions;
const mockSignature = Crypto.Ed25519SignatureHex(
// eslint-disable-next-line max-len
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
);
const dRepKeyHash = (await Crypto.Ed25519PublicKey.fromHex(dRepPublicKey).hash()).hex();
const signatureKeyPaths = uniqWith(
[...(await ownSignatureKeyPaths(txBody, knownAddresses, inputResolver)), ...additionalKeyPaths],
[...(await ownSignatureKeyPaths(txBody, knownAddresses, inputResolver, dRepKeyHash)), ...additionalKeyPaths],
deepEquals
);
body,
hash: Cardano.TransactionId('8561258e210352fba2ac0488afed67b3427a27ccf1d41ec030c98a8199bc22ec')
});
expect(ownSignatureKeyPaths).toBeCalledWith(body, keyAgent.knownAddresses, inputResolver);
expect(ownSignatureKeyPaths).toBeCalledWith(body, keyAgent.knownAddresses, inputResolver, expect.anything());
expect(witnessSet.size).toBe(2);
expect(typeof [...witnessSet.values()][0]).toBe('string');
});
const ownStakeKeyHash = Cardano.RewardAccount.toHash(ownRewardAccount);
const otherStakeKeyHash = Cardano.RewardAccount.toHash(otherRewardAccount);
let dRepPublicKey: Crypto.Ed25519PublicKeyHex;
let dRepKeyHash: Crypto.Ed25519KeyHashHex;
const foreignDRepKeyHash = Crypto.Hash28ByteBase16('8293d319ef5b3ac72366dd28006bd315b715f7e7cfcbd3004129b80d');
const knownAddress1 = createGroupedAddress(address1, ownRewardAccount, AddressType.External, 0, stakeKeyPath);
beforeEach(async () => {
dRepPublicKey = Crypto.Ed25519PublicKeyHex('deeb8f82f2af5836ebbc1b450b6dbf0b03c93afe5696f10d49e8a8304ebfac01');
dRepKeyHash = (await Crypto.Ed25519PublicKey.fromHex(dRepPublicKey).hash()).hex();
});
it('returns distinct derivation paths required to sign the transaction', async () => {
const txBody = {
inputs: [{}, {}, {}]
]);
});
// eslint-disable-next-line max-len
it('does not return stake key derivation path when no certificate with wallet stake key hash is present', async () => {
it('does not return derivation paths when all certificates and voting procedures are foreign', async () => {
const txBody = {
certificates: [{ __typename: Cardano.CertificateType.StakeKeyRegistration, stakeKeyHash: otherStakeKeyHash }],
inputs: [{}, {}, {}]
certificates: [
{ __typename: Cardano.CertificateType.StakeKeyRegistration, stakeKeyHash: otherStakeKeyHash },
{ __typename: Cardano.CertificateType.VoteDelegation, stakeKeyHash: otherStakeKeyHash },
{ __typename: Cardano.CertificateType.StakeVoteDelegation, stakeKeyHash: otherStakeKeyHash },
{
__typename: Cardano.CertificateType.StakeRegistrationDelegation,
stakeKeyHash: otherStakeKeyHash
},
{
__typename: Cardano.CertificateType.VoteRegistrationDelegation,
stakeKeyHash: otherStakeKeyHash
},
{
__typename: Cardano.CertificateType.StakeVoteRegistrationDelegation,
stakeKeyHash: otherStakeKeyHash
},
{ __typename: Cardano.CertificateType.Unregistration, otherStakeKeyHash },
{
__typename: Cardano.CertificateType.UnregisterDelegateRepresentative,
dRepCredential: {
hash: foreignDRepKeyHash,
type: Cardano.CredentialType.KeyHash
},
deposit: 0n
} as Cardano.UnRegisterDelegateRepresentativeCertificate,
{
__typename: Cardano.CertificateType.UpdateDelegateRepresentative,
dRepCredential: {
hash: foreignDRepKeyHash,
type: Cardano.CredentialType.KeyHash
}
} as Cardano.UpdateDelegateRepresentativeCertificate
],
fee: 0n,
inputs: [{}, {}, {}] as Cardano.TxIn[],
outputs: [],
votingProcedures: [
{
voter: {
__typename: Cardano.VoterType.dRepKeyHash,
credential: { hash: foreignDRepKeyHash, type: Cardano.CredentialType.KeyHash }
},
votes: []
},
{
voter: {
__typename: Cardano.VoterType.stakePoolKeyHash,
credential: {
hash: Crypto.Hash28ByteBase16(otherStakeKeyHash),
type: Cardano.CredentialType.KeyHash
}
},
votes: []
}
]
} as Cardano.TxBody;
const resolveInput = jest
.fn()
.mockReturnValueOnce({ ...txOut, address: address1 })
.mockReturnValueOnce(address1);
expect(await util.ownSignatureKeyPaths(txBody, [knownAddress1], { resolveInput })).toEqual([
expect(await util.ownSignatureKeyPaths(txBody, [knownAddress1], { resolveInput }, dRepKeyHash)).toEqual([
{
index: 0,
role: KeyRole.External
}
]);
});
describe('Stake key derivation path', () => {
const rewardAccounts = [
'stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27',
'stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d',
'stake_test1uqrw9tjymlm8wrwq7jk68n6v7fs9qz8z0tkdkve26dylmfc2ux2hj',
'stake_test1uzwd0ng8pw7vvhm4k3s28azx9c6ytug60uh35jvztgg03rge58jf8',
'stake_test1urpklgzqsh9yqz8pkyuxcw9dlszpe5flnxjtl55epla6ftqktdyfz',
'stake_test1upqykkjq3zhf4085s6n70w8cyp57dl87r0ezduv9rnnj2uqk5zmdv'
]
.map((acct) => ({ account: Cardano.RewardAccount(acct) }))
.map(({ account }) => ({ account, stakeKeyHash: Cardano.RewardAccount.toHash(account) }));
// Using multiple stake keys with one payment key to have separate derivation paths per each certificate
const knownAddresses = rewardAccounts.map(({ account }, index) =>
createGroupedAddress(address1, account, AddressType.External, 0, { index, role: KeyRole.Stake })
);
it('is returned for certificates with the wallet stake key hash', async () => {
const txBody = {
certificates: [
{ __typename: Cardano.CertificateType.VoteDelegation, stakeKeyHash: rewardAccounts[0].stakeKeyHash },
{ __typename: Cardano.CertificateType.StakeVoteDelegation, stakeKeyHash: rewardAccounts[1].stakeKeyHash },
{
__typename: Cardano.CertificateType.StakeRegistrationDelegation,
stakeKeyHash: rewardAccounts[2].stakeKeyHash
},
{
__typename: Cardano.CertificateType.VoteRegistrationDelegation,
stakeKeyHash: rewardAccounts[3].stakeKeyHash
},
{
__typename: Cardano.CertificateType.StakeVoteRegistrationDelegation,
stakeKeyHash: rewardAccounts[4].stakeKeyHash
},
{ __typename: Cardano.CertificateType.Unregistration, stakeKeyHash: rewardAccounts[5].stakeKeyHash }
],
inputs: [{}, {}, {}]
} as Cardano.TxBody;
const resolveInput = jest
.fn()
.mockReturnValueOnce({ ...txOut, address: address1 })
.mockReturnValueOnce(address1);
expect(await util.ownSignatureKeyPaths(txBody, knownAddresses, { resolveInput })).toEqual([
{ index: 0, role: KeyRole.External },
{ index: 0, role: KeyRole.Stake },
{ index: 1, role: KeyRole.Stake },
{ index: 2, role: KeyRole.Stake },
{ index: 3, role: KeyRole.Stake },
{ index: 4, role: KeyRole.Stake },
{ index: 5, role: KeyRole.Stake }
]);
});
it('duplicates are removed when multiple certificates use the wallet stake key hash', async () => {
const txBody = {
certificates: [
{ __typename: Cardano.CertificateType.VoteDelegation, stakeKeyHash: rewardAccounts[0].stakeKeyHash },
{ __typename: Cardano.CertificateType.StakeVoteDelegation, stakeKeyHash: rewardAccounts[0].stakeKeyHash }
],
inputs: [{}, {}, {}]
} as Cardano.TxBody;
const resolveInput = jest
.fn()
.mockReturnValueOnce({ ...txOut, address: address1 })
.mockReturnValueOnce(address1);
expect(await util.ownSignatureKeyPaths(txBody, knownAddresses, { resolveInput })).toEqual([
{ index: 0, role: KeyRole.External },
{ index: 0, role: KeyRole.Stake }
]);
});
it('is returned for StakePool voter in voting procedures', async () => {
const txBody = {
fee: 0n,
inputs: [{}, {}, {}] as Cardano.TxIn[],
outputs: [],
votingProcedures: [
{
voter: {
__typename: Cardano.VoterType.stakePoolKeyHash,
credential: {
hash: Crypto.Hash28ByteBase16(rewardAccounts[3].stakeKeyHash),
type: Cardano.CredentialType.KeyHash
}
},
votes: []
}
]
} as Cardano.TxBody;
const resolveInput = jest
.fn()
.mockReturnValueOnce({ ...txOut, address: address1 })
.mockReturnValueOnce(address1);
expect(await util.ownSignatureKeyPaths(txBody, knownAddresses, { resolveInput })).toEqual([
{ index: 0, role: KeyRole.External },
{ index: 3, role: KeyRole.Stake }
]);
});
});
describe('DRep key derivation path', () => {
import { Cardano } from '@cardano-sdk/core';
import { Ed25519PublicKey, Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
import { GroupedAddress, util } from '../../src';
jest.mock('../../src/util/ownSignatureKeyPaths');
const inputResolver = {} as Cardano.InputResolver; // not called
const txBody = {} as Cardano.HydratedTxBody;
const knownAddresses = [{} as GroupedAddress];
const dRepPublicKey = Ed25519PublicKeyHex('0b1c96fad4179d7910bd9485ac28c4c11368c83d18d01b29d4cf84d8ff6a06c4');
const dRepKeyHash = (await Ed25519PublicKey.fromHex(dRepPublicKey).hash()).hex();
ownSignatureKeyPaths.mockReturnValueOnce(['a']).mockReturnValueOnce(['a', 'b']);
expect((await util.stubSignTransaction(txBody, knownAddresses, inputResolver)).size).toBe(1);
expect((await util.stubSignTransaction(txBody, knownAddresses, inputResolver)).size).toBe(2);
expect(ownSignatureKeyPaths).toBeCalledWith(txBody, knownAddresses, inputResolver);
expect((await util.stubSignTransaction({ dRepPublicKey, inputResolver, knownAddresses, txBody })).size).toBe(1);
expect((await util.stubSignTransaction({ dRepPublicKey, inputResolver, knownAddresses, txBody })).size).toBe(2);
expect(ownSignatureKeyPaths).toBeCalledWith(txBody, knownAddresses, inputResolver, dRepKeyHash);
});
});
stubSign = false
): Promise<SignedTx> => {
const signatures = stubSign
? await keyManagementUtil.stubSignTransaction(
tx.body,
ownAddresses,
? await keyManagementUtil.stubSignTransaction({
dRepPublicKey: await keyAgent.derivePublicKey(keyManagementUtil.DREP_KEY_DERIVATION_PATH),
extraSigners: witness?.extraSigners,
inputResolver,
witness?.extraSigners,
signingOptions
)
knownAddresses: ownAddresses,
signTransactionOptions: signingOptions,
txBody: tx.body
})
: await getSignatures(keyAgent, tx, witness?.extraSigners, signingOptions);
const transaction = {
/* eslint-disable sonarjs/no-duplicate-string */
import * as Crypto from '@cardano-sdk/crypto';
import { AddressType, AsyncKeyAgent, GroupedAddress, SignBlobResult, util } from '@cardano-sdk/key-management';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { AddressType } from '@cardano-sdk/key-management';
import { Cardano } from '@cardano-sdk/core';
import { GenericTxBuilder, OutputValidation } from '../../src';
import { StubKeyAgent, mockProviders as mocks } from '@cardano-sdk/util-dev';
import { dummyLogger } from 'ts-log';
import { generateRandomHexString, mockProviders as mocks } from '@cardano-sdk/util-dev';
import delay from 'delay';
class StubKeyAgent implements AsyncKeyAgent {
knownAddresses$ = new BehaviorSubject<GroupedAddress[]>([]);
constructor(private inputResolver: Cardano.InputResolver) {}
deriveAddress(): Promise<GroupedAddress> {
throw new Error('Method not implemented.');
}
derivePublicKey(): Promise<Crypto.Ed25519PublicKeyHex> {
throw new Error('Method not implemented.');
}
signBlob(): Promise<SignBlobResult> {
throw new Error('Method not implemented.');
}
async signTransaction(txInternals: Cardano.TxBodyWithHash): Promise<Cardano.Signatures> {
const signatures = new Map<Crypto.Ed25519PublicKeyHex, Crypto.Ed25519SignatureHex>();
const knownAddresses = await firstValueFrom(this.knownAddresses$);
for (const _ of await util.ownSignatureKeyPaths(txInternals.body, knownAddresses, this.inputResolver)) {
signatures.set(
Crypto.Ed25519PublicKeyHex(generateRandomHexString(64)),
Crypto.Ed25519SignatureHex(generateRandomHexString(128))
);
}
return signatures;
}
getChainId(): Promise<Cardano.ChainId> {
throw new Error('Method not implemented.');
}
getBip32Ed25519(): Promise<Crypto.Bip32Ed25519> {
throw new Error('Method not implemented.');
}
getExtendedAccountPublicKey(): Promise<Crypto.Bip32PublicKeyHex> {
throw new Error('Method not implemented.');
}
async setKnownAddresses(addresses: GroupedAddress[]): Promise<void> {
this.knownAddresses$.next(addresses);
}
shutdown(): void {
throw new Error('Method not implemented.');
}
}
describe('TxBuilder bootstrap', () => {
it('awaits for non-empty knownAddresses$', async () => {
// Initialize the TxBuilder
"test:e2e": "shx echo 'test:e2e' command not implemented yet"
},
"devDependencies": {
"@cardano-sdk/crypto": "workspace:~",
"@types/dockerode": "^3.3.8",
"@types/jest": "^26.0.24",
"eslint": "^7.32.0",
},
"dependencies": {
"@cardano-sdk/core": "workspace:~",
"@cardano-sdk/crypto": "workspace:~",
"@cardano-sdk/key-management": "workspace:~",
"@cardano-sdk/util": "workspace:~",
"@types/dockerode": "^3.3.8",
"axios": "^0.27.2",
import * as Crypto from '@cardano-sdk/crypto';
import {
AccountKeyDerivationPath,
AsyncKeyAgent,
GroupedAddress,
SignBlobResult,
util
} from '@cardano-sdk/key-management';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Cardano } from '@cardano-sdk/core';
import { generateRandomHexString } from './dataGeneration';
const NOT_IMPLEMENTED = 'Method not implemented';
export class StubKeyAgent implements AsyncKeyAgent {
static readonly dRepPubKey = Crypto.Ed25519PublicKeyHex(
'0b1c96fad4179d7910bd9485ac28c4c11368c83d18d01b29d4cf84d8ff6a06c4'
);
knownAddresses$ = new BehaviorSubject<GroupedAddress[]>([]);
constructor(private inputResolver: Cardano.InputResolver) {}
deriveAddress(): Promise<GroupedAddress> {
throw new Error(NOT_IMPLEMENTED);
}
derivePublicKey(derivationPath: AccountKeyDerivationPath): Promise<Crypto.Ed25519PublicKeyHex> {
if (derivationPath.role === util.DREP_KEY_DERIVATION_PATH.role) {
return Promise.resolve(StubKeyAgent.dRepPubKey);
}
throw new Error(NOT_IMPLEMENTED);
}
signBlob(): Promise<SignBlobResult> {
throw new Error(NOT_IMPLEMENTED);
}
async signTransaction(txInternals: Cardano.TxBodyWithHash): Promise<Cardano.Signatures> {
const signatures = new Map<Crypto.Ed25519PublicKeyHex, Crypto.Ed25519SignatureHex>();
const knownAddresses = await firstValueFrom(this.knownAddresses$);
for (const _ of await util.ownSignatureKeyPaths(
txInternals.body,
knownAddresses,
this.inputResolver,
Crypto.Ed25519KeyHashHex('f15db05f56035465bf8900a09bdaa16c3d8b8244fea686524408dd80')
)) {
signatures.set(
Crypto.Ed25519PublicKeyHex(generateRandomHexString(64)),
Crypto.Ed25519SignatureHex(generateRandomHexString(128))
);
}
return signatures;
}
getChainId(): Promise<Cardano.ChainId> {
throw new Error(NOT_IMPLEMENTED);
}
getBip32Ed25519(): Promise<Crypto.Bip32Ed25519> {
throw new Error(NOT_IMPLEMENTED);
}
getExtendedAccountPublicKey(): Promise<Crypto.Bip32PublicKeyHex> {
throw new Error(NOT_IMPLEMENTED);
}
async setKnownAddresses(addresses: GroupedAddress[]): Promise<void> {
this.knownAddresses$.next(addresses);
}
shutdown(): void {
throw new Error(NOT_IMPLEMENTED);
}
}
export * from './createGenericMockServer';
export * from './dataGeneration';
export * from './eraSummaries';
export * from './StubKeyAgent';
export * as mockProviders from './mockProviders';
export * as handleProviderMocks from './handleProvider';
export * as cip19TestVectors from './Cip19TestVectors';
/* eslint-disable no-bitwise */
import * as Crypto from '@cardano-sdk/crypto';
import { Cardano } from '@cardano-sdk/core';
import { util as KeyManagementUtil } from '@cardano-sdk/key-management';
import { Observable, firstValueFrom } from 'rxjs';
import { ObservableWallet } from '../types';
import { ProtocolParametersRequiredByOutputValidator, createOutputValidator } from '@cardano-sdk/tx-construction';
};
};
/** All transaction inputs and collaterals must come from our utxo set */
const hasForeignInputs = (
{ body: { inputs, collaterals = [] } }: { body: Pick<Cardano.TxBody, 'inputs' | 'collaterals'> },
utxoSet: Cardano.Utxo[]
): boolean => [...inputs, ...collaterals].some((txIn) => utxoSet.every((utxo) => !txInEquals(txIn, utxo[0])));
/** Wallet does not include committee certificate keys, so they cannot be signed */
const hasCommitteeCertificates = ({ certificates }: Cardano.TxBody) =>
(certificates || []).some(
(certificate) =>
certificate.__typename === Cardano.CertificateType.AuthorizeCommitteeHot ||
certificate.__typename === Cardano.CertificateType.ResignCommitteeCold
);
/**
* Gets whether the given TX requires signatures that can not be provided by the given wallet.
*
* @param tx The transaction to inspect.
* @param wallet The wallet that will provide the signatures.
* @returns true if the wallet can not sign all inputs/certificates; otherwise; false.
*/
// eslint-disable-next-line complexity, sonarjs/cognitive-complexity
export const requiresForeignSignatures = async (tx: Cardano.Tx, wallet: ObservableWallet): Promise<boolean> => {
const utxoSet = await firstValueFrom(wallet.utxo.total$);
const knownAddresses = await firstValueFrom(wallet.addresses$);
const uniqueAccounts = uniqBy(knownAddresses, 'rewardAccount').map((groupedAddress) => {
const stakeKeyHash = Cardano.RewardAccount.toHash(groupedAddress.rewardAccount);
return {
poolId: Cardano.PoolId.fromKeyHash(stakeKeyHash),
rewardAccount: groupedAddress.rewardAccount,
stakeKeyHash
};
});
// Iterate over the inputs and see if all of them are present in our UTXO set.
for (const input of tx.body.inputs) {
const ownsInput = utxoSet.find(
(utxo: Cardano.Utxo) => input.txId === utxo[0].txId && input.index === utxo[0].index
);
if (!ownsInput) return true;
}
// Iterate over the collateral inputs and see if all of them are present in our UTXO set.
if (tx.body.collaterals) {
for (const input of tx.body.collaterals) {
const ownsInput = utxoSet.find(
(utxo: Cardano.Utxo) => input.txId === utxo[0].txId && input.index === utxo[0].index
);
if (!ownsInput) return true;
}
}
// If all inputs are accounted for, see if all certificates belong to any of our reward accounts.
if (!tx.body.certificates) return false;
for (const certificate of tx.body.certificates) {
let matchesOneAccount = false;
for (const account of uniqueAccounts) {
switch (certificate.__typename) {
case Cardano.CertificateType.StakeKeyDeregistration:
case Cardano.CertificateType.StakeDelegation:
if (certificate.stakeKeyHash === account.stakeKeyHash) matchesOneAccount = true;
break;
case Cardano.CertificateType.PoolRegistration:
for (const owner of certificate.poolParameters.owners) {
// eslint-disable-next-line max-depth
if (owner === account.rewardAccount) matchesOneAccount = true;
}
break;
case Cardano.CertificateType.PoolRetirement:
if (certificate.poolId === account.poolId) matchesOneAccount = true;
break;
case Cardano.CertificateType.MIR:
if (
certificate.kind === Cardano.MirCertificateKind.ToStakeCreds &&
certificate.stakeCredential!.hash ===
Crypto.Hash28ByteBase16(Cardano.RewardAccount.toHash(account.rewardAccount))
)
matchesOneAccount = true;
break;
case Cardano.CertificateType.StakeKeyRegistration:
case Cardano.CertificateType.GenesisKeyDelegation:
default:
// These certificates don't require our signature, so we will map them as 'accounted for'.
matchesOneAccount = true;
}
}
// If it doesn't match at least one account, then it requires a foreign signature.
if (!matchesOneAccount) return true;
}
return false;
const uniqueAccounts: KeyManagementUtil.StakeKeySignerData[] = uniqBy(knownAddresses, 'rewardAccount')
.map((groupedAddress) => {
const stakeKeyHash = Cardano.RewardAccount.toHash(groupedAddress.rewardAccount);
return {
derivationPath: groupedAddress.stakeKeyDerivationPath,
poolId: Cardano.PoolId.fromKeyHash(stakeKeyHash),
rewardAccount: groupedAddress.rewardAccount,
stakeKeyHash
};
})
.filter((acct): acct is KeyManagementUtil.StakeKeySignerData => acct.derivationPath !== null);
const dRepKeyHash = (await Crypto.Ed25519PublicKey.fromHex(await wallet.getPubDRepKey()).hash()).hex();
return (
hasForeignInputs(tx, utxoSet) ||
KeyManagementUtil.checkStakeCredentialCertificates(uniqueAccounts, tx.body).requiresForeignSignatures ||
KeyManagementUtil.getDRepCredentialKeyPaths({ dRepKeyHash, txBody: tx.body }).requiresForeignSignatures ||
KeyManagementUtil.getVotingProcedureKeyPaths({ dRepKeyHash, groupedAddresses: knownAddresses, txBody: tx.body })
.requiresForeignSignatures ||
hasCommitteeCertificates(tx.body)
);
};
/* eslint-disable unicorn/consistent-destructuring, sonarjs/no-duplicate-string, @typescript-eslint/no-floating-promises, promise/no-nesting, promise/always-return */
import * as Crypto from '@cardano-sdk/crypto';
import { AddressType, AsyncKeyAgent, GroupedAddress, SignBlobResult, util } from '@cardano-sdk/key-management';
import {
AssetId,
createStubStakePoolProvider,
generateRandomHexString,
mockProviders as mocks
} from '@cardano-sdk/util-dev';
import { AddressType, GroupedAddress } from '@cardano-sdk/key-management';
import { AssetId, StubKeyAgent, createStubStakePoolProvider, mockProviders as mocks } from '@cardano-sdk/util-dev';
import { BehaviorSubject, Subscription, firstValueFrom, skip } from 'rxjs';
import { CML, Cardano, CardanoNodeErrors, ProviderError, ProviderFailure, TxCBOR } from '@cardano-sdk/core';
import { HexBlob } from '@cardano-sdk/util';
const { mockChainHistoryProvider, mockRewardsProvider, utxo } = mocks;
class StubKeyAgent implements AsyncKeyAgent {
knownAddresses$ = new BehaviorSubject<GroupedAddress[]>([]);
constructor(private inputResolver: Cardano.InputResolver) {}
deriveAddress(): Promise<GroupedAddress> {
throw new Error('Method not implemented.');
}
derivePublicKey(): Promise<Crypto.Ed25519PublicKeyHex> {
throw new Error('Method not implemented.');
}
signBlob(): Promise<SignBlobResult> {
throw new Error('Method not implemented.');
}
async signTransaction(txInternals: Cardano.TxBodyWithHash): Promise<Cardano.Signatures> {
const signatures = new Map<Crypto.Ed25519PublicKeyHex, Crypto.Ed25519SignatureHex>();
const knownAddresses = await firstValueFrom(this.knownAddresses$);
for (const _ of await util.ownSignatureKeyPaths(txInternals.body, knownAddresses, this.inputResolver)) {
signatures.set(
Crypto.Ed25519PublicKeyHex(generateRandomHexString(64)),
Crypto.Ed25519SignatureHex(generateRandomHexString(128))
);
}
return signatures;
}
getChainId(): Promise<Cardano.ChainId> {
throw new Error('Method not implemented.');
}
getBip32Ed25519(): Promise<Crypto.Bip32Ed25519> {
throw new Error('Method not implemented.');
}
getExtendedAccountPublicKey(): Promise<Crypto.Bip32PublicKeyHex> {
throw new Error('Method not implemented.');
}
async setKnownAddresses(addresses: GroupedAddress[]): Promise<void> {
this.knownAddresses$.next(addresses);
}
shutdown(): void {
throw new Error('Method not implemented.');
}
}
// We can't consistently re-serialize this specific tx due to witness.datums list format
const serializedForeignTx =
'84a60081825820260aed6e7a24044b1254a87a509468a649f522a4e54e830ac10f27ea7b5ec61f01018383581d70b429738bd6cc58b5c7932d001aa2bd05cfea47020a556c8c753d44361a004c4b40582007845f8f3841996e3d8157954e2f5e2fb90465f27112fc5fe9056d916fae245b82583900b1814238b0d287a8a46ce7348c6ad79ab8995b0e6d46010e2d9e1c68042f1946335c498d2e7556c5c647c4649c6a69d2b645cd1428a339ba1a0463676982583900b1814238b0d287a8a46ce7348c6ad79ab8995b0e6d46010e2d9e1c68042f1946335c498d2e7556c5c647c4649c6a69d2b645cd1428a339ba821a00177a6ea2581c648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198a5447742544319271044774554481a0031f9194577444f47451a0056898d4577555344431a000fc589467753484942411a000103c2581c659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a7a14a57696e675269646572731a02269552021a0002e665031a02414F03081a02414EFA0b58204107eada931c72a600a6e3305bd22c7aeb9ada7c3f6823b155f4db85de36a69aa20081825820e686ade5bc97372f271fd2abc06cfd96c24b3d9170f9459de1d8e3dd8fd385575840653324a9dddad004f05a8ac99fa2d1811af5f00543591407fb5206cfe9ac91bb1412404323fa517e0e189684cd3592e7f74862e3f16afbc262519abec958180c0481d8799fd8799fd8799fd8799f581cb1814238b0d287a8a46ce7348c6ad79ab8995b0e6d46010e2d9e1c68ffd8799fd8799fd8799f581c042f1946335c498d2e7556c5c647c4649c6a69d2b645cd1428a339baffffffff581cb1814238b0d287a8a46ce7348c6ad79ab8995b0e6d46010e2d9e1c681b000001863784a12ed8799fd8799f4040ffd8799f581c648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff1984577444f4745ffffffd8799fd87980190c8efffff5f6';
let utxoProvider: mocks.UtxoProviderStub;
let tx: Cardano.Tx;
const foreignRewardAccount = Cardano.RewardAccount(
'stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'
);
const foreignRewardAccountHash = Cardano.RewardAccount.toHash(foreignRewardAccount);
let dRepCredential: Crypto.Ed25519PublicKeyHex;
let dRepKeyHash: Crypto.Hash28ByteBase16;
const foreignDRepKeyHash = Crypto.Hash28ByteBase16('8293d319ef5b3ac72366dd28006bd315b715f7e7cfcbd3004129b80d');
beforeEach(async () => {
txSubmitProvider = mocks.mockTxSubmitProvider();
networkInfoProvider = mocks.mockNetworkInfoProvider();
};
tx = await wallet.finalizeTx({ tx: await wallet.initializeTx(props) });
dRepCredential = await wallet.getPubDRepKey();
dRepKeyHash = Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(
(await Crypto.Ed25519PublicKey.fromHex(dRepCredential).hash()).hex()
);
});
afterEach(() => {
expect(await requiresForeignSignatures(tx, wallet)).toBeFalsy();
});
it('returns true when at least one certificate can not be accounted for - StakeKeyDeregistration ', async () => {
const foreignRewardAccountHash = Cardano.RewardAccount.toHash(
Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')
);
it('returns false when a GenesisKeyDelegation certificate can not be accounted for ', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.StakeKeyDeregistration,
stakeKeyHash: foreignRewardAccountHash
} as Cardano.StakeAddressCertificate,
__typename: Cardano.CertificateType.GenesisKeyDelegation,
genesisDelegateHash: Crypto.Hash28ByteBase16('00000000000000000000000000000000000000000000000000000000'),
genesisHash: Crypto.Hash28ByteBase16('00000000000000000000000000000000000000000000000000000000'),
vrfKeyHash: Crypto.Hash32ByteBase16('0000000000000000000000000000000000000000000000000000000000000000')
} as Cardano.GenesisKeyDelegationCertificate,
...tx.body.certificates!
];
expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1);
expect(tx.body.certificates!.length).toBe(2);
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
expect(await requiresForeignSignatures(tx, wallet)).toBeFalsy();
});
it('returns true when at least one certificate can not be accounted for - StakeDelegation ', async () => {
const foreignRewardAccountHash = Cardano.RewardAccount.toHash(
Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')
);
describe('StakeCredential based check', () => {
it('detects foreign stakeKeyHash in StakeKeyDeregistration certificate', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.StakeKeyDeregistration,
stakeKeyHash: foreignRewardAccountHash
} as Cardano.StakeAddressCertificate,
...tx.body.certificates!
];
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
tx.body.certificates = [
{
__typename: Cardano.CertificateType.StakeDelegation,
poolId: Cardano.PoolId.fromKeyHash(foreignRewardAccountHash),
stakeKeyHash: foreignRewardAccountHash
} as Cardano.StakeDelegationCertificate,
...tx.body.certificates!
];
it('detects foreign stakeKeyHash in StakeDelegation certificate', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.StakeDelegation,
poolId: Cardano.PoolId.fromKeyHash(foreignRewardAccountHash),
stakeKeyHash: foreignRewardAccountHash
} as Cardano.StakeDelegationCertificate,
...tx.body.certificates!
];
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1);
expect(tx.body.certificates!.length).toBe(2);
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
it('detects foreign pool owner in PoolRegistration certificate', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.PoolRegistration,
poolParameters: {
cost: 340n,
id: Cardano.PoolId.fromKeyHash(foreignRewardAccountHash),
margin: {
denominator: 50,
numerator: 10
},
owners: [foreignRewardAccount],
pledge: 10_000n,
relays: [
{
__typename: 'RelayByName',
hostname: 'localhost'
}
],
rewardAccount: Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'),
vrf: Cardano.VrfVkHex('641d042ed39c2c258d381060c1424f40ef8abfe25ef566f4cb22477c42b2a014')
}
} as Cardano.PoolRegistrationCertificate,
...tx.body.certificates!
];
it('returns true when at least one certificate can not be accounted for - PoolRegistration ', async () => {
const foreignRewardAccountHash = Cardano.RewardAccount.toHash(
Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')
);
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
tx.body.certificates = [
{
__typename: Cardano.CertificateType.PoolRegistration,
poolParameters: {
cost: 340n,
id: Cardano.PoolId.fromKeyHash(foreignRewardAccountHash),
margin: {
denominator: 50,
numerator: 10
},
owners: [Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')],
pledge: 10_000n,
relays: [
{
__typename: 'RelayByName',
hostname: 'localhost'
}
],
rewardAccount: Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'),
vrf: Cardano.VrfVkHex('641d042ed39c2c258d381060c1424f40ef8abfe25ef566f4cb22477c42b2a014')
}
} as Cardano.PoolRegistrationCertificate,
...tx.body.certificates!
];
it('detects foreign poolId in PoolRetirement certificate', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.PoolRetirement,
epoch: Cardano.EpochNo(100),
poolId: Cardano.PoolId.fromKeyHash(foreignRewardAccountHash)
} as Cardano.PoolRetirementCertificate,
...tx.body.certificates!
];
expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1);
expect(tx.body.certificates!.length).toBe(2);
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
expect(tx.body.inputs.length).toBeGreaterThanOrEqual(1);
expect(tx.body.certificates!.length).toBe(2);
expect(await requiresForeignSignatures(tx, wallet)).toBeTruthy();
});
it('returns false when a StakeKeyRegistration certificate can not be accounted for', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.StakeKeyRegistration,
stakeKeyHash: foreignRewardAccountHash
} as Cardano.StakeAddressCertificate,
...tx.body.certificates!
];
expect(await requiresForeignSignatures(tx, wallet)).toBeFalsy();
});
it('returns true when at least one certificate can not be accounted for - PoolRetirement ', async () => {
const foreignRewardAccountHash = Cardano.RewardAccount.toHash(
Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')
);
it('accepts valid conway certificates', async () => {
tx.body.certificates = [
{
__typename: Cardano.CertificateType.Registration,
// using foreign intentionally because registration is not signed so it should be accepted
stakeKeyHash: foreignRewardAccountHash
} as Cardano.NewStakeAddressCertificate,
{
__typename: Cardano.CertificateType.Unregistration,
stakeKeyHash: mocks.stakeKeyHash
} as Cardano.NewStakeAddressCertificate,
{
"@cardano-sdk/crypto": "workspace:~"
"@cardano-sdk/dapp-connector": "workspace:~"
"@cardano-sdk/util": "workspace:~"
"@cardano-sdk/util-dev": "workspace:~"
"@emurgo/cardano-message-signing-nodejs": ^1.0.1
"@trezor/connect": 9.0.11
"@trezor/connect-web": 9.0.11
dependencies:
"@cardano-sdk/core": "workspace:~"
"@cardano-sdk/crypto": "workspace:~"
"@cardano-sdk/key-management": "workspace:~"
"@cardano-sdk/util": "workspace:~"
"@types/dockerode": ^3.3.8
"@types/jest": ^26.0.24
TODO: Remove SRP TODO: Bump to the latest cardano-api TODO: Bump to the latest cardano-cli
Create and submit votes on proposal
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <[email protected]>