Skip to content

Commit 4466bed

Browse files
Merge pull request #1466 from input-output-hk/feat/lw-10717-conditional-withdrawal-in-base-wallet
Feat/lw 10717 conditional withdrawal in base wallet
2 parents bbefe52 + aad7339 commit 4466bed

File tree

7 files changed

+991
-12
lines changed

7 files changed

+991
-12
lines changed

packages/cardano-services/test/util/TypeormService.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ describe('TypeormService', () => {
4848
await expect(service.withQueryRunner((queryRunner) => queryRunner.hasTable('block'))).resolves.toBe(false);
4949
});
5050

51-
it('reconnects on error', async () => {
51+
it.skip('reconnects on error', async () => {
5252
connectionConfig$.next(badConnectionConfig);
5353
service.onError(new Error('Any error'));
5454
const queryResultReady = service.withQueryRunner(async () => 'ok');
5555
connectionConfig$.next(goodConnectionConfig);
5656
await expect(queryResultReady).resolves.toBe('ok');
5757
});
5858

59-
it('times out when it cannot reconnect for too long, then recovers', async () => {
59+
it.skip('times out when it cannot reconnect for too long, then recovers', async () => {
6060
connectionConfig$.next(badConnectionConfig);
6161
service.onError(new Error('Any error'));
6262
const queryFailureReady = service.withQueryRunner(async () => 'ok');

packages/core/src/Cardano/types/Certificate.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ export const StakeCredentialCertificateTypes = [
213213
CertificateType.VoteDelegation
214214
] as const;
215215

216+
export const VoteDelegationCredentialCertificateTypes = [
217+
CertificateType.VoteDelegation,
218+
CertificateType.VoteRegistrationDelegation,
219+
CertificateType.StakeVoteDelegation,
220+
CertificateType.StakeVoteRegistrationDelegation
221+
] as const;
222+
216223
type CertificateTypeMap = {
217224
[CertificateType.AuthorizeCommitteeHot]: AuthorizeCommitteeHotCertificate;
218225
[CertificateType.GenesisKeyDelegation]: GenesisKeyDelegationCertificate;

packages/core/src/Cardano/types/DelegationsAndRewards.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DelegateRepresentative } from './Governance';
12
import { Lovelace } from './Value';
23
import { Metadatum } from './AuxiliaryData';
34
import { PoolId, PoolIdHex, StakePool } from './StakePool';
@@ -23,10 +24,13 @@ export enum StakeCredentialStatus {
2324
Unregistered = 'UNREGISTERED'
2425
}
2526

27+
export type DRepDelegatee = { delegateRepresentative: DelegateRepresentative };
28+
2629
export interface RewardAccountInfo {
2730
address: RewardAccount;
2831
credentialStatus: StakeCredentialStatus;
2932
delegatee?: Delegatee;
33+
dRepDelegatee?: DRepDelegatee;
3034
rewardBalance: Lovelace;
3135
// Maybe add rewardsHistory for each reward account too
3236
deposit?: Lovelace; // defined only when keyStatus is Registered

packages/tx-construction/src/tx-builder/initializeTx.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Bip32Account, SignTransactionContext, util } from '@cardano-sdk/key-man
44
import { Cardano, Serialization } from '@cardano-sdk/core';
55
import { Ed25519KeyHashHex } from '@cardano-sdk/crypto';
66
import { GreedyTxEvaluator } from './GreedyTxEvaluator';
7-
import { InitializeTxProps, InitializeTxResult } from '../types';
7+
import { InitializeTxProps, InitializeTxResult, RewardAccountWithPoolId } from '../types';
88
import { RedeemersByType, defaultSelectionConstraints } from '../input-selection';
99
import { TxBuilderDependencies } from './types';
1010
import { createPreInputSelectionTxBody, includeChangeAndInputs } from '../createTransactionInternals';
@@ -13,6 +13,38 @@ import { ensureValidityInterval } from '../ensureValidityInterval';
1313
const dRepPublicKeyHash = async (addressManager?: Bip32Account): Promise<Ed25519KeyHashHex | undefined> =>
1414
addressManager && (await (await addressManager.derivePublicKey(util.DREP_KEY_DERIVATION_PATH)).hash()).hex();
1515

16+
const DREP_REG_REQUIRED_PROTOCOL_VERSION = 10;
17+
18+
/**
19+
* Filters and transforms reward accounts based on current protocol version and reward balance.
20+
*
21+
* Accounts are first filtered based on two conditions:
22+
* 1. If the current protocol version is greater than or equal to the version for required DREP registration,
23+
* the account must have a 'dRepDelegatee'.
24+
* 2. The account must have a non-zero 'rewardBalance'.
25+
*
26+
* @param accounts - Array of accounts to be processed.
27+
* @param version - Current protocol version.
28+
* @returns Array of objects containing the 'quantity' and 'stakeAddress' of filtered accounts.
29+
*/
30+
const getWithdrawals = (
31+
accounts: RewardAccountWithPoolId[],
32+
version: Cardano.ProtocolVersion
33+
): {
34+
quantity: Cardano.Lovelace;
35+
stakeAddress: Cardano.RewardAccount;
36+
}[] =>
37+
accounts
38+
.filter(
39+
(account) =>
40+
(version.major >= DREP_REG_REQUIRED_PROTOCOL_VERSION ? !!account.dRepDelegatee : true) &&
41+
!!account.rewardBalance
42+
)
43+
.map(({ rewardBalance: quantity, address: stakeAddress }) => ({
44+
quantity,
45+
stakeAddress
46+
}));
47+
1648
export const initializeTx = async (
1749
props: InitializeTxProps,
1850
{
@@ -53,12 +85,7 @@ export const initializeTx = async (
5385
requiredExtraSignatures: props.requiredExtraSignatures,
5486
scriptIntegrityHash: props.scriptIntegrityHash,
5587
validityInterval: ensureValidityInterval(tip.slot, genesisParameters, props.options?.validityInterval),
56-
withdrawals: rewardAccounts
57-
.map(({ rewardBalance: quantity, address: stakeAddress }) => ({
58-
quantity,
59-
stakeAddress
60-
}))
61-
.filter(({ quantity }) => !!quantity)
88+
withdrawals: getWithdrawals(rewardAccounts, protocolParameters.protocolVersion)
6289
});
6390

6491
const bodyPreInputSelection = props.customizeCb ? props.customizeCb({ txBody }) : txBody;

packages/tx-construction/test/tx-builder/TxBuilder.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ function assertObjectRefsAreDifferent(obj1: unknown, obj2: unknown): void {
3535
expect(obj1).not.toBe(obj2);
3636
}
3737

38+
const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27');
39+
const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d');
40+
const rewardAccount3 = Cardano.RewardAccount('stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn');
41+
const rewardAccount4 = Cardano.RewardAccount('stake_test17rphkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcljw6kf');
42+
3843
const resolvedHandle = {
3944
cardanoAddress: Cardano.PaymentAddress('addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg'),
4045
handle: 'alice',
@@ -54,6 +59,8 @@ describe.each([
5459
let txBuilderWithoutHandleProvider: GenericTxBuilder;
5560
let txBuilderWithHandleErrors: GenericTxBuilder;
5661
let txBuilderWithNullHandles: GenericTxBuilder;
62+
let txBuilderWithDeRepsPv10: GenericTxBuilder;
63+
let txBuilderWithDeReps: GenericTxBuilder;
5764
let txBuilderProviders: jest.Mocked<TxBuilderProviders>;
5865
let output: Cardano.TxOut;
5966
let output2: Cardano.TxOut;
@@ -128,6 +135,84 @@ describe.each([
128135
},
129136
...builderParams
130137
});
138+
139+
txBuilderWithDeRepsPv10 = new GenericTxBuilder({
140+
...builderParams,
141+
txBuilderProviders: {
142+
...txBuilderProviders,
143+
protocolParameters: jest.fn().mockResolvedValue({
144+
...mocks.protocolParameters,
145+
protocolVersion: { major: 10, minor: 0 }
146+
}),
147+
rewardAccounts: jest.fn().mockResolvedValue([
148+
{
149+
address: rewardAccount1,
150+
keyStatus: Cardano.StakeCredentialStatus.Registered,
151+
rewardBalance: 10n
152+
},
153+
{
154+
address: rewardAccount2,
155+
dRepDelegatee: {
156+
__typename: 'AlwaysAbstain'
157+
},
158+
keyStatus: Cardano.StakeCredentialStatus.Registered,
159+
rewardBalance: 20n
160+
},
161+
{
162+
address: rewardAccount3,
163+
dRepDelegatee: {
164+
__typename: 'AlwaysAbstain'
165+
},
166+
keyStatus: Cardano.StakeCredentialStatus.Registered,
167+
rewardBalance: 30n
168+
},
169+
{
170+
address: rewardAccount4,
171+
dRepDelegatee: {
172+
__typename: 'AlwaysAbstain'
173+
},
174+
keyStatus: Cardano.StakeCredentialStatus.Registered,
175+
rewardBalance: 0n
176+
}
177+
])
178+
}
179+
});
180+
181+
txBuilderWithDeReps = new GenericTxBuilder({
182+
...builderParams,
183+
txBuilderProviders: {
184+
...txBuilderProviders,
185+
rewardAccounts: jest.fn().mockResolvedValue([
186+
{
187+
address: rewardAccount1,
188+
keyStatus: Cardano.StakeCredentialStatus.Registered,
189+
rewardBalance: 10n
190+
},
191+
{
192+
address: rewardAccount2,
193+
dRepDelegatee: {
194+
__typename: 'AlwaysAbstain'
195+
},
196+
keyStatus: Cardano.StakeCredentialStatus.Registered,
197+
rewardBalance: 20n
198+
},
199+
{
200+
address: rewardAccount3,
201+
keyStatus: Cardano.StakeCredentialStatus.Registered,
202+
rewardBalance: 30n
203+
},
204+
{
205+
address: rewardAccount4,
206+
dRepDelegatee: {
207+
__typename: 'AlwaysAbstain'
208+
},
209+
keyStatus: Cardano.StakeCredentialStatus.Registered,
210+
rewardBalance: 0n
211+
}
212+
])
213+
}
214+
});
215+
131216
txBuilderWithoutHandleProvider = new GenericTxBuilder(builderParams);
132217
txBuilderWithNullHandles = new GenericTxBuilder({
133218
handleProvider: {
@@ -137,6 +222,7 @@ describe.each([
137222
},
138223
...builderParams
139224
});
225+
140226
txBuilderWithHandleErrors = new GenericTxBuilder({
141227
handleProvider: {
142228
getPolicyIds: async () => [],
@@ -718,4 +804,37 @@ describe.each([
718804
expect(txBuilder.partialTxBody.validityInterval).toEqual(validityInterval2);
719805
});
720806
});
807+
808+
describe('Withdrawals', () => {
809+
it('only add withdrawals for reward accounts with positive reward balance and dRep delegation if protocol version is equal or greater than 10', async () => {
810+
const address = Cardano.PaymentAddress('addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg');
811+
const output1Coin = 10_000_000n;
812+
813+
const builtOutput = await txBuilderWithDeRepsPv10.buildOutput().address(address).coin(output1Coin).build();
814+
const tx = txBuilderWithDeRepsPv10.addOutput(builtOutput).build();
815+
816+
const txProps = await tx.inspect();
817+
818+
expect(txProps.body.withdrawals).toEqual([
819+
{ quantity: 20n, stakeAddress: rewardAccount2 },
820+
{ quantity: 30n, stakeAddress: rewardAccount3 }
821+
]);
822+
});
823+
824+
it('adds withdrawals for all registered reward accounts with positive reward balance if protocol version is less than 10', async () => {
825+
const address = Cardano.PaymentAddress('addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg');
826+
const output1Coin = 10_000_000n;
827+
828+
const builtOutput = await txBuilderWithDeReps.buildOutput().address(address).coin(output1Coin).build();
829+
const tx = txBuilderWithDeReps.addOutput(builtOutput).build();
830+
831+
const txProps = await tx.inspect();
832+
833+
expect(txProps.body.withdrawals).toEqual([
834+
{ quantity: 10n, stakeAddress: rewardAccount1 },
835+
{ quantity: 20n, stakeAddress: rewardAccount2 },
836+
{ quantity: 30n, stakeAddress: rewardAccount3 }
837+
]);
838+
});
839+
});
721840
});

packages/wallet/src/services/DelegationTracker/RewardAccounts.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,38 @@ const accountCertificateTransactions = (
196196
);
197197
};
198198

199+
const accountDRepCertificateTransactions = (
200+
transactions$: Observable<TxWithEpoch[]>,
201+
rewardAccount: Cardano.RewardAccount
202+
) => {
203+
const stakeKeyHash = Cardano.RewardAccount.toHash(rewardAccount);
204+
return transactions$.pipe(
205+
map((transactions) =>
206+
transactions
207+
.map(({ tx, epoch }) => ({
208+
certificates: (tx.body.certificates || [])
209+
.map((cert) =>
210+
Cardano.isCertType(cert, [
211+
...Cardano.VoteDelegationCredentialCertificateTypes,
212+
Cardano.CertificateType.StakeDeregistration,
213+
Cardano.CertificateType.Unregistration
214+
])
215+
? cert
216+
: null
217+
)
218+
.filter(isNotNil)
219+
.filter((cert) => (cert.stakeCredential.hash as unknown as Crypto.Ed25519KeyHashHex) === stakeKeyHash),
220+
epoch
221+
}))
222+
.filter(({ certificates }) => certificates.length > 0)
223+
),
224+
distinctUntilChanged((a, b) => isEqual(a, b))
225+
);
226+
};
227+
199228
type ObservableType<O> = O extends Observable<infer T> ? T : unknown;
200229
type TransactionsCertificates = ObservableType<ReturnType<typeof accountCertificateTransactions>>;
230+
type TransactionsDRepCertificates = ObservableType<ReturnType<typeof accountDRepCertificateTransactions>>;
201231

202232
/**
203233
* Check if the stake key was registered and is delegated, and return the pool ID.
@@ -249,6 +279,32 @@ export const createDelegateeTracker = (
249279
distinctUntilChanged((a, b) => isEqual(a, b))
250280
);
251281

282+
export const createDRepDelegateeTracker = (
283+
certificates$: Observable<TransactionsDRepCertificates>
284+
): Observable<Cardano.DRepDelegatee | undefined> =>
285+
certificates$.pipe(
286+
switchMap((certs) => {
287+
const sortedCerts = [...certs].sort((a, b) => a.epoch - b.epoch);
288+
const mostRecent = sortedCerts.pop()?.certificates.pop();
289+
let dRep;
290+
291+
// Certificates at this point are pre filtered, they are either vote delegation kind or stake key de-registration kind.
292+
// If the most recent is not a de-registration, emit found dRep.
293+
if (
294+
mostRecent &&
295+
!Cardano.isCertType(mostRecent, [
296+
Cardano.CertificateType.StakeDeregistration,
297+
Cardano.CertificateType.Unregistration
298+
])
299+
) {
300+
dRep = { delegateRepresentative: mostRecent.dRep };
301+
}
302+
303+
return of(dRep);
304+
}),
305+
distinctUntilChanged((a, b) => isEqual(a, b))
306+
);
307+
252308
export const addressCredentialStatuses = (
253309
addresses: Cardano.RewardAccount[],
254310
transactions$: Observable<TxWithEpoch[]>,
@@ -271,6 +327,11 @@ export const addressDelegatees = (
271327
)
272328
);
273329

330+
export const addressDRepDelegatees = (addresses: Cardano.RewardAccount[], transactions$: Observable<TxWithEpoch[]>) =>
331+
combineLatest(
332+
addresses.map((address) => createDRepDelegateeTracker(accountDRepCertificateTransactions(transactions$, address)))
333+
);
334+
274335
export const addressRewards = (
275336
rewardAccounts: Cardano.RewardAccount[],
276337
transactionsInFlight$: Observable<TxInFlight[]>,
@@ -316,15 +377,17 @@ export const addressRewards = (
316377

317378
export const toRewardAccounts =
318379
(addresses: Cardano.RewardAccount[]) =>
319-
([statuses, delegatees, rewards]: [
380+
([statuses, delegatees, dReps, rewards]: [
320381
{ credentialStatus: Cardano.StakeCredentialStatus; deposit?: Cardano.Lovelace }[],
321382
(Cardano.Delegatee | undefined)[],
383+
(Cardano.DRepDelegatee | undefined)[],
322384
Cardano.Lovelace[]
323385
]) =>
324386
addresses.map(
325387
(address, i): Cardano.RewardAccountInfo => ({
326388
address,
327389
credentialStatus: statuses[i].credentialStatus,
390+
dRepDelegatee: dReps[i],
328391
delegatee: delegatees[i],
329392
deposit: statuses[i].deposit,
330393
rewardBalance: rewards[i]
@@ -353,6 +416,7 @@ export const createRewardAccountsTracker = ({
353416
combineLatest([
354417
addressCredentialStatuses(rewardAccounts, transactions$, transactionsInFlight$),
355418
addressDelegatees(rewardAccounts, transactions$, stakePoolProvider, epoch$),
419+
addressDRepDelegatees(rewardAccounts, transactions$),
356420
addressRewards(rewardAccounts, transactionsInFlight$, rewardsProvider, balancesStore)
357421
]).pipe(map(toRewardAccounts(rewardAccounts)))
358422
)

0 commit comments

Comments
 (0)