Skip to content

Commit

Permalink
fix: resolve issues preventing to make a delegation tx
Browse files Browse the repository at this point in the history
test(wallet): fix InMemoryKeyManager test to use new signTx signature
  • Loading branch information
mkazlauskas committed Nov 16, 2021
1 parent c8563ba commit 7429f46
Show file tree
Hide file tree
Showing 18 changed files with 187 additions and 73 deletions.
2 changes: 1 addition & 1 deletion packages/blockfrost/src/blockfrostProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export const blockfrostProvider = (options: Options, logger = dummyLogger): Wall
rewards: BigInt(amount)
}))
);
haveMorePages = rewards[rewards.length - 1].epoch < upperBound && rewards.length === 100;
haveMorePages = rewards.length === 100 && rewards[rewards.length - 1].epoch < upperBound;
page += 1;
}
return result;
Expand Down
4 changes: 4 additions & 0 deletions packages/cip2/src/RoundRobinRandomImprove/roundRobin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,9 @@ export const roundRobinSelection = ({
}
}
}
if (utxoSelected.length === 0) {
utxoSelected.push(utxoRemaining[0]);
utxoRemaining.splice(0, 1);
}
return { utxoRemaining, utxoSelected };
};
3 changes: 3 additions & 0 deletions packages/cip2/src/RoundRobinRandomImprove/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export const assertIsBalanceSufficient = (
outputValues: Cardano.Value[],
implicitCoin: ImplicitCoinBigint
): void => {
if (utxoValues.length === 0) {
throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient);
}
for (const assetId of uniqueOutputAssetIDs) {
const getAssetQuantity = assetQuantitySelector(assetId);
const utxoTotal = getAssetQuantity(utxoValues);
Expand Down
12 changes: 12 additions & 0 deletions packages/cip2/test/RoundRobinRandomImprove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
testInputSelectionFailureMode,
testInputSelectionProperties
} from './util';
import { createOutput } from '@cardano-sdk/util-dev/src/cslTestUtil';
import { roundRobinRandomImprove } from '../src/RoundRobinRandomImprove';
import fc from 'fast-check';

