Skip to content

Commit

Permalink
Merge pull request #4261 from BitGo/zeta-redelegation
Browse files Browse the repository at this point in the history
feat(sdk-coin-zeta): add redelegate transaction support for zeta
  • Loading branch information
DinshawKothari authored Feb 12, 2024
2 parents 616c7bf + b9bf137 commit 0b81ed0
Show file tree
Hide file tree
Showing 13 changed files with 402 additions and 5 deletions.
111 changes: 111 additions & 0 deletions modules/abstract-cosmos/src/cosmosCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
FeeData,
GasAmountDetails,
RecoveryOptions,
RedelegateMessage,
SendMessage,
} from './lib';
import { ROOT_PATH } from './lib/constants';
Expand Down Expand Up @@ -236,6 +237,116 @@ export class CosmosCoin extends BaseCoin {
return { serializedTx: serializedTx };
}

/**
* Builds a redelegate transaction
* @param {RecoveryOptions} params parameters needed to construct and
* (maybe) sign the transaction
*
* @returns {CosmosLikeCoinRecoveryOutput} the serialized transaction hex string
*/
async redelegate(
params: RecoveryOptions & {
validatorSrcAddress: string;
validatorDstAddress: string;
amountToRedelegate: string;
}
): Promise<CosmosLikeCoinRecoveryOutput> {
if (!params.bitgoKey) {
throw new Error('missing bitgoKey');
}

if (!params.validatorSrcAddress || !this.isValidAddress(params.validatorSrcAddress)) {
throw new Error('invalid validatorSrcAddress');
}

if (!params.validatorDstAddress || !this.isValidAddress(params.validatorDstAddress)) {
throw new Error('invalid validatorDstAddress');
}

if (!params.userKey) {
throw new Error('missing userKey');
}

if (!params.backupKey) {
throw new Error('missing backupKey');
}

if (!params.walletPassphrase) {
throw new Error('missing wallet passphrase');
}

if (!params.amountToRedelegate) {
throw new Error('missing amountToRedelegate');
}

const bitgoKey = params.bitgoKey.replace(/\s/g, '');

const MPC = new Ecdsa();
const chainId = await this.getChainId();
const publicKey = MPC.deriveUnhardened(bitgoKey, ROOT_PATH).slice(0, 66);
const senderAddress = this.getAddressFromPublicKey(publicKey);

const [accountNumber, sequenceNo] = await this.getAccountDetails(senderAddress);
const gasBudget: FeeData = {
amount: [{ denom: this.getDenomination(), amount: this.getGasAmountDetails().gasAmount }],
gasLimit: this.getGasAmountDetails().gasLimit,
};

const amount: Coin = {
denom: this.getDenomination(),
amount: new BigNumber(params.amountToRedelegate).toFixed(),
};

const sendMessage: RedelegateMessage[] = [
{
delegatorAddress: senderAddress,
validatorSrcAddress: params.validatorSrcAddress,
validatorDstAddress: params.validatorDstAddress,
amount: amount,
},
];

const txnBuilder = this.getBuilder().getStakingRedelegateBuilder();
txnBuilder
.messages(sendMessage)
.gasBudget(gasBudget)
.publicKey(publicKey)
.sequence(Number(sequenceNo))
.accountNumber(Number(accountNumber))
.chainId(chainId);

const unsignedTransaction = (await txnBuilder.build()) as CosmosTransaction;
let serializedTx = unsignedTransaction.toBroadcastFormat();
const signableHex = unsignedTransaction.signablePayload.toString('hex');
const userKey = params.userKey.replace(/\s/g, '');
const backupKey = params.backupKey.replace(/\s/g, '');
const [userKeyCombined, backupKeyCombined] = ((): [
ECDSAMethodTypes.KeyCombined | undefined,
ECDSAMethodTypes.KeyCombined | undefined
] => {
const [userKeyCombined, backupKeyCombined] = this.getKeyCombinedFromTssKeyShares(
userKey,
backupKey,
params.walletPassphrase
);
return [userKeyCombined, backupKeyCombined];
})();

if (!userKeyCombined || !backupKeyCombined) {
throw new Error('Missing combined key shares for user or backup');
}

const signature = await this.signRecoveryTSS(userKeyCombined, backupKeyCombined, signableHex);
const signableBuffer = Buffer.from(signableHex, 'hex');
MPC.verify(signableBuffer, signature, this.getHashFunction());
const cosmosKeyPair = this.getKeyPair(publicKey);
txnBuilder.addSignature({ pub: cosmosKeyPair.getKeys().pub }, Buffer.from(signature.r + signature.s, 'hex'));
const signedTransaction = await txnBuilder.build();
serializedTx = signedTransaction.toBroadcastFormat();

return { serializedTx: serializedTx };
}

