diff --git a/modules/sdk-coin-xrp/src/index.ts b/modules/sdk-coin-xrp/src/index.ts index 82c0f16e33..b452b44656 100644 --- a/modules/sdk-coin-xrp/src/index.ts +++ b/modules/sdk-coin-xrp/src/index.ts @@ -1,6 +1,5 @@ -export * from './xrp'; -export * from './txrp'; +export * from './lib'; export * from './register'; -export * from './lib/iface'; -export * from './lib/utils'; +export * from './txrp'; +export * from './xrp'; export * from './xrpToken'; diff --git a/modules/sdk-coin-xrp/src/lib/accountSetBuilder.ts b/modules/sdk-coin-xrp/src/lib/accountSetBuilder.ts new file mode 100644 index 0000000000..eba3d803ce --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/accountSetBuilder.ts @@ -0,0 +1,73 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { AccountSet } from 'xrpl'; +import { XrpTransactionType } from './iface'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; + +export class AccountSetBuilder extends TransactionBuilder { + protected _setFlag: number; + protected _messageKey: string; + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.AccountUpdate; + } + + protected get xrpTransactionType(): XrpTransactionType.AccountSet { + return XrpTransactionType.AccountSet; + } + + setFlag(setFlag: number): TransactionBuilder { + utils.validateAccountSetFlag(setFlag); + this._setFlag = setFlag; + return this; + } + + messageKey(messageKey: string): TransactionBuilder { + if (typeof messageKey !== 'string') { + throw new BuildTransactionError('Invalid message key'); + } + this._messageKey = messageKey; + return this; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { setFlag, messageKey } = tx.toJson(); + if (setFlag) { + this.setFlag(setFlag); + } + + if (messageKey) { + this.messageKey(messageKey); + } + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + + const accountSetFields: AccountSet = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + }; + if (this._setFlag) { + accountSetFields.SetFlag = this._setFlag; + } + + if (this._messageKey) { + accountSetFields.MessageKey = this._messageKey; + } + + this._specificFields = accountSetFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/constants.ts b/modules/sdk-coin-xrp/src/lib/constants.ts new file mode 100644 index 0000000000..abaf8fd77e --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/constants.ts @@ -0,0 +1,28 @@ +// https://xrpl.org/signerlistset.html +export const MAX_SIGNERS = 32; +export const MIN_SIGNERS = 1; +export const MIN_SIGNER_QUORUM = 1; + +// https://xrpl.org/accountset.html#accountset-flags +export const VALID_ACCOUNT_SET_FLAGS = [ + 5, // asfAccountTxnID + 16, // asfAllowTrustLineClawback + 10, // asfAuthorizedNFTokenMinter + 8, // asfDefaultRipple + 9, // asfDepositAuth + 4, // asfDisableMaster + 13, // asfDisallowIncomingCheck + 12, // asfDisallowIncomingNFTokenOffer + 14, // asfDisallowIncomingPayChan + 15, // asfDisallowIncomingTrustline + 3, // asfDisallowXRP + 7, // asfGlobalFreeze + 6, // asfNoFreeze + 2, // asfRequireAuth + 1, // asfRequireDest +]; + +// Global flags for bitgo address +export const USER_KEY_SETTING_FLAG = 65536; +export const MASTER_KEY_DEACTIVATION_FLAG = 1048576; +export const REQUIRE_DESTINATION_TAG_FLAG = 131072; diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index c5e05945e1..4a5dc607a8 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -1,10 +1,21 @@ import { InitiateRecoveryOptions as BaseInitiateRecoveryOptions, SignTransactionOptions as BaseSignTransactionOptions, + TransactionExplanation as BaseTransactionExplanation, VerifyAddressOptions as BaseVerifyAddressOptions, - TransactionExplanation, TransactionPrebuild, } from '@bitgo/sdk-core'; +import { AccountSet, Payment, Signer, SignerEntry, SignerListSet, Amount, TrustSet } from 'xrpl'; + +export enum XrpTransactionType { + AccountSet = 'AccountSet', + Payment = 'Payment', + SignerListSet = 'SignerListSet', + TrustSet = 'TrustSet', + TokenPayment = 'TokenPayment', +} + +export type XrpTransaction = Payment | AccountSet | SignerListSet | TrustSet; export interface Address { address: string; @@ -35,7 +46,7 @@ export interface VerifyAddressOptions extends BaseVerifyAddressOptions { rootAddress: string; } -export interface RecoveryInfo extends TransactionExplanation { +export interface RecoveryInfo extends BaseTransactionExplanation { txHex: string; backupKey?: string; coin?: string; @@ -68,3 +79,53 @@ export interface HalfSignedTransaction { export interface SupplementGenerateWalletOptions { rootPrivateKey?: string; } + +export type TransactionExplanation = + | BaseTransactionExplanation + | AccountSetTransactionExplanation + | SignerListSetTransactionExplanation; + +export interface AccountSetTransactionExplanation extends BaseTransactionExplanation { + accountSet: { + messageKey?: string; + setFlag: number; + }; +} + +export interface SignerListSetTransactionExplanation extends BaseTransactionExplanation { + signerListSet: { + signerQuorum: number; + signerEntries: SignerEntry[]; + }; +} + +export interface TxData { + // mandatory fields + from: string; + transactionType: XrpTransactionType; + isMultiSig: boolean; + // optional fields + id?: string; + fee?: string; + flags: number; + sequence?: number; + lastLedgerSequence?: number; + signingPubKey?: string; // if '' then it is a multi sig + txnSignature?: string; // only for single sig + signers?: Signer[]; // only for multi sig + // transfer xrp fields + destination?: string; + destinationTag?: number; + amount?: Amount; + // account set fields + messageKey?: string; + setFlag?: number; + // signer list set fields + signerQuorum?: number; + signerEntries?: SignerEntry[]; +} + +export interface SignerDetails { + address: string; + weight: number; +} diff --git a/modules/sdk-coin-xrp/src/lib/index.ts b/modules/sdk-coin-xrp/src/lib/index.ts new file mode 100644 index 0000000000..b37b1a20be --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/index.ts @@ -0,0 +1,13 @@ +import Utils from './utils'; + +export * from './constants'; +export * from './iface'; +export { AccountSetBuilder } from './accountSetBuilder'; +export { KeyPair } from './keyPair'; +export { Transaction } from './transaction'; +export { TransactionBuilder } from './transactionBuilder'; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { TransferBuilder } from './transferBuilder'; +export { TokenTransferBuilder } from './tokenTransferBuilder'; +export { WalletInitializationBuilder } from './walletInitializationBuilder'; +export { Utils }; diff --git a/modules/sdk-coin-xrp/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-xrp/src/lib/tokenTransferBuilder.ts new file mode 100644 index 0000000000..0eca9dc1c5 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/tokenTransferBuilder.ts @@ -0,0 +1,99 @@ +import { Amount, IssuedCurrencyAmount, Payment } from 'xrpl'; +import { TransactionBuilder } from './transactionBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { XrpTransactionType } from './iface'; +import { Transaction } from './transaction'; +import utils from './utils'; +import _ from 'lodash'; + +export class TokenTransferBuilder extends TransactionBuilder { + private _amount: Amount; + private _destination: string; + private _destinationTag?: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + protected get xrpTransactionType(): XrpTransactionType.Payment { + return XrpTransactionType.Payment; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { destination, amount, destinationTag } = tx.toJson(); + if (!destination) { + throw new BuildTransactionError('Missing destination'); + } + if (!amount) { + throw new BuildTransactionError('Missing amount'); + } + + const normalizeAddress = utils.normalizeAddress({ address: destination, destinationTag }); + this.to(normalizeAddress); + this.amount(amount); + } + + /** + * Set the receiver address + * @param {string} address - the address with optional destination tag + * @returns {TransactionBuilder} This transaction builder + */ + to(address: string): TransactionBuilder { + const { address: xrpAddress, destinationTag } = utils.getAddressDetails(address); + this._destination = xrpAddress; + this._destinationTag = destinationTag; + return this; + } + + /** + * Set the amount to send + * @param {string} amount - the amount sent + * @returns {TransactionBuilder} This transaction builder + */ + amount(amount: Amount): TransactionBuilder { + function isIssuedCurrencyAmount(amount: Amount): amount is IssuedCurrencyAmount { + return ( + !_.isString(amount) && + _.isObjectLike(amount) && + _.isString(amount.currency) && + _.isString(amount.issuer) && + _.isString(amount.value) + ); + } + + if (!isIssuedCurrencyAmount(amount)) { + throw new Error(`amount type ${typeof amount} must be a IssuedCurrencyAmount type`); + } + this._amount = amount; + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + + const transferFields: Payment = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + Destination: this._destination, + Amount: this._amount, + }; + + if (this._destinationTag) { + transferFields.DestinationTag = this._destinationTag; + } + + this._specificFields = transferFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/transaction.ts b/modules/sdk-coin-xrp/src/lib/transaction.ts new file mode 100644 index 0000000000..945fcb05a5 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/transaction.ts @@ -0,0 +1,426 @@ +import _ from 'lodash'; +import * as xrpl from 'xrpl'; + +import { + BaseKey, + BaseTransaction, + TransactionExplanation as BaseTransactionExplanation, + InvalidTransactionError, + SigningError, + TransactionType, +} from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import utils from './utils'; + +import BigNumber from 'bignumber.js'; +import { Signer, IssuedCurrencyAmount, Amount } from 'xrpl'; +import { + AccountSetTransactionExplanation, + SignerListSetTransactionExplanation, + TransactionExplanation, + TxData, + XrpTransaction, + XrpTransactionType, +} from './iface'; +import { KeyPair } from './keyPair'; + +/** + * XRP transaction. + */ +export class Transaction extends BaseTransaction { + // XRP specific fields + protected _xrpTransaction: XrpTransaction; + protected _isMultiSig: boolean; + + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + get xrpTransaction(): XrpTransaction { + return this._xrpTransaction; + } + + set xrpTransaction(tx: XrpTransaction) { + this._xrpTransaction = tx; + } + + canSign(key: BaseKey): boolean { + if (!utils.isValidPrivateKey(key.key)) { + return false; + } + return true; + } + + toJson(): TxData { + if (!this._xrpTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + + const txData: TxData = { + from: this._xrpTransaction.Account, + isMultiSig: this._isMultiSig, + transactionType: this._xrpTransaction.TransactionType as XrpTransactionType, + id: this._id, + fee: this._xrpTransaction.Fee, + sequence: this._xrpTransaction.Sequence, + lastLedgerSequence: this._xrpTransaction.LastLedgerSequence, + flags: this._xrpTransaction.Flags as number, + signingPubKey: this._xrpTransaction.SigningPubKey, + signers: this._xrpTransaction.Signers, + txnSignature: this._xrpTransaction.TxnSignature, + }; + + if (this._xrpTransaction.SigningPubKey === '' && !_.isEmpty(this._xrpTransaction.Signers)) { + txData.isMultiSig = true; + } + if (this._xrpTransaction.SigningPubKey && utils.isValidPublicKey(this._xrpTransaction.SigningPubKey)) { + txData.isMultiSig = false; + } + + function isIssuedCurrencyAmount(amount: Amount): amount is IssuedCurrencyAmount { + return ( + !_.isString(amount) && + _.isObjectLike(amount) && + _.isString(amount.currency) && + _.isString(amount.issuer) && + _.isString(amount.value) + ); + } + + switch (this._xrpTransaction.TransactionType) { + case XrpTransactionType.Payment: + txData.destination = this._xrpTransaction.Destination; + txData.destinationTag = this._xrpTransaction.DestinationTag; + if (_.isString(this._xrpTransaction.Amount)) { + txData.amount = this._xrpTransaction.Amount; + } else if (this.isIssuedCurrencyAmount(this._xrpTransaction.Amount)) { + txData.amount = this._xrpTransaction.Amount; + } else if (isIssuedCurrencyAmount(this._xrpTransaction.Amount)) { + txData.amount = this._xrpTransaction.Amount; + } else { + throw new InvalidTransactionError('Invalid amount'); + } + return txData; + + case XrpTransactionType.AccountSet: + txData.setFlag = this._xrpTransaction.SetFlag; + txData.messageKey = this._xrpTransaction.MessageKey; + return txData; + + case XrpTransactionType.SignerListSet: + txData.signerQuorum = this._xrpTransaction.SignerQuorum; + txData.signerEntries = this._xrpTransaction.SignerEntries; + return txData; + + case XrpTransactionType.TrustSet: + return txData; + + default: + throw new InvalidTransactionError('Invalid transaction type'); + } + } + + getSignablePayload(): XrpTransaction { + if (!this._xrpTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + return _.omit(this._xrpTransaction, ['TxnSignature', 'Signers', 'SigningPubKey']) as XrpTransaction; + } + + sign(keyPair: KeyPair | KeyPair[]): void { + if (!this._xrpTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + if (!this._xrpTransaction.Fee) { + throw new InvalidTransactionError('Missing fee'); + } + if (!this._xrpTransaction.Sequence) { + throw new InvalidTransactionError('Missing sequence'); + } + if (!this._xrpTransaction.Flags) { + throw new InvalidTransactionError('Missing flags'); + } + if (_.isEmpty(keyPair)) { + return; + } + + const keyPairs = keyPair instanceof Array ? keyPair : [keyPair]; + for (const kp of keyPairs) { + const { pub, prv } = kp.getKeys(); + if (!prv) { + throw new SigningError('Missing private key'); + } + + if (this._isMultiSig === false && this._xrpTransaction.TxnSignature) { + throw new SigningError('Transaction has already been signed'); + } + const signablePayload = this.getSignablePayload(); + + const xrpWallet = new xrpl.Wallet(pub, prv); + const signedTx = xrpWallet.sign(signablePayload, this._isMultiSig); + const xrpSignedTx = xrpl.decode(signedTx.tx_blob); + xrpl.validate(xrpSignedTx); + + if (this._isMultiSig === false) { + xrpWallet.verifyTransaction(signedTx.tx_blob); + this._xrpTransaction = xrpSignedTx as unknown as XrpTransaction; + this._id = signedTx.hash; + } + + if (this._isMultiSig === true) { + if (!xrpSignedTx.Signers || !_.isArray(xrpSignedTx.Signers)) { + throw new SigningError('Missing or invalid signers'); + } + const sortedSigners = this.concatAndSortSigners(this._xrpTransaction.Signers || [], xrpSignedTx.Signers); + this._xrpTransaction = xrpSignedTx as unknown as XrpTransaction; + this._xrpTransaction.Signers = sortedSigners; + this._id = this.calculateIdFromRawTx(xrpl.encode(this._xrpTransaction)); + } + } + } + + /** @inheritdoc */ + toBroadcastFormat(): string { + if (!this._xrpTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + return xrpl.encode(this._xrpTransaction); + } + + explainTransaction(): TransactionExplanation { + switch (this._xrpTransaction.TransactionType) { + case XrpTransactionType.Payment: + return this.explainPaymentTransaction(); + case XrpTransactionType.AccountSet: + return this.explainAccountSetTransaction(); + case XrpTransactionType.SignerListSet: + return this.explainSignerListSetTransaction(); + default: + throw new Error('Unsupported transaction type'); + } + } + + private explainPaymentTransaction(): BaseTransactionExplanation { + const tx = this._xrpTransaction as xrpl.Payment; + const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); + const amount = _.isString(tx.Amount) ? tx.Amount : 0; + + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], + id: this._id as string, + changeOutputs: [], + outputAmount: amount, + changeAmount: 0, + outputs: [ + { + address, + amount, + }, + ], + fee: { + fee: tx.Fee as string, + feeRate: undefined, + }, + }; + } + + private explainAccountSetTransaction(): AccountSetTransactionExplanation { + const tx = this._xrpTransaction as xrpl.AccountSet; + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'accountSet'], + id: this._id as string, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: tx.Fee as string, + feeRate: undefined, + }, + accountSet: { + messageKey: tx.MessageKey, + setFlag: tx.SetFlag as xrpl.AccountSetAsfFlags, + }, + }; + } + + private explainSignerListSetTransaction(): SignerListSetTransactionExplanation { + const tx = this._xrpTransaction as xrpl.SignerListSet; + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'signerListSet'], + id: this._id as string, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: tx.Fee as string, + feeRate: undefined, + }, + signerListSet: { + signerQuorum: tx.SignerQuorum, + signerEntries: tx.SignerEntries as xrpl.SignerEntry[], + }, + }; + } + + private calculateIdFromRawTx(rawTransaction: string): string { + let id: string; + // hashes ids are different for signed and unsigned tx + // first we try to get the hash id as if it is signed, will throw if its not + try { + id = xrpl.hashes.hashSignedTx(rawTransaction); + } catch (e) { + id = xrpl.hashes.hashTx(rawTransaction); + } + return id; + } + + /** + * Set the transaction type. + * + * @param {TransactionType} transactionType The transaction type to be set. + */ + setTransactionType(transactionType: TransactionType): void { + this._type = transactionType; + } + + setMultiSigValue(isMultiSig: boolean): void { + this._isMultiSig = isMultiSig; + } + + private getXrpTransactionType(xrpTransaction: XrpTransaction): XrpTransactionType { + if (xrpTransaction.TransactionType === XrpTransactionType.Payment) { + if (_.isString(xrpTransaction.Amount)) { + return XrpTransactionType.Payment; + } else if (this.isIssuedCurrencyAmount(xrpTransaction.Amount)) { + return XrpTransactionType.TokenPayment; + } else { + throw new InvalidTransactionError('Invalid amount'); + } + } else { + switch (xrpTransaction.TransactionType) { + case XrpTransactionType.AccountSet: + return XrpTransactionType.AccountSet; + case XrpTransactionType.SignerListSet: + return XrpTransactionType.SignerListSet; + default: + throw new InvalidTransactionError(`Invalid TransactionType: ${xrpTransaction.TransactionType}`); + } + } + } + + /** + * Sets this transaction payload + * + * @param rawTransaction + */ + fromRawTransaction(rawTransaction: string): void { + let txHex = rawTransaction; + if (!utils.isValidHex(rawTransaction)) { + try { + txHex = xrpl.encode(JSON.parse(rawTransaction)); + } catch (e) { + throw new InvalidTransactionError('Invalid transaction'); + } + } + utils.validateRawTransaction(txHex); + + this._xrpTransaction = xrpl.decode(txHex) as unknown as XrpTransaction; + if (!XrpTransactionType[this._xrpTransaction.TransactionType]) { + throw new InvalidTransactionError('Unsupported transaction type, got: ' + this._xrpTransaction.TransactionType); + } + if (this._xrpTransaction.SigningPubKey && this._xrpTransaction.SigningPubKey !== '') { + this._isMultiSig = false; + } + if ( + this._xrpTransaction.SigningPubKey === '' && + this._xrpTransaction.Signers && + this._xrpTransaction.Signers.length > 0 + ) { + this._isMultiSig = true; + } + this._id = this.calculateIdFromRawTx(txHex); + const txType = this.getXrpTransactionType(this._xrpTransaction); + + switch (txType) { + case XrpTransactionType.SignerListSet: + this.setTransactionType(TransactionType.WalletInitialization); + break; + case XrpTransactionType.AccountSet: + this.setTransactionType(TransactionType.AccountUpdate); + break; + case XrpTransactionType.Payment: + this.setTransactionType(TransactionType.Send); + break; + case XrpTransactionType.TokenPayment: + this.setTransactionType(TransactionType.SendToken); + break; + } + this.loadInputsAndOutputs(); + } + + /** + * Load the input and output data on this transaction. + */ + loadInputsAndOutputs(): void { + if (!this._xrpTransaction) { + return; + } + if (this._xrpTransaction.TransactionType === XrpTransactionType.Payment) { + let value: string, coin: string; + const { Account, Destination, Amount, DestinationTag } = this._xrpTransaction; + if (_.isString(Amount)) { + value = Amount; + coin = this._coinConfig.name; + } else { + value = Amount.value; + coin = Amount.currency; + } + this.inputs.push({ + address: Account, + value, + coin, + }); + this.outputs.push({ + address: utils.normalizeAddress({ address: Destination, destinationTag: DestinationTag }), + value, + coin, + }); + } + } + + /** + * Groups and sorts the signers by account. + * @param {Signer[]}signers1 - The first set of signers. + * @param {Signer[]}signers2 - The second set of signers. + * @returns The grouped and sorted signers. + **/ + private concatAndSortSigners(signers1: Signer[], signers2: Signer[]): Signer[] { + return signers1 + .concat(signers2) + .sort((signer1, signer2) => this.compareSignersByAccount(signer1.Signer.Account, signer2.Signer.Account)); + } + + /** + * If presented in binary form, the Signers array must be sorted based on + * the numeric value of the signer addresses, with the lowest value first. + * (If submitted as JSON, the submit_multisigned method handles this automatically.) + * https://xrpl.org/multi-signing.html. + * + * @param left - A Signer to compare with. + * @param right - A second Signer to compare with. + * @returns 1 if left \> right, 0 if left = right, -1 if left \< right, and null if left or right are NaN. + */ + private compareSignersByAccount(address1: string, address2: string): number { + const addressBN1 = this.addressToBigNumber(address1); + const addressBN2 = this.addressToBigNumber(address2); + return addressBN1.comparedTo(addressBN2); + } + + private addressToBigNumber(address: string): BigNumber { + const hex = Buffer.from(xrpl.decodeAccountID(address)).toString('hex'); + const numberOfBitsInHex = 16; + return new BigNumber(hex, numberOfBitsInHex); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/transactionBuilder.ts b/modules/sdk-coin-xrp/src/lib/transactionBuilder.ts new file mode 100644 index 0000000000..da533fbe20 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/transactionBuilder.ts @@ -0,0 +1,283 @@ +import { + BaseAddress, + BaseKey, + BaseTransaction, + BaseTransactionBuilder, + BuildTransactionError, + SigningError, + TransactionType, + xprvToRawPrv, +} from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import BigNumber from 'bignumber.js'; +import * as _ from 'lodash'; +import * as xrpl from 'xrpl'; +import { Signer } from 'xrpl/dist/npm/models/common'; +import { XrpTransaction } from './iface'; +import { KeyPair } from './keyPair'; +import { Transaction } from './transaction'; +import utils from './utils'; + +/** + * XRP transaction builder. + */ +export abstract class TransactionBuilder extends BaseTransactionBuilder { + protected _transaction: Transaction; + protected _sender: string; + protected _fee?: string; + protected _sequence?: number; + protected _lastLedgerSequence?: number; + protected _flags?: number = 0; + protected _signingPubKey?: string; + protected _signers: Signer[]; + protected _txnSignature?: string; + protected _specificFields: XrpTransaction; + + protected _isMultiSig?: boolean; + + protected _keyPairs: KeyPair[]; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.transaction = new Transaction(_coinConfig); + this._keyPairs = []; + this._signers = []; + } + + /** + * The transaction type. + */ + protected abstract get transactionType(): TransactionType; + + /** + * Set the transaction signature type to multi sig. + **/ + setMultiSig(): TransactionBuilder { + this._isMultiSig = true; + return this; + } + + /** + * Set the transaction signature type to single sig. + **/ + setSingleSig(): TransactionBuilder { + this._isMultiSig = false; + return this; + } + + /** + * Sets the sender of this transaction. + * + * @param {string} address the account that is sending this transaction + * @returns {TransactionBuilder} This transaction builder + */ + sender(address: string): TransactionBuilder { + this.validateAddress({ address }); + this._sender = address; + return this; + } + + sequence(sequence: number): TransactionBuilder { + if (typeof sequence !== 'number' || sequence < 0) { + throw new Error(`sequence ${sequence} is not valid`); + } + this._sequence = sequence; + return this; + } + + fee(fee: string): TransactionBuilder { + if (typeof fee !== 'string') { + throw new Error(`fee type ${typeof fee} must be a string`); + } + const feeBigInt = BigInt(fee); + if (feeBigInt > 0) { + throw new Error(`fee ${fee} is not valid`); + } + this._fee = fee; + return this; + } + + flags(flags: number): TransactionBuilder { + if (typeof flags !== 'number' || flags < 0) { + throw new Error(`flags ${flags} is not valid`); + } + this._flags = flags; + return this; + } + + lastLedgerSequence(lastLedgerSequence: number): TransactionBuilder { + if (typeof lastLedgerSequence !== 'number' || lastLedgerSequence < 0) { + throw new Error(`lastLedgerSequence ${lastLedgerSequence} is not valid`); + } + this._lastLedgerSequence = lastLedgerSequence; + return this; + } + + /** + * Initialize the transaction builder fields using the decoded transaction data + * + * @param {Transaction} tx the transaction data + */ + initBuilder(tx: Transaction): void { + this._transaction = tx; + const txData = tx.toJson(); + + if (!_.isUndefined(txData.isMultiSig)) { + txData.isMultiSig ? this.setMultiSig() : this.setSingleSig(); + } + + this.sender(txData.from); + if (txData.fee) { + this.fee(txData.fee); + } + if (txData.sequence) { + this.sequence(txData.sequence); + } + if (txData.lastLedgerSequence) { + this.lastLedgerSequence(txData.lastLedgerSequence); + } + if (txData.flags) { + this.flags(txData.flags); + } + this._signers = txData.signers || []; + this._signingPubKey = txData.signingPubKey; + this._txnSignature = txData.txnSignature; + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): BaseTransaction { + const tx = new Transaction(this._coinConfig); + this.validateRawTransaction(rawTransaction); + tx.fromRawTransaction(rawTransaction); + this.initBuilder(tx); + return this.transaction; + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { + this.validateKey(key); + this.checkDuplicatedKeys(key); + let prv = key.key; + if (prv.startsWith('xprv')) { + const rawPrv = xprvToRawPrv(prv); + prv = new KeyPair({ prv: rawPrv }).getKeys().prv; + } + const signer = new KeyPair({ prv: prv }); + + this._keyPairs.push(signer); + + return this.transaction; + } + + private checkDuplicatedKeys(key: BaseKey) { + const keyPair = new KeyPair({ prv: key.key }).getKeys(); + const keyPairPrv = keyPair.prv as string; + + this._keyPairs.forEach((kp) => { + const prv = kp.getKeys().prv as string; + if (prv.toLowerCase() === keyPairPrv.toLowerCase()) { + throw new SigningError('Repeated sign'); + } + }); + this._signers.forEach((signer) => { + if (signer.Signer.SigningPubKey.toLowerCase() === keyPair.pub.toLowerCase()) { + throw new SigningError('Repeated sign'); + } + }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.xrpTransaction = this.buildXrpTransaction(); + this.transaction.setTransactionType(this.transactionType); + this.transaction.loadInputsAndOutputs(); + if (this._keyPairs.length > 0) { + this.validateIsMultiSig(); + this.transaction.setMultiSigValue(this._isMultiSig as boolean); + this.transaction.sign(this._keyPairs); + } + return this.transaction; + } + + private buildXrpTransaction() { + const commonFields: Partial = { + Account: this._sender, + Fee: this._fee, + Sequence: this._sequence, + Flags: this._flags, + }; + + if (this._signingPubKey) { + commonFields.SigningPubKey = this._signingPubKey; + } + if (this._txnSignature) { + commonFields.TxnSignature = this._txnSignature; + } + if (this._signers.length > 0) { + commonFields.Signers = this._signers; + } + if (this._lastLedgerSequence) { + commonFields.LastLedgerSequence = this._lastLedgerSequence; + } + + const tx = Object.assign(commonFields, this._specificFields); + xrpl.validate(tx as unknown as Record); + return tx; + } + + validateKey(key: BaseKey): void { + let keyPair: KeyPair; + try { + keyPair = new KeyPair({ prv: key.key }); + } catch { + throw new BuildTransactionError('Invalid key'); + } + + if (!keyPair.getKeys().prv) { + throw new BuildTransactionError('Invalid key'); + } + } + + validateIsMultiSig(): void { + if (_.isUndefined(this._isMultiSig)) { + throw new BuildTransactionError('Signature type is not defined. Please call setMultiSig or setSingleSig.'); + } + } + + /** @inheritdoc */ + validateTransaction(): void { + if (this._sender === undefined) { + throw new BuildTransactionError('Invalid transaction: missing sender'); + } + if (this._fee === undefined) { + throw new BuildTransactionError('Invalid transaction: missing fee'); + } + if (this._sequence === undefined) { + throw new BuildTransactionError('Invalid transaction: missing sequence'); + } + } + + validateAddress(address: BaseAddress): void { + if (!utils.isValidAddress(address.address)) { + throw new BuildTransactionError('Invalid address ' + address.address); + } + } + + validateValue(value: BigNumber): void { + if (value.isLessThan(0)) { + throw new BuildTransactionError('Value cannot be less than zero'); + } + } + + validateRawTransaction(rawTransaction: string): void { + utils.validateRawTransaction(rawTransaction); + } + + protected get transaction(): Transaction { + return this._transaction; + } + + protected set transaction(transaction: Transaction) { + this._transaction = transaction; + } +} diff --git a/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts new file mode 100644 index 0000000000..217bb45f48 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts @@ -0,0 +1,90 @@ +import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import xrpl from 'xrpl'; +import { AccountSetBuilder } from './accountSetBuilder'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { TransferBuilder } from './transferBuilder'; +import utils from './utils'; +import { WalletInitializationBuilder } from './walletInitializationBuilder'; +import { TokenTransferBuilder } from './tokenTransferBuilder'; + +export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** @inheritdoc */ + from(raw: string): TransactionBuilder { + let txHex = raw; + if (!utils.isValidHex(raw)) { + try { + txHex = xrpl.encode(JSON.parse(raw)); + } catch (e) { + throw new InvalidTransactionError('Invalid transaction'); + } + } + const tx = this.parseTransaction(txHex); + try { + switch (tx.type) { + case TransactionType.AccountUpdate: + return this.getAccountUpdateBuilder(tx); + case TransactionType.Send: + return this.getTransferBuilder(tx); + case TransactionType.WalletInitialization: + return this.getWalletInitializationBuilder(tx); + case TransactionType.SendToken: + return this.getTokenTransferBuilder(tx); + default: + throw new InvalidTransactionError('Invalid transaction'); + } + } catch (e) { + throw e; + } + } + + /** @inheritdoc */ + getWalletInitializationBuilder(tx?: Transaction): WalletInitializationBuilder { + return this.initializeBuilder(tx, new WalletInitializationBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + public getTransferBuilder(tx?: Transaction): TransferBuilder { + return this.initializeBuilder(tx, new TransferBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + public getAccountUpdateBuilder(tx?: Transaction): AccountSetBuilder { + return this.initializeBuilder(tx, new AccountSetBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + public getTokenTransferBuilder(tx?: Transaction): TokenTransferBuilder { + return this.initializeBuilder(tx, new TokenTransferBuilder(this._coinConfig)); + } + + /** + * Initialize the builder with the given transaction + * + * @param {Transaction | undefined} tx - the transaction used to initialize the builder + * @param {TransactionBuilder} builder - the builder to be initialized + * @returns {TransactionBuilder} the builder initialized + */ + private initializeBuilder(tx: Transaction | undefined, builder: T): T { + if (tx) { + builder.initBuilder(tx); + } + return builder; + } + + /** Parse the transaction from a raw transaction + * + * @param {string} rawTransaction - the raw tx + * @returns {Transaction} parsedtransaction + */ + private parseTransaction(rawTransaction: string): Transaction { + const tx = new Transaction(this._coinConfig); + tx.fromRawTransaction(rawTransaction); + return tx; + } +} diff --git a/modules/sdk-coin-xrp/src/lib/transferBuilder.ts b/modules/sdk-coin-xrp/src/lib/transferBuilder.ts new file mode 100644 index 0000000000..cc0ec670c7 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/transferBuilder.ts @@ -0,0 +1,91 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Payment, Amount } from 'xrpl'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; + +export class TransferBuilder extends TransactionBuilder { + private _amount: Amount; + private _destination: string; + private _destinationTag?: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + protected get xrpTransactionType(): 'Payment' { + return 'Payment'; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { destination, amount, destinationTag } = tx.toJson(); + if (!destination) { + throw new BuildTransactionError('Missing destination'); + } + if (!amount) { + throw new BuildTransactionError('Missing amount'); + } + + const normalizeAddress = utils.normalizeAddress({ address: destination, destinationTag }); + this.to(normalizeAddress); + this.amount(amount); + } + + /** + * Set the receiver address + * @param {string} address - the address with optional destination tag + * @returns {TransactionBuilder} This transaction builder + */ + to(address: string): TransactionBuilder { + const { address: xrpAddress, destinationTag } = utils.getAddressDetails(address); + this._destination = xrpAddress; + this._destinationTag = destinationTag; + return this; + } + + /** + * Set the amount to send + * @param {string} amount - the amount sent + * @returns {TransactionBuilder} This transaction builder + */ + amount(amount: Amount): TransactionBuilder { + if (typeof amount !== 'string') { + throw new Error(`amount type ${typeof amount} must be a string`); + } + const amountBigInt = BigInt(amount); + if (amountBigInt < 0) { + throw new Error(`amount ${amount} is not valid`); + } + this._amount = amount; + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + + const transferFields: Payment = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + Destination: this._destination, + Amount: this._amount, + }; + + if (this._destinationTag) { + transferFields.DestinationTag = this._destinationTag; + } + + this._specificFields = transferFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/trustsetBuilder.ts b/modules/sdk-coin-xrp/src/lib/trustsetBuilder.ts new file mode 100644 index 0000000000..f91a881985 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/trustsetBuilder.ts @@ -0,0 +1,77 @@ +import { TransactionBuilder } from './transactionBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { XrpTransactionType } from './iface'; +import { Amount, IssuedCurrencyAmount, TrustSet } from 'xrpl'; +import { Transaction } from './transaction'; +import _ from 'lodash'; + +export class TrustsetBuilder extends TransactionBuilder { + private _amount: IssuedCurrencyAmount; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.TrustLine; + } + + protected get xrpTransactionType(): XrpTransactionType.TrustSet { + return XrpTransactionType.TrustSet; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { destination, amount } = tx.toJson(); + if (!destination) { + throw new BuildTransactionError('Missing destination'); + } + if (!amount) { + throw new BuildTransactionError('Missing amount'); + } + + this.amount(amount); + } + + /** + * Set the amount to send + * @param {string} amount - the amount sent + * @returns {TransactionBuilder} This transaction builder + */ + amount(amount: Amount): TransactionBuilder { + function isIssuedCurrencyAmount(amount: Amount): amount is IssuedCurrencyAmount { + return ( + !_.isString(amount) && + _.isObjectLike(amount) && + _.isString(amount.currency) && + _.isString(amount.issuer) && + _.isString(amount.value) + ); + } + + if (!isIssuedCurrencyAmount(amount)) { + throw new Error(`amount type ${typeof amount} must be a IssuedCurrencyAmount type`); + } + this._amount = amount; + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + + const transferFields: TrustSet = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + LimitAmount: this._amount, + }; + + this._specificFields = transferFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/utils.ts b/modules/sdk-coin-xrp/src/lib/utils.ts index cb438eccd1..6257cddced 100644 --- a/modules/sdk-coin-xrp/src/lib/utils.ts +++ b/modules/sdk-coin-xrp/src/lib/utils.ts @@ -1,9 +1,10 @@ -import { BaseUtils, InvalidAddressError, UtilsError } from '@bitgo/sdk-core'; +import { BaseUtils, InvalidAddressError, InvalidTransactionError, UtilsError } from '@bitgo/sdk-core'; import * as querystring from 'querystring'; import * as rippleKeypairs from 'ripple-keypairs'; import * as url from 'url'; import * as xrpl from 'xrpl'; -import { Address } from './iface'; +import { VALID_ACCOUNT_SET_FLAGS } from './constants'; +import { Address, SignerDetails } from './iface'; import { KeyPair as XrpKeyPair } from './keyPair'; class Utils implements BaseUtils { @@ -155,6 +156,60 @@ class Utils implements BaseUtils { return false; } } + + /** + * Check the raw transaction has a valid format in the blockchain context, throw otherwise. + * + * @param {string} rawTransaction - Transaction in hex string format + */ + public validateRawTransaction(rawTransaction: string): void { + if (!rawTransaction) { + throw new InvalidTransactionError('Invalid raw transaction: Undefined'); + } + if (!this.isValidHex(rawTransaction)) { + throw new InvalidTransactionError('Invalid raw transaction: Hex string expected'); + } + if (!this.isValidRawTransaction(rawTransaction)) { + throw new InvalidTransactionError('Invalid raw transaction'); + } + } + + /** + * Checks if raw transaction can be deserialized + * + * @param {string} rawTransaction - transaction in base64 string format + * @returns {boolean} - the validation result + */ + public isValidRawTransaction(rawTransaction: string): boolean { + try { + const jsonTx = xrpl.decode(rawTransaction); + xrpl.validate(jsonTx); + return true; + } catch (e) { + return false; + } + } + + public validateAccountSetFlag(setFlag: number) { + if (typeof setFlag !== 'number') { + throw new UtilsError(`setFlag ${setFlag} is not valid`); + } + if (!VALID_ACCOUNT_SET_FLAGS.includes(setFlag)) { + throw new UtilsError(`setFlag ${setFlag} is not a valid account set flag`); + } + } + + public validateSigner(signer: SignerDetails): void { + if (!signer.address) { + throw new UtilsError('signer must have an address'); + } + if (!this.isValidAddress(signer.address)) { + throw new UtilsError(`signer address ${signer.address} is invalid`); + } + if (typeof signer.weight !== 'number' || signer.weight < 0) { + throw new UtilsError(`signer weight ${signer.weight} is not valid`); + } + } } const utils = new Utils(); diff --git a/modules/sdk-coin-xrp/src/lib/walletInitializationBuilder.ts b/modules/sdk-coin-xrp/src/lib/walletInitializationBuilder.ts new file mode 100644 index 0000000000..c3f845cf58 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/walletInitializationBuilder.ts @@ -0,0 +1,86 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { SignerEntry } from 'xrpl/dist/npm/models/common'; +import { MAX_SIGNERS, MIN_SIGNER_QUORUM, MIN_SIGNERS } from './constants'; +import { SignerDetails } from './iface'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; + +export class WalletInitializationBuilder extends TransactionBuilder { + protected _signerQuorum: number; + protected _signerEntries: SignerEntry[] = []; + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.WalletInitialization; + } + + protected get xrpTransactionType(): 'SignerListSet' { + return 'SignerListSet'; + } + + signerQuorum(quorum: number): TransactionBuilder { + if (typeof quorum !== 'number' || quorum < 1) { + throw new Error(`quorum must be a valid number greater than 0, got: ${quorum}`); + } + this._signerQuorum = quorum; + return this; + } + + signer(signer: SignerDetails): TransactionBuilder { + if (this._signerEntries.length > MAX_SIGNERS) { + throw new BuildTransactionError(`Cannot have more than ${MAX_SIGNERS} signers`); + } + utils.validateSigner(signer); + this._signerEntries.push({ + SignerEntry: { Account: signer.address, SignerWeight: signer.weight }, + }); + return this; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + const { signerEntries, signerQuorum } = tx.toJson(); + if (signerQuorum) { + this.signerQuorum(signerQuorum); + } + + if (signerEntries?.length) { + signerEntries.forEach((signer) => + this.signer({ + address: signer.SignerEntry.Account, + weight: signer.SignerEntry.SignerWeight, + }) + ); + } + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + if (!this._signerQuorum || this._signerQuorum < MIN_SIGNER_QUORUM) { + throw new BuildTransactionError('Signer quorum must be set before building the transaction'); + } + if (!this._signerEntries) { + throw new BuildTransactionError('Signers must be set before building the transaction'); + } + + if (this._signerEntries.length < MIN_SIGNERS || this._signerEntries.length > MAX_SIGNERS) { + throw new BuildTransactionError(`Signers must be between ${MIN_SIGNERS} and ${MAX_SIGNERS}`); + } + + this._specificFields = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + SignerQuorum: this._signerQuorum, + SignerEntries: this._signerEntries, + }; + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index 97681ef7f0..7ede6b1920 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -16,7 +16,6 @@ import { ParsedTransaction, ParseTransactionOptions, promiseProps, - TransactionExplanation, UnexpectedAddressError, VerifyTransactionOptions, } from '@bitgo/sdk-core'; @@ -34,6 +33,7 @@ import { RecoveryTransaction, SignTransactionOptions, SupplementGenerateWalletOptions, + TransactionExplanation, VerifyAddressOptions, } from './lib/iface'; import { KeyPair as XrpKeyPair } from './lib/keyPair'; @@ -204,7 +204,7 @@ export class Xrp extends BaseCoin { id = xrpl.hashes.hashTx(txHex); } - if (transaction.TransactionType == 'AccountSet') { + if (transaction.TransactionType === 'AccountSet') { return { displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'accountSet'], id: id, @@ -214,13 +214,14 @@ export class Xrp extends BaseCoin { outputs: [], fee: { fee: transaction.Fee, - feeRate: null, - size: txHex!.length / 2, + feeRate: undefined, + size: txHex.length / 2, }, accountSet: { messageKey: transaction.MessageKey, + setFlag: transaction.SetFlag, }, - } as any; + }; } const address = @@ -239,10 +240,10 @@ export class Xrp extends BaseCoin { ], fee: { fee: transaction.Fee, - feeRate: null, + feeRate: undefined, size: txHex.length / 2, }, - } as any; + }; } /** @@ -492,7 +493,7 @@ export class Xrp extends BaseCoin { const transactionExplanation: RecoveryInfo = (await this.explainTransaction({ txHex: signedTransaction, - })) as any; + })) as RecoveryInfo; transactionExplanation.txHex = signedTransaction; diff --git a/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts b/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts new file mode 100644 index 0000000000..fe5f8a733b --- /dev/null +++ b/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts @@ -0,0 +1,6 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; + +export const getBuilderFactory = (coin: string): TransactionBuilderFactory => { + return new TransactionBuilderFactory(coins.get(coin)); +}; diff --git a/modules/sdk-coin-xrp/test/unit/lib/utils.ts b/modules/sdk-coin-xrp/test/unit/lib/utils.ts index dab544bd1f..da9a5057ff 100644 --- a/modules/sdk-coin-xrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-xrp/test/unit/lib/utils.ts @@ -1,5 +1,6 @@ -import { InvalidAddressError } from '@bitgo/sdk-core'; +import { InvalidAddressError, UtilsError } from '@bitgo/sdk-core'; import should from 'should'; +import { VALID_ACCOUNT_SET_FLAGS } from '../../../src/lib/constants'; import { Address } from '../../../src/lib/iface'; import XrpUtils from '../../../src/lib/utils'; @@ -305,4 +306,77 @@ describe('Utils', () => { XrpUtils.verifySignature(message, signature, publicKey).should.be.false(); }); }); + + describe('validateRawTransaction', () => { + it('should throw an error when given an undefined raw transaction', () => { + const rawTransaction = undefined; + should(() => { + // @ts-expect-error testing for invalid type + XrpUtils.validateRawTransaction(rawTransaction); + }).throw('Invalid raw transaction: Undefined'); + }); + + it('should throw an error when given a non-hex raw transaction', () => { + const rawTransaction = 'not a hex string'; + should(() => { + XrpUtils.validateRawTransaction(rawTransaction); + }).throw('Invalid raw transaction: Hex string expected'); + }); + + it('should throw an error when given an invalid raw transaction', () => { + const rawTransaction = + '228000000024000000072E00000000201B0018D07161400000000003DE296840000000B7C9D0AF67D113EBCE1F1'; + should(() => { + XrpUtils.validateRawTransaction(rawTransaction); + }).throw('Invalid raw transaction'); + }); + + it('should not throw an error when given a valid raw transaction', () => { + const rawTransaction = + '120000228000000024000000072E00000000201B0018D07161400000000003DE2968400000000000002D73008114726D0D8A26568D5D9680AC80577C912236717191831449EE221CCACC4DD2BF8862B22B0960A84FC771D9F3E010732103AFBB6845826367D738B0D42EA0756C94547E70B064E8FE1260CF21354C898B0B74473045022100CA3A98AA6FC8CCA251C3A2754992E474EA469884EB8D489D2B180EB644AC7695022037EB886DCF57928E5844DB73C2E86DE553FB59DCFC9408F3FD5D802ADB69DFCC8114F0DBA9D34C77B6769F6142AB7C9D0AF67D113EBCE1F1'; + should(() => { + XrpUtils.validateRawTransaction(rawTransaction); + }).not.throw(); + }); + }); + + describe('isValidRawTransaction', () => { + it('should return false when given an invalid raw transaction', () => { + const rawTransaction = + '228000000024000000072E00000000201B0018D07161400000000003DE296840000000B7C9D0AF67D113EBCE1F1'; + XrpUtils.isValidRawTransaction(rawTransaction).should.be.false(); + }); + + it('should return true when given a valid raw transaction', () => { + const rawTransaction = + '120000228000000024000000072E00000000201B0018D07161400000000003DE2968400000000000002D73008114726D0D8A26568D5D9680AC80577C912236717191831449EE221CCACC4DD2BF8862B22B0960A84FC771D9F3E010732103AFBB6845826367D738B0D42EA0756C94547E70B064E8FE1260CF21354C898B0B74473045022100CA3A98AA6FC8CCA251C3A2754992E474EA469884EB8D489D2B180EB644AC7695022037EB886DCF57928E5844DB73C2E86DE553FB59DCFC9408F3FD5D802ADB69DFCC8114F0DBA9D34C77B6769F6142AB7C9D0AF67D113EBCE1F1'; + XrpUtils.isValidRawTransaction(rawTransaction).should.be.true(); + }); + }); + + describe('validateAccountSetFlag', () => { + it('should throw an error if the flag is not a valid number', () => { + const invalidFlag = 'invalid'; + should(() => { + // @ts-expect-error testing for invalid type + XrpUtils.validateAccountSetFlag(invalidFlag); + }).throw(UtilsError, { message: `setFlag ${invalidFlag} is not valid` }); + }); + + it('should throw an error if the flag is not a valid account set flag', () => { + const invalidFlag = 55; + should(() => { + XrpUtils.validateAccountSetFlag(invalidFlag); + }).throw(UtilsError, { message: `setFlag ${invalidFlag} is not a valid account set flag` }); + }); + + it('should not throw an error if the flag is valid', () => { + const validFlags = VALID_ACCOUNT_SET_FLAGS; + for (const validFlag of validFlags) { + should(() => { + XrpUtils.validateAccountSetFlag(validFlag); + }).not.throw(); + } + }); + }); }); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 8486f24d2b..7f92275ce3 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -70,6 +70,8 @@ export enum TransactionType { // Closing an associated token account (e.g. SOL) CloseAssociatedTokenAccount, SingleNominatorWithdraw, + SendToken, + TrustLine, } /** diff --git a/package.json b/package.json index 210583be2b..65370ddcb9 100644 --- a/package.json +++ b/package.json @@ -120,5 +120,6 @@ }, "dependencies": { "terser": "^5.14.2" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" }