Expand Down Expand Up @@ -34,6 +35,17 @@ describe('RoundRobinRandomImprove', () => {
}
});
});
it('Selects UTxO even when implicit input covers outputs', async () => {
const utxo = new Set([CslTestUtil.createUnspentTxOutput({ coins: 10_000_000n })]);
const outputs = new Set([createOutput({ coins: 1_000_000n })]);
const results = await roundRobinRandomImprove().select({
constraints: SelectionConstraints.NO_CONSTRAINTS,
implicitCoin: { input: 2_000_000n },
outputs,
utxo
});
expect(results.selection.inputs.size).toBe(1);
});
});
describe('Failure Modes', () => {
describe('UtxoBalanceInsufficient', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cip2/test/util/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const assertFailureProperties = ({
]);
switch (error.failure) {
case InputSelectionFailure.UtxoBalanceInsufficient: {
if (utxoAmounts.length === 0) return; // must select at least 1 utxo
const insufficientCoin = availableQuantities.coins < requestedQuantities.coins;
const insufficientAsset =
requestedQuantities.assets &&
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/CSL/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,30 @@ import {
RewardAddress,
SingleHostAddr,
SingleHostName,
StakeCredential,
StakeDelegation,
StakeDeregistration,
StakeRegistration,
URL,
UnitInterval,
VRFKeyHash
} from '@emurgo/cardano-serialization-lib-nodejs';
import { Cardano, NotImplementedError } from '..';
import { Cardano, NotImplementedError, SerializationError, SerializationFailure } from '..';
import { CertificateType } from '../Cardano';

export const stakeAddressToCredential = (address: Cardano.Address) =>
StakeCredential.from_keyhash(Ed25519KeyHash.from_bech32(address));
export const stakeAddressToCredential = (address: Cardano.Address) => {
try {
const rewardAddress = RewardAddress.from_address(Address.from_bech32(address));
if (!rewardAddress)
throw new SerializationError(SerializationFailure.InvalidAddress, `Invalid reward account address: ${address}`);
return rewardAddress.payment_cred();
} catch (error) {
throw new SerializationError(
SerializationFailure.InvalidAddress,
`Invalid reward account address: ${address}`,
error
);
}
};

export const stakeKeyRegistration = (address: Cardano.Address) =>
Certificate.new_stake_registration(StakeRegistration.new(stakeAddressToCredential(address)));
Expand Down
53 changes: 29 additions & 24 deletions packages/core/test/CSL/certificate.test.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,56 @@
import { Cardano, coreToCsl } from '../../src';
import { CSL, Cardano, SerializationError, coreToCsl } from '../../src';

describe('coreToCsl.certificate', () => {
let stakeKey: Cardano.Address;
const delegatee = 'pool1qqvukkkfr3ux4qylfkrky23f6trl2l6xjluv36z90ax7gfa8yxt';
let poolKeyHash: Cardano.Address;

beforeAll(async () => {
stakeKey = 'stake1mpgg03jxj52qwxvvy7cmj58a96vl9pvxcqqvuw0kumhey8p37kz';
stakeKey = 'stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr';
poolKeyHash = 'pool1mpgg03jxj52qwxvvy7cmj58a96vl9pvxcqqvuw0kumheygxmn34';
});

it('throws SerializationError with invalid stake key', () => {
expect(() => coreToCsl.certificate.stakeKeyRegistration(poolKeyHash)).toThrowError(SerializationError);
});

it('stakeKeyRegistration', () =>
expect(
coreToCsl.certificate
.stakeKeyRegistration(stakeKey)
.as_stake_registration()
?.stake_credential()
.to_keyhash()
?.to_bech32('stake')
CSL.RewardAddress.new(
1,
coreToCsl.certificate.stakeKeyRegistration(stakeKey).as_stake_registration()!.stake_credential()
)
?.to_address()
.to_bech32()
).toBe(stakeKey));

it('stakeKeyDeregistration', () =>
expect(
coreToCsl.certificate
.stakeKeyDeregistration(stakeKey)
.as_stake_deregistration()
?.stake_credential()
.to_keyhash()
?.to_bech32('stake')
CSL.RewardAddress.new(
1,
coreToCsl.certificate.stakeKeyDeregistration(stakeKey).as_stake_deregistration()!.stake_credential()
)
?.to_address()
.to_bech32()
).toBe(stakeKey));

it('stakeDelegation', () => {
const delegation = coreToCsl.certificate.stakeDelegation(stakeKey, delegatee).as_stake_delegation()!;
expect(delegation.stake_credential().to_keyhash()?.to_bech32('stake')).toBe(stakeKey);
expect(delegation.pool_keyhash().to_bech32('pool')).toBe(delegatee);
const delegation = coreToCsl.certificate.stakeDelegation(stakeKey, poolKeyHash).as_stake_delegation()!;
expect(CSL.RewardAddress.new(1, delegation.stake_credential()).to_address().to_bech32()).toBe(stakeKey);
expect(delegation.pool_keyhash().to_bech32('pool')).toBe(poolKeyHash);
});

it('poolRegistration', () => {
const owner = 'ed25519_pk1fapzz685dzht56689jgthxxrzcsrtauu9zhptghq9lzj7w8xara';
const vrf = '8dd154228946bd12967c12bedb1cb6038b78f8b84a1760b1a788fa72a4af3db0';
const rewardAccount = 'addr1uxa5pudxg77g3sdaddecmw8tvc6hmynywn49lltt4fmvn7cmpqcax';
const rewardAccount = stakeKey;
const metadataJson = {
hash: 'pool1ntpzzu5g6xhqkns4csd435lqtgfjqm7e4wquk9v58eqhf0esey9st6vf29',
url: 'https://example.com'
};
const params = coreToCsl.certificate
.poolRegistration({
cost: 1000n,
id: stakeKey,
id: poolKeyHash,
margin: { denominator: 5, numerator: 1 },
metadataJson,
owners: [owner],
Expand All @@ -72,19 +77,19 @@ describe('coreToCsl.certificate', () => {
const owners = params.pool_owners();
expect(owners.len()).toBe(1);
expect(owners.get(0).to_bech32('ed25519_pk')).toBe(owner);
expect(params.operator().to_bech32('stake')).toBe(stakeKey);
expect(params.operator().to_bech32('pool')).toBe(poolKeyHash);
const relays = params.relays();
expect(relays.len()).toBe(3);
expect(Buffer.from(params.vrf_keyhash().to_bytes()).toString('hex')).toBe(vrf);
expect(params.reward_account().to_address().to_bech32('addr')).toBe(rewardAccount);
expect(params.reward_account().to_address().to_bech32('stake')).toBe(rewardAccount);
const metadata = params.pool_metadata()!;
expect(metadata.url().url()).toBe(metadataJson.url);
expect(metadata.pool_metadata_hash().to_bech32('pool')).toBe(metadataJson.hash);
});

it('poolRetirement', () => {
const retirement = coreToCsl.certificate.poolRetirement(stakeKey, 1000).as_pool_retirement()!;
expect(retirement.pool_keyhash().to_bech32('stake')).toEqual(stakeKey);
const retirement = coreToCsl.certificate.poolRetirement(poolKeyHash, 1000).as_pool_retirement()!;
expect(retirement.pool_keyhash().to_bech32('pool')).toEqual(poolKeyHash);
expect(retirement.epoch()).toEqual(1000);
});
});
4 changes: 3 additions & 1 deletion packages/wallet/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ STAKE_POOL_SEARCH_PROVIDER=stub
BLOCKFROST_API_KEY=testnetNElagmhpQDubE6Ic4XBUVJjV5DROyijO
NETWORK_ID=0
MNEMONIC_WORDS="actor scout worth mansion thumb device mass pave gospel secret height document merge text broom kind lesson invest across estate erase interest end century"
WALLET_PASSWORD=some_password
WALLET_PASSWORD=some_password
POOL_ID_1=pool1euf2nh92ehqfw7rpd4s9qgq34z8dg4pvfqhjmhggmzk95gcd402
POOL_ID_2=pool1fghrkl620rl3g54ezv56weeuwlyce2tdannm2hphs62syf3vyyh
43 changes: 28 additions & 15 deletions packages/wallet/src/KeyManagement/InMemoryKeyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as bip39 from 'bip39';
import * as errors from './errors';
import { CSL, Cardano } from '@cardano-sdk/core';
import { KeyManager } from './types';
import { TxInternals } from '../Transaction';
import { harden, joinMnemonicWords } from './util';

export const createInMemoryKeyManager = ({
Expand Down Expand Up @@ -29,17 +30,18 @@ export const createInMemoryKeyManager = ({
.derive(harden(1815))
.derive(harden(accountIndex));

const privateParentKey = accountPrivateKey.derive(0).derive(0);
const publicParentKey = privateParentKey.to_public();
const publicKey = accountPrivateKey.to_public();
const privateParentPaymentKey = accountPrivateKey.derive(0).derive(0);
const publicParentPaymentKey = privateParentPaymentKey.to_public();
const publicPaymentKey = accountPrivateKey.to_public();

const stakeKey = publicKey.derive(2).derive(0);
const stakeKeyRaw = stakeKey.to_raw_key();
const stakeKeyCredential = CSL.StakeCredential.from_keyhash(stakeKeyRaw.hash());
const privateStakeKey = accountPrivateKey.derive(2).derive(0);
const publicStakeKey = privateStakeKey.to_public();
const publicRawSakeKey = publicStakeKey.to_raw_key();
const stakeKeyCredential = CSL.StakeCredential.from_keyhash(publicRawSakeKey.hash());

return {
deriveAddress: (addressIndex, index) => {
const utxoPubKey = publicKey.derive(index).derive(addressIndex);
const utxoPubKey = publicPaymentKey.derive(index).derive(addressIndex);
const baseAddr = CSL.BaseAddress.new(
networkId,
CSL.StakeCredential.from_keyhash(utxoPubKey.to_raw_key().hash()),
Expand All @@ -48,20 +50,31 @@ export const createInMemoryKeyManager = ({

return baseAddr.to_address().to_bech32();
},
publicKey: publicKey.to_raw_key(),
publicParentKey: publicParentKey.to_raw_key(),
publicKey: publicPaymentKey.to_raw_key(),
publicParentKey: publicParentPaymentKey.to_raw_key(),
rewardAccount: CSL.RewardAddress.new(networkId, stakeKeyCredential).to_address().to_bech32(),
signMessage: async (_addressType, _signingIndex, message) => ({
publicKey: publicParentKey.toString(),
publicKey: publicParentPaymentKey.toString(),
signature: `Signature for ${message} is not implemented yet`
}),
signTransaction: async (txHash: Cardano.Hash16) => {
const cslHash = CSL.TransactionHash.from_bytes(Buffer.from(txHash, 'hex'));
const vkeyWitness = CSL.make_vkey_witness(cslHash, privateParentKey.to_raw_key());
signTransaction: async ({ body, hash }: TxInternals) => {
const cslHash = CSL.TransactionHash.from_bytes(Buffer.from(hash, 'hex'));
const paymentVkeyWitness = CSL.make_vkey_witness(cslHash, privateParentPaymentKey.to_raw_key());
const stakeWitnesses = (() => {
// TODO: not all certificate types might need a stake key witness
if (!body.certificates) {
return {};
}
const stakeVkeyWitness = CSL.make_vkey_witness(cslHash, privateStakeKey.to_raw_key());
return {
[stakeVkeyWitness.vkey().public_key().to_bech32()]: stakeVkeyWitness.signature().to_hex()
};
})();
return {
[vkeyWitness.vkey().public_key().to_bech32()]: vkeyWitness.signature().to_hex()
[paymentVkeyWitness.vkey().public_key().to_bech32()]: paymentVkeyWitness.signature().to_hex(),
...stakeWitnesses
};
},
stakeKey: stakeKeyRaw
stakeKey: publicRawSakeKey
};
};
3 changes: 2 additions & 1 deletion packages/wallet/src/KeyManagement/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AddressType } from '..';
import { CSL, Cardano } from '@cardano-sdk/core';
import { TxInternals } from '../Transaction';

export interface KeyManager {
deriveAddress: (addressIndex: number, index: 0 | 1) => string;
Expand All @@ -12,7 +13,7 @@ export interface KeyManager {
publicKey: CSL.PublicKey;
publicParentKey: CSL.PublicKey;
// TODO: make signatures object key type clear with type alias
signTransaction: (txHash: Cardano.Hash16) => Promise<Cardano.Witness['signatures']>;
signTransaction: (tx: TxInternals) => Promise<Cardano.Witness['signatures']>;
stakeKey: CSL.PublicKey;
rewardAccount: Cardano.Address;
}
16 changes: 10 additions & 6 deletions packages/wallet/src/SingleAddressWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ export class SingleAddressWallet implements Wallet {
buildTx: async (inputSelection) => {
this.#logger.debug('Building TX for selection constraints', inputSelection);
const txInternals = await createTransactionInternals({
certificates: props.certificates,
changeAddress,
inputSelection,
validityInterval
validityInterval,
withdrawals: props.withdrawals
});
return coreToCsl.tx(await this.finalizeTx(txInternals));
},
Expand All @@ -186,22 +188,24 @@ export class SingleAddressWallet implements Wallet {
})
.then((inputSelectionResult) =>
createTransactionInternals({
certificates: props.certificates,
changeAddress,
inputSelection: inputSelectionResult.selection,
validityInterval
validityInterval,
withdrawals: props.withdrawals
})
)
);
})
)
);
}
async finalizeTx({ body, hash }: TxInternals, auxiliaryData?: Cardano.AuxiliaryData): Promise<Cardano.NewTxAlonzo> {
const signatures = await this.#keyManager.signTransaction(hash);
async finalizeTx(tx: TxInternals, auxiliaryData?: Cardano.AuxiliaryData): Promise<Cardano.NewTxAlonzo> {
const signatures = await this.#keyManager.signTransaction(tx);
return {
auxiliaryData,
body,
id: hash,
body: tx.body,
id: tx.hash,
// TODO: add support for the rest of the witness properties
witness: { signatures }
};
Expand Down
5 changes: 5 additions & 0 deletions packages/wallet/src/services/BalanceTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { TrackerSubject } from './util';
import { TransactionalObservables } from '..';
import { combineLatest, map } from 'rxjs';

// TODO: subtract deposit quantity from total utxo coin as it can't be spent
// Review: not sure how to represent this. Is it 'balance.available$'? 'balance.total$'? a new one?
// 'total' represents total value available to spend. Would be confusing if it includes deposit.
// 'available' represents value available to spend if it makes a transaction right now:
// would be confusing if it's never equal to total after stake registration
const mapToBalances = map<[Cardano.Utxo[], Cardano.Lovelace], Balance>(([utxo, rewards]) => ({
...Cardano.util.coalesceValueQuantities(utxo.map(([_, txOut]) => txOut.value)),
rewards
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Cardano, StakePoolSearchProvider, WalletProvider } from '@cardano-sdk/c
import { CertificateType, Epoch } from '@cardano-sdk/core/src/Cardano';
import { Delegation, Transactions } from '../types';
import { KeyManager } from '../../KeyManagement';
import { Observable, distinctUntilChanged, filter, map, share, switchMap } from 'rxjs';
import { Observable, distinctUntilChanged, map, share, switchMap } from 'rxjs';
import { ObservableStakePoolSearchProvider, createDelegateeTracker, createQueryStakePoolsProvider } from './Delegatee';
import { RetryBackoffConfig } from 'backoff-rxjs';
import { RewardsHistoryProvider, createRewardsHistoryTracker } from './RewardsHistory';
Expand Down Expand Up @@ -40,7 +40,6 @@ export const certificateTransactionsWithEpochs = (
transactionsTracker.history.outgoing$.pipe(
map((transactions) => transactions.filter((tx) => transactionHasAnyCertificate(tx, certificateTypes))),
distinctUntilChanged(transactionsEquals),
filter((transactions) => transactions.length > 0),
switchMap((transactions) =>
blockEpochProvider(transactions.map((tx) => tx.blockHeader.blockHash)).pipe(
map((epochs) => transactions.map((tx, txIndex) => ({ epoch: epochs[txIndex], tx })))
Expand Down
Loading

0 comments on commit 7429f46

Please sign in to comment.