private getKeyCombinedFromTssKeyShares(
userPublicOrPrivateKeyShare: string,
backupPrivateOrPublicKeyShare: string,
Expand Down
31 changes: 31 additions & 0 deletions modules/abstract-cosmos/src/lib/StakingRedelegateBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { TransactionType } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import * as constants from './constants';
import { RedelegateMessage } from './iface';
import { CosmosTransactionBuilder } from './transactionBuilder';
import { CosmosUtils } from './utils';

export class StakingRedelegateBuilder extends CosmosTransactionBuilder {
protected _utils: CosmosUtils;

constructor(_coinConfig: Readonly<CoinConfig>, utils: CosmosUtils) {
super(_coinConfig, utils);
this._utils = utils;
}

protected get transactionType(): TransactionType {
return TransactionType.StakingRedelegate;
}

/** @inheritdoc */
messages(redelegateMessages: RedelegateMessage[]): this {
this._messages = redelegateMessages.map((redelegateMessage) => {
this._utils.validateRedelegateMessage(redelegateMessage);
return {
typeUrl: constants.redelegateTypeUrl,
value: redelegateMessage,
};
});
return this;
}
}
1 change: 1 addition & 0 deletions modules/abstract-cosmos/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const DEFAULT_SEED_SIZE_BYTES = 16;
export const sendMsgTypeUrl = '/cosmos.bank.v1beta1.MsgSend';
export const delegateMsgTypeUrl = '/cosmos.staking.v1beta1.MsgDelegate';
export const undelegateMsgTypeUrl = '/cosmos.staking.v1beta1.MsgUndelegate';
export const redelegateTypeUrl = '/cosmos.staking.v1beta1.MsgBeginRedelegate';
export const withdrawDelegatorRewardMsgTypeUrl = '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward';
export const executeContractMsgTypeUrl = '/cosmwasm.wasm.v1.MsgExecuteContract';
export const UNAVAILABLE_TEXT = 'UNAVAILABLE';
Expand Down
10 changes: 9 additions & 1 deletion modules/abstract-cosmos/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export interface DelegateOrUndelegeteMessage {
amount: Coin;
}

export interface RedelegateMessage {
delegatorAddress: string;
validatorSrcAddress: string;
validatorDstAddress: string;
amount: Coin;
}

export interface WithdrawDelegatorRewardsMessage {
delegatorAddress: string;
validatorAddress: string;
Expand All @@ -62,7 +69,8 @@ export type CosmosTransactionMessage =
| SendMessage
| DelegateOrUndelegeteMessage
| WithdrawDelegatorRewardsMessage
| ExecuteContractMessage;
| ExecuteContractMessage
| RedelegateMessage;

export interface MessageData {
typeUrl: string;
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-cosmos/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export { CosmosKeyPair } from './keyPair';
export { CosmosTransaction } from './transaction';
export { CosmosTransactionBuilder } from './transactionBuilder';
export { CosmosTransferBuilder } from './transferBuilder';
export { StakingRedelegateBuilder } from './StakingRedelegateBuilder';
export { CosmosUtils } from './utils';
export { CosmosConstants };
28 changes: 28 additions & 0 deletions modules/abstract-cosmos/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CosmosLikeTransaction,
DelegateOrUndelegeteMessage,
ExecuteContractMessage,
RedelegateMessage,
SendMessage,
TransactionExplanation,
TxData,
Expand Down Expand Up @@ -232,6 +233,18 @@ export class CosmosTransaction extends BaseTransaction {
};
});
break;
case TransactionType.StakingRedelegate:
explanationResult.type = TransactionType.StakingRedelegate;
outputAmount = BigInt(0);
outputs = json.sendMessages.map((message) => {
const redelegateMessage = message.value as RedelegateMessage;
outputAmount = outputAmount + BigInt(redelegateMessage.amount.amount);
return {
address: redelegateMessage.validatorDstAddress,
amount: redelegateMessage.amount.amount,
};
});
break;
default:
throw new InvalidTransactionError('Transaction type not supported');
}
Expand Down Expand Up @@ -321,6 +334,21 @@ export class CosmosTransaction extends BaseTransaction {
});
});
break;
case TransactionType.StakingRedelegate:
this.cosmosLikeTransaction.sendMessages.forEach((message) => {
const redelegateMessage = message.value as RedelegateMessage;
inputs.push({
address: redelegateMessage.delegatorAddress,
value: redelegateMessage.amount.amount,
coin: this._coinConfig.name,
});
outputs.push({
address: redelegateMessage.validatorDstAddress,
value: redelegateMessage.amount.amount,
coin: this._coinConfig.name,
});
});
break;
default:
throw new InvalidTransactionError('Transaction type not supported');
}
Expand Down
56 changes: 56 additions & 0 deletions modules/abstract-cosmos/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
ExecuteContractMessage,
FeeData,
MessageData,
RedelegateMessage,
SendMessage,
WithdrawDelegatorRewardsMessage,
} from './iface';
Expand Down Expand Up @@ -231,6 +232,26 @@ export class CosmosUtils implements BaseUtils {
});
}

/**
* Returns the array of MessageData[] from the decoded transaction
* @param {DecodedTxRaw} decodedTx
* @returns {MessageData[]} Redelegate transaction message data
*/
getRedelegateMessageDataFromDecodedTx(decodedTx: DecodedTxRaw): MessageData[] {
return decodedTx.body.messages.map((message) => {
const value = this.registry.decode(message);
return {
value: {
delegatorAddress: value.delegatorAddress,
validatorSrcAddress: value.validatorSrcAddress,
validatorDstAddress: value.validatorDstAddress,
amount: value.amount,
},
typeUrl: message.typeUrl,
};
});
}

/**
* Returns the array of MessageData[] from the decoded transaction
* @param {DecodedTxRaw} decodedTx
Expand Down Expand Up @@ -304,6 +325,8 @@ export class CosmosUtils implements BaseUtils {
return TransactionType.StakingWithdraw;
case constants.executeContractMsgTypeUrl:
return TransactionType.ContractCall;
case constants.redelegateTypeUrl:
return TransactionType.StakingRedelegate;
default:
return undefined;
}
Expand Down Expand Up @@ -495,6 +518,32 @@ export class CosmosUtils implements BaseUtils {
this.validateAmount(delegateMessage.amount);
}

/**
* Validates the RedelegateMessage
* @param {DelegateOrUndelegeteMessage} redelegateMessage - The RedelegateMessage to validate.
* @throws {InvalidTransactionError} Throws an error if the validatorSrcAddress, validatorDstAddress, delegatorAddress, or amount is invalid or missing.
*/
validateRedelegateMessage(redelegateMessage: RedelegateMessage) {
this.isObjPropertyNull(redelegateMessage, ['validatorSrcAddress', 'validatorDstAddress', 'delegatorAddress']);

if (!this.isValidValidatorAddress(redelegateMessage.validatorSrcAddress)) {
throw new InvalidTransactionError(
`Invalid RedelegateMessage validatorSrcAddress: ` + redelegateMessage.validatorSrcAddress
);
}
if (!this.isValidValidatorAddress(redelegateMessage.validatorDstAddress)) {
throw new InvalidTransactionError(
`Invalid RedelegateMessage validatorDstAddress: ` + redelegateMessage.validatorDstAddress
);
}
if (!this.isValidAddress(redelegateMessage.delegatorAddress)) {
throw new InvalidTransactionError(
`Invalid DelegateOrUndelegeteMessage delegatorAddress: ` + redelegateMessage.delegatorAddress
);
}
this.validateAmount(redelegateMessage.amount);
}

/**
* Validates the MessageData
* @param {MessageData} messageData - The MessageData to validate.
Expand Down Expand Up @@ -531,6 +580,11 @@ export class CosmosUtils implements BaseUtils {
this.validateExecuteContractMessage(value, TransactionType.ContractCall);
break;
}
case TransactionType.StakingRedelegate: {
const value = messageData.value as RedelegateMessage;
this.validateRedelegateMessage(value);
break;
}
default:
throw new InvalidTransactionError(`Invalid MessageData TypeUrl is not supported: ` + messageData.typeUrl);
}
Expand Down Expand Up @@ -635,6 +689,8 @@ export class CosmosUtils implements BaseUtils {
sendMessageData = this.getWithdrawRewardsMessageDataFromDecodedTx(decodedTx);
} else if (type === TransactionType.ContractCall) {
sendMessageData = this.getExecuteContractMessageDataFromDecodedTx(decodedTx);
} else if (type === TransactionType.StakingRedelegate) {
sendMessageData = this.getRedelegateMessageDataFromDecodedTx(decodedTx);
} else {
throw new Error('Transaction type not supported: ' + typeUrl);
}
Expand Down
7 changes: 7 additions & 0 deletions modules/sdk-coin-zeta/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
StakingDeactivateBuilder,
StakingWithdrawRewardsBuilder,
ContractCallBuilder,
StakingRedelegateBuilder,
} from '@bitgo/abstract-cosmos';
import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
Expand All @@ -32,6 +33,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return this.getStakingWithdrawRewardsBuilder(tx);
case TransactionType.ContractCall:
return this.getContractCallBuilder(tx);
case TransactionType.StakingRedelegate:
return this.getStakingRedelegateBuilder(tx);
default:
throw new InvalidTransactionError('Invalid transaction');
}
Expand Down Expand Up @@ -64,6 +67,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return this.initializeBuilder(tx, new ContractCallBuilder(this._coinConfig, zetaUtils));
}

getStakingRedelegateBuilder(tx?: CosmosTransaction): StakingRedelegateBuilder {
return this.initializeBuilder(tx, new StakingRedelegateBuilder(this._coinConfig, zetaUtils));
}

/** @inheritdoc */
getWalletInitializationBuilder(): void {
throw new Error('Method not implemented.');
Expand Down
Loading

0 comments on commit 0b81ed0

Please sign in to comment.