diff --git a/.ci-config/xahaud.cfg b/.ci-config/xahaud.cfg index 5d6ddcef2d..7e25d010dd 100644 --- a/.ci-config/xahaud.cfg +++ b/.ci-config/xahaud.cfg @@ -164,6 +164,23 @@ fixXahauV3 PaychanAndEscrowForTokens DeepFreeze Clawback +# 2.4.0 sync +XChainBridge +DID +fixNFTokenReserve +fixInnerObjTemplate +PriceOracle +fixPreviousTxnID +AMMClawback +Credentials +NFTokenMintOffer +MPTokensV1 +fixNFTokenPageLinks +fixEnforceNFTokenTrustline +fixReducedOffersV2 +DeepFreeze +DynamicNFT +PermissionedDomains [network_id] 21337 diff --git a/packages/xahau-binary-codec/HISTORY.md b/packages/xahau-binary-codec/HISTORY.md index a0710d4eba..f89cb4c8d4 100644 --- a/packages/xahau-binary-codec/HISTORY.md +++ b/packages/xahau-binary-codec/HISTORY.md @@ -2,6 +2,8 @@ ## Unreleased +### Added +* Support for the AMMClawback amendment (XLS-73) ## 2.1.0 (2024-06-03) ### Added diff --git a/packages/xahau-binary-codec/src/enums/definitions.json b/packages/xahau-binary-codec/src/enums/definitions.json index e529ab5d86..8dcf647b8b 100644 --- a/packages/xahau-binary-codec/src/enums/definitions.json +++ b/packages/xahau-binary-codec/src/enums/definitions.json @@ -21,6 +21,8 @@ "UInt192": 21, "UInt384": 22, "UInt512": 23, + "Issue": 24, + "Currency": 26, "Transaction": 10001, "LedgerEntry": 10002, "Validation": 10003, @@ -46,6 +48,7 @@ "NegativeUNL": 78, "NFTokenPage": 80, "NFTokenOffer": 55, + "AMM": 121, "URIToken": 85, "UNLReport": 82, "Any": -3, @@ -279,6 +282,26 @@ "type": "UInt16" } ], + [ + "TradingFee", + { + "nth": 5, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt16" + } + ], + [ + "DiscountedFee", + { + "nth": 6, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt16" + } + ], [ "Version", { @@ -1269,6 +1292,16 @@ "type": "Hash256" } ], + [ + "AMMID", + { + "nth": 14, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Hash256" + } + ], [ "ObjectID", { @@ -1639,6 +1672,36 @@ "type": "Amount" } ], + [ + "Amount2", + { + "nth": 11, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "BidMin", + { + "nth": 12, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "BidMax", + { + "nth": 13, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], [ "MinimumOffer", { @@ -1729,6 +1792,56 @@ "type": "Amount" } ], + [ + "LPTokenOut", + { + "nth": 25, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "LPTokenIn", + { + "nth": 26, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "EPrice", + { + "nth": 27, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "Price", + { + "nth": 28, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], + [ + "LPTokenBalance", + { + "nth": 31, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Amount" + } + ], [ "PublicKey", { @@ -2179,6 +2292,26 @@ "type": "PathSet" } ], + [ + "Asset", + { + "nth": 3, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Issue" + } + ], + [ + "Asset2", + { + "nth": 4, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Issue" + } + ], [ "TransactionMetaData", { @@ -2389,6 +2522,36 @@ "type": "STObject" } ], + [ + "VoteEntry", + { + "nth": 25, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], + [ + "AuctionSlot", + { + "nth": 26, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], + [ + "AuthAccount", + { + "nth": 27, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], [ "Remark", { @@ -2549,6 +2712,16 @@ "type": "STArray" } ], + [ + "VoteSlots", + { + "nth": 12, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "Majorities", { @@ -2599,6 +2772,16 @@ "type": "STArray" } ], + [ + "AuthAccounts", + { + "nth": 25, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "Remarks", { @@ -2719,7 +2902,7 @@ "temUNKNOWN": -264, "temSEQ_AND_TICKET": -263, "temBAD_NFTOKEN_TRANSFER_FEE": -262, - "temAMM_BAD_TOKENS": -261, + "temBAD_AMM_TOKENS": -261, "temXCHAIN_EQUAL_DOOR_ACCOUNTS": -260, "temXCHAIN_BAD_PROOF": -259, "temXCHAIN_BRIDGE_BAD_ISSUES": -258, @@ -2882,6 +3065,13 @@ "NFTokenCancelOffer": 28, "NFTokenAcceptOffer": 29, "Clawback": 30, + "AMMClawback": 31, + "AMMCreate": 35, + "AMMDeposit": 36, + "AMMWithdraw": 37, + "AMMVote": 38, + "AMMBid": 39, + "AMMDelete": 40, "URITokenMint": 45, "URITokenBurn": 46, "URITokenBuy": 47, diff --git a/packages/xahau-binary-codec/test/signing-data-encoding.test.ts b/packages/xahau-binary-codec/test/signing-data-encoding.test.ts index 421fda0613..482fcd8158 100644 --- a/packages/xahau-binary-codec/test/signing-data-encoding.test.ts +++ b/packages/xahau-binary-codec/test/signing-data-encoding.test.ts @@ -73,7 +73,7 @@ describe('Signing data', function () { const customPaymentDefinitions = JSON.parse( JSON.stringify(normalDefinitions), ) - customPaymentDefinitions.TRANSACTION_TYPES.Payment = 31 + customPaymentDefinitions.TRANSACTION_TYPES.Payment = 200 const newDefs = new XrplDefinitions(customPaymentDefinitions) const actual = encodeForSigning(tx_json, newDefs) @@ -82,7 +82,7 @@ describe('Signing data', function () { '53545800', // signingPrefix // TransactionType '12', - '001F', + '00C8', // Flags '22', '80000000', @@ -176,7 +176,7 @@ describe('Signing data', function () { const customPaymentDefinitions = JSON.parse( JSON.stringify(normalDefinitions), ) - customPaymentDefinitions.TRANSACTION_TYPES.Payment = 31 + customPaymentDefinitions.TRANSACTION_TYPES.Payment = 200 const newDefs = new XrplDefinitions(customPaymentDefinitions) const signingAccount = 'rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN' @@ -187,7 +187,7 @@ describe('Signing data', function () { '534D5400', // signingPrefix // TransactionType '12', - '001F', + '00C8', // Flags '22', '80000000', diff --git a/packages/xahau/HISTORY.md b/packages/xahau/HISTORY.md index cfdb5960e8..b829902502 100644 --- a/packages/xahau/HISTORY.md +++ b/packages/xahau/HISTORY.md @@ -12,6 +12,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Added * parseTransactionFlags as a utility function in the xrpl package to streamline transactions flags-to-map conversion +* Support for the AMMClawback amendment (XLS-73) * Support for XLS-77d Deep-Freeze amendment ### Fixed diff --git a/packages/xahau/src/models/ledger/AMM.ts b/packages/xahau/src/models/ledger/AMM.ts new file mode 100644 index 0000000000..5db38227a5 --- /dev/null +++ b/packages/xahau/src/models/ledger/AMM.ts @@ -0,0 +1,78 @@ +import { AuthAccount, Currency, IssuedCurrencyAmount } from '../common' + +import { BaseLedgerEntry, HasOptionalPreviousTxnID } from './BaseLedgerEntry' + +export interface VoteSlot { + VoteEntry: { + Account: string + TradingFee: number + VoteWeight: number + } +} + +/** + * The AMM object type describes a single Automated Market Maker (AMM) instance. + * + * @category Ledger Entries + */ +export default interface AMM extends BaseLedgerEntry, HasOptionalPreviousTxnID { + LedgerEntryType: 'AMM' + /** + * The address of the special account that holds this AMM's assets. + */ + Account: string + /** + * The definition for one of the two assets this AMM holds. + */ + Asset: Currency + /** + * The definition for the other asset this AMM holds. + */ + Asset2: Currency + /** + * Details of the current owner of the auction slot. + */ + AuctionSlot?: { + /** + * The current owner of this auction slot. + */ + Account: string + /** + * A list of at most 4 additional accounts that are authorized to trade at the discounted fee for this AMM instance. + */ + AuthAccounts?: AuthAccount[] + /** + * The trading fee to be charged to the auction owner, in the same format as TradingFee. + * By default this is 0, meaning that the auction owner can trade at no fee instead of the standard fee for this AMM. + */ + DiscountedFee: number + /** + * The time when this slot expires, in seconds since the Ripple Epoch. + */ + Expiration: number + /** + * The amount the auction owner paid to win this slot, in LP Tokens. + */ + Price: IssuedCurrencyAmount + } + /** + * The total outstanding balance of liquidity provider tokens from this AMM instance. + * The holders of these tokens can vote on the AMM's trading fee in proportion to their holdings, + * or redeem the tokens for a share of the AMM's assets which grows with the trading fees collected. + */ + LPTokenBalance: IssuedCurrencyAmount + /** + * The percentage fee to be charged for trades against this AMM instance, in units of 1/100,000. + * The maximum value is 1000, for a 1% fee. + */ + TradingFee: number + /** + * A list of vote objects, representing votes on the pool's trading fee. + */ + VoteSlots?: VoteSlot[] + /** + * A bit-map of boolean flags. No flags are defined for the AMM object + * type, so this value is always 0. + */ + Flags: 0 +} diff --git a/packages/xahau/src/models/ledger/AccountRoot.ts b/packages/xahau/src/models/ledger/AccountRoot.ts index c7547295f0..13cd3c7305 100644 --- a/packages/xahau/src/models/ledger/AccountRoot.ts +++ b/packages/xahau/src/models/ledger/AccountRoot.ts @@ -28,6 +28,12 @@ export default interface AccountRoot extends BaseLedgerEntry, HasPreviousTxnID { * `asfAccountTxnID` flag enabled. */ AccountTxnID?: string + /** + * The ledger entry ID of the corresponding AMM ledger entry. + * Set during account creation; cannot be modified. + * If present, indicates that this is a special AMM AccountRoot; always omitted on non-AMM accounts. + */ + AMMID?: string /** * A domain associated with this account. In JSON, this is the hexadecimal * for the ASCII representation of the domain. diff --git a/packages/xahau/src/models/ledger/LedgerEntry.ts b/packages/xahau/src/models/ledger/LedgerEntry.ts index b804b1aa57..a9c6f87122 100644 --- a/packages/xahau/src/models/ledger/LedgerEntry.ts +++ b/packages/xahau/src/models/ledger/LedgerEntry.ts @@ -1,5 +1,6 @@ import AccountRoot from './AccountRoot' import Amendments from './Amendments' +import AMM from './AMM' import Check from './Check' import Cron from './Cron' import DepositPreauth from './DepositPreauth' @@ -24,6 +25,7 @@ import URIToken from './URIToken' type LedgerEntry = | AccountRoot | Amendments + | AMM | Cron | Check | DepositPreauth @@ -47,6 +49,7 @@ type LedgerEntry = type LedgerEntryFilter = | 'account' + | 'amm' | 'amendments' | 'cron' | 'check' diff --git a/packages/xahau/src/models/ledger/index.ts b/packages/xahau/src/models/ledger/index.ts index 31b928e28f..4c43a63cc0 100644 --- a/packages/xahau/src/models/ledger/index.ts +++ b/packages/xahau/src/models/ledger/index.ts @@ -3,6 +3,7 @@ import AccountRoot, { AccountRootFlagsInterface, } from './AccountRoot' import Amendments, { Majority, AMENDMENTS_ID } from './Amendments' +import AMM, { VoteSlot } from './AMM' import Check from './Check' import Cron from './Cron' import DepositPreauth from './DepositPreauth' @@ -34,6 +35,8 @@ export { AccountRoot, AccountRootFlags, AccountRootFlagsInterface, + AMM, + VoteSlot, AMENDMENTS_ID, Amendments, Check, diff --git a/packages/xahau/src/models/methods/ammInfo.ts b/packages/xahau/src/models/methods/ammInfo.ts new file mode 100644 index 0000000000..3f503e6e79 --- /dev/null +++ b/packages/xahau/src/models/methods/ammInfo.ts @@ -0,0 +1,150 @@ +import { Amount, Currency, IssuedCurrencyAmount } from '../common' + +import { BaseRequest, BaseResponse } from './baseMethod' + +/** + * The `amm_info` method gets information about an Automated Market Maker (AMM) instance. + * Returns an {@link AMMInfoResponse}. + * + * @category Requests + */ +export interface AMMInfoRequest extends BaseRequest { + command: 'amm_info' + + /** + * The address of the AMM Account to look up. + */ + amm_account?: string + + /** + * One of the assets of the AMM pool to look up. + */ + asset?: Currency + + /** + * The other asset of the AMM pool. + */ + asset2?: Currency +} + +/** + * Response expected from an {@link AMMInfoRequest}. + * + * @category Responses + */ +export interface AMMInfoResponse extends BaseResponse { + result: { + amm: { + /** + * The address of the AMM Account. + */ + account: string + + /** + * The total amount of one asset in the AMM's pool. + * (Note: This could be asset or asset2 from the request) + */ + amount: Amount + + /** + * The total amount of the other asset in the AMM's pool. + * (Note: This could be asset or asset2 from the request) + */ + amount2: Amount + + /** + * (Omitted for XRP) If true, the amount currency is currently frozen for asset. + */ + asset_frozen?: boolean + + /** + * (Omitted for XRP) If true, the amount currency is currently frozen for asset2. + */ + asset2_frozen?: boolean + + /** + * (May be omitted) An Auction Slot Object describing the current auction slot holder, if there is one. + */ + auction_slot?: { + /** + * The address of the account that owns the auction slot. + */ + account: string + + /** + * A list of additional accounts that the auction slot holder has designated as being eligible + * of the discounted trading fee. + * Each member of this array is an object with one field, account, containing the address of the designated account. + */ + auth_accounts: Array<{ + account: string + }> + + /** + * The discounted trading fee that applies to the auction slot holder, and any eligible accounts + * when trading against this AMM. + * This is always 0. + */ + discounted_fee: number + + /** + * The ISO 8601 UTC timestamp after which this auction slot expires. + * After expired, the auction slot does not apply (but the data can remain in the ledger + * until another transaction replaces it or cleans it up). + */ + expiration: string + + /** + * The amount, in LP Tokens, that the auction slot holder paid to win the auction slot. + * This affects the price to outbid the current slot holder. + */ + price: IssuedCurrencyAmount + + /** + * The current 72-minute time interval this auction slot is in, from 0 to 19. + * The auction slot expires after 24 hours (20 intervals of 72 minutes) + * and affects the cost to outbid the current holder and how much the current holder is refunded if someone outbids them. + */ + time_interval: number + } + + /** + * The total amount of this AMM's LP Tokens outstanding. + */ + lp_token: IssuedCurrencyAmount + + /** + * The AMM's current trading fee, in units of 1/100,000; a value of 1 is equivalent to a 0.001% fee. + */ + trading_fee: number + + /** + * (May be omitted) The current votes for the AMM's trading fee, as Vote Slot Objects. + */ + vote_slots?: Array<{ + account: string + trading_fee: number + vote_weight: number + }> + } + + /** + * The identifying hash of the ledger that was used to generate this + * response. + */ + ledger_hash?: string + + /** + * The ledger index of the ledger version that was used to generate this + * response. + */ + ledger_index?: number + + /** + * If included and set to true, the information in this response comes from + * a validated ledger version. Otherwise, the information is subject to + * change. + */ + validated?: boolean + } +} diff --git a/packages/xahau/src/models/methods/index.ts b/packages/xahau/src/models/methods/index.ts index 78ee6060da..df68d1c414 100644 --- a/packages/xahau/src/models/methods/index.ts +++ b/packages/xahau/src/models/methods/index.ts @@ -47,6 +47,7 @@ import { AccountTxVersionResponseMap, AccountTxTransaction, } from './accountTx' +import { AMMInfoRequest, AMMInfoResponse } from './ammInfo' import { BaseRequest, BaseResponse, @@ -181,6 +182,8 @@ type Request = | AccountTxRequest | GatewayBalancesRequest | NoRippleCheckRequest + // amm methods + | AMMInfoRequest // ledger methods | LedgerRequest | LedgerClosedRequest @@ -259,6 +262,8 @@ type Response = // utility methods | PingResponse | RandomResponse + // AMM methods + | AMMInfoResponse export type RequestResponseMap< T, @@ -395,6 +400,9 @@ export type RequestResponseMap< ? PingResponse : T extends RandomRequest ? RandomResponse + : // AMM methods + T extends AMMInfoRequest + ? AMMInfoResponse : Response export type MarkerRequest = Request & { @@ -557,4 +565,7 @@ export { RandomRequest, RandomResponse, ErrorResponse, + // AMM methods + AMMInfoRequest, + AMMInfoResponse, } diff --git a/packages/xahau/src/models/methods/ledgerEntry.ts b/packages/xahau/src/models/methods/ledgerEntry.ts index c3ebfc368c..a62d077ade 100644 --- a/packages/xahau/src/models/methods/ledgerEntry.ts +++ b/packages/xahau/src/models/methods/ledgerEntry.ts @@ -20,6 +20,21 @@ import { BaseRequest, BaseResponse, LookupByLedgerRequest } from './baseMethod' */ export interface LedgerEntryRequest extends BaseRequest, LookupByLedgerRequest { command: 'ledger_entry' + /** + * Retrieve an Automated Market Maker (AMM) object from the ledger. + * This is similar to amm_info method, but the ledger_entry version returns only the ledger entry as stored. + */ + amm?: { + asset: { + currency: string + issuer?: string + } + asset2: { + currency: string + issuer?: string + } + } + /** * (Optional) If set to true and the queried object has been deleted, * return its complete data prior to its deletion. diff --git a/packages/xahau/src/models/transactions/AMMBid.ts b/packages/xahau/src/models/transactions/AMMBid.ts new file mode 100644 index 0000000000..046aeacceb --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMBid.ts @@ -0,0 +1,139 @@ +import { ValidationError } from '../../errors' +import { AuthAccount, Currency, IssuedCurrencyAmount } from '../common' + +import { + BaseTransaction, + isAmount, + isCurrency, + validateBaseTransaction, +} from './common' + +const MAX_AUTH_ACCOUNTS = 4 + +/** + * Bid on an Automated Market Maker's (AMM's) auction slot. + * + * If you win, you can trade against the AMM at a discounted fee until you are outbid or 24 hours have passed. + * If you are outbid before 24 hours have passed, you are refunded part of the cost of your bid based on how much time remains. + * You bid using the AMM's LP Tokens; the amount of a winning bid is returned to the AMM, + * decreasing the outstanding balance of LP Tokens. + */ +export interface AMMBid extends BaseTransaction { + TransactionType: 'AMMBid' + + /** + * The definition for one of the assets in the AMM's pool. + */ + Asset: Currency + + /** + * The definition for the other asset in the AMM's pool. + */ + Asset2: Currency + + /** + * Pay at least this LPToken amount for the slot. + * Setting this value higher makes it harder for others to outbid you. + * If omitted, pay the minimum necessary to win the bid. + */ + BidMin?: IssuedCurrencyAmount + + /** + * Pay at most this LPToken amount for the slot. + * If the cost to win the bid is higher than this amount, the transaction fails. + * If omitted, pay as much as necessary to win the bid. + */ + BidMax?: IssuedCurrencyAmount + + /** + * A list of up to 4 additional accounts that you allow to trade at the discounted fee. + * This cannot include the address of the transaction sender. + */ + AuthAccounts?: AuthAccount[] +} + +/** + * Verify the form and type of an AMMBid at runtime. + * + * @param tx - An AMMBid Transaction. + * @throws When the AMMBid is Malformed. + */ +export function validateAMMBid(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Asset == null) { + throw new ValidationError('AMMBid: missing field Asset') + } + + if (!isCurrency(tx.Asset)) { + throw new ValidationError('AMMBid: Asset must be a Currency') + } + + if (tx.Asset2 == null) { + throw new ValidationError('AMMBid: missing field Asset2') + } + + if (!isCurrency(tx.Asset2)) { + throw new ValidationError('AMMBid: Asset2 must be a Currency') + } + + if (tx.BidMin != null && !isAmount(tx.BidMin)) { + throw new ValidationError('AMMBid: BidMin must be an Amount') + } + + if (tx.BidMax != null && !isAmount(tx.BidMax)) { + throw new ValidationError('AMMBid: BidMax must be an Amount') + } + + if (tx.AuthAccounts != null) { + if (!Array.isArray(tx.AuthAccounts)) { + throw new ValidationError( + `AMMBid: AuthAccounts must be an AuthAccount array`, + ) + } + if (tx.AuthAccounts.length > MAX_AUTH_ACCOUNTS) { + throw new ValidationError( + `AMMBid: AuthAccounts length must not be greater than ${MAX_AUTH_ACCOUNTS}`, + ) + } + validateAuthAccounts( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Only used by JS + tx.Account as string, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Only used by JS + tx.AuthAccounts as Array>, + ) + } +} + +function validateAuthAccounts( + senderAddress: string, + authAccounts: Array>, +): boolean { + for (const authAccount of authAccounts) { + if ( + authAccount.AuthAccount == null || + typeof authAccount.AuthAccount !== 'object' + ) { + throw new ValidationError(`AMMBid: invalid AuthAccounts`) + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- used for null check + // @ts-expect-error -- used for null check + if (authAccount.AuthAccount.Account == null) { + throw new ValidationError(`AMMBid: invalid AuthAccounts`) + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- used for null check + // @ts-expect-error -- used for null check + if (typeof authAccount.AuthAccount.Account !== 'string') { + throw new ValidationError(`AMMBid: invalid AuthAccounts`) + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- used for null check + // @ts-expect-error -- used for null check + if (authAccount.AuthAccount.Account === senderAddress) { + throw new ValidationError( + `AMMBid: AuthAccounts must not include sender's address`, + ) + } + } + + return true +} diff --git a/packages/xahau/src/models/transactions/AMMClawback.ts b/packages/xahau/src/models/transactions/AMMClawback.ts new file mode 100644 index 0000000000..7ef4b2ca85 --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMClawback.ts @@ -0,0 +1,120 @@ +import { ValidationError } from '../../errors' +import { Currency, IssuedCurrency, IssuedCurrencyAmount } from '../common' + +import { + Account, + BaseTransaction, + GlobalFlags, + isAccount, + isAmount, + isCurrency, + validateBaseTransaction, + validateOptionalField, + validateRequiredField, +} from './common' + +/** + * Enum representing values for AMMClawback Transaction Flags. + * + * @category Transaction Flags + */ +export enum AMMClawbackFlags { + tfClawTwoAssets = 0x00000001, +} + +/** + * Map of flags to boolean values representing {@link AMMClawback} transaction + * flags. + * + * @category Transaction Flags + */ +export interface AMMClawbackFlagsInterface extends GlobalFlags { + tfClawTwoAssets?: boolean +} + +/** + * Claw back tokens from a holder that has deposited your issued tokens into an AMM pool. + * + * Clawback is disabled by default. To use clawback, you must send an AccountSet transaction to enable the + * Allow Trust Line Clawback setting. An issuer with any existing tokens cannot enable clawback. You can + * only enable Allow Trust Line Clawback if you have a completely empty owner directory, meaning you must + * do so before you set up any trust lines, offers, escrows, payment channels, checks, or signer lists. + * After you enable clawback, it cannot reverted: the account permanently gains the ability to claw back + * issued assets on trust lines. + */ +export interface AMMClawback extends BaseTransaction { + TransactionType: 'AMMClawback' + + /** + * The account holding the asset to be clawed back. + */ + Holder: Account + + /** + * Specifies the asset that the issuer wants to claw back from the AMM pool. + * In JSON, this is an object with currency and issuer fields. The issuer field must match with Account. + */ + Asset: IssuedCurrency + + /** + * Specifies the other asset in the AMM's pool. In JSON, this is an object with currency and + * issuer fields (omit issuer for XRP). + */ + Asset2: Currency + + /** + * The maximum amount to claw back from the AMM account. The currency and issuer subfields should match + * the Asset subfields. If this field isn't specified, or the value subfield exceeds the holder's available + * tokens in the AMM, all of the holder's tokens will be clawed back. + */ + Amount?: IssuedCurrencyAmount +} + +/** + * Verify the form and type of an AMMClawback at runtime. + * + * @param tx - An AMMClawback Transaction. + * @throws {ValidationError} When the transaction is malformed. + */ +export function validateAMMClawback(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'Holder', isAccount) + + validateRequiredField(tx, 'Asset', isCurrency) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- required + const asset = tx.Asset as IssuedCurrency + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- required + const amount = tx.Amount as IssuedCurrencyAmount + + if (tx.Holder === asset.issuer) { + throw new ValidationError( + 'AMMClawback: Holder and Asset.issuer must be distinct', + ) + } + + if (tx.Account !== asset.issuer) { + throw new ValidationError( + 'AMMClawback: Account must be the same as Asset.issuer', + ) + } + + validateRequiredField(tx, 'Asset2', isCurrency) + + validateOptionalField(tx, 'Amount', isAmount) + + if (tx.Amount != null) { + if (amount.currency !== asset.currency) { + throw new ValidationError( + 'AMMClawback: Amount.currency must match Asset.currency', + ) + } + + if (amount.issuer !== asset.issuer) { + throw new ValidationError( + 'AMMClawback: Amount.issuer must match Amount.issuer', + ) + } + } +} diff --git a/packages/xahau/src/models/transactions/AMMCreate.ts b/packages/xahau/src/models/transactions/AMMCreate.ts new file mode 100644 index 0000000000..8924fa75ff --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMCreate.ts @@ -0,0 +1,80 @@ +import { ValidationError } from '../../errors' +import { Amount } from '../common' + +import { BaseTransaction, isAmount, validateBaseTransaction } from './common' + +export const AMM_MAX_TRADING_FEE = 1000 + +/** + * Create a new Automated Market Maker (AMM) instance for trading a pair of assets (fungible tokens or XRP). + * + * Creates both an AMM object and a special AccountRoot object to represent the AMM. + * Also transfers ownership of the starting balance of both assets from the sender to the created AccountRoot + * and issues an initial balance of liquidity provider tokens (LP Tokens) from the AMM account to the sender. + * + * CAUTION: When you create the AMM, you should fund it with (approximately) equal-value amounts of each asset. + * Otherwise, other users can profit at your expense by trading with this AMM (performing arbitrage). + * The currency risk that liquidity providers take on increases with the volatility (potential for imbalance) of the asset pair. + * The higher the trading fee, the more it offsets this risk, + * so it's best to set the trading fee based on the volatility of the asset pair. + */ +export interface AMMCreate extends BaseTransaction { + TransactionType: 'AMMCreate' + + /** + * The first of the two assets to fund this AMM with. This must be a positive amount. + */ + Amount: Amount + + /** + * The second of the two assets to fund this AMM with. This must be a positive amount. + */ + Amount2: Amount + + /** + * The fee to charge for trades against this AMM instance, in units of 1/100,000; a value of 1 is equivalent to 0.001%. + * The maximum value is 1000, indicating a 1% fee. + * The minimum value is 0. + */ + TradingFee: number +} + +/** + * Verify the form and type of an AMMCreate at runtime. + * + * @param tx - An AMMCreate Transaction. + * @throws When the AMMCreate is Malformed. + */ +export function validateAMMCreate(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Amount == null) { + throw new ValidationError('AMMCreate: missing field Amount') + } + + if (!isAmount(tx.Amount)) { + throw new ValidationError('AMMCreate: Amount must be an Amount') + } + + if (tx.Amount2 == null) { + throw new ValidationError('AMMCreate: missing field Amount2') + } + + if (!isAmount(tx.Amount2)) { + throw new ValidationError('AMMCreate: Amount2 must be an Amount') + } + + if (tx.TradingFee == null) { + throw new ValidationError('AMMCreate: missing field TradingFee') + } + + if (typeof tx.TradingFee !== 'number') { + throw new ValidationError('AMMCreate: TradingFee must be a number') + } + + if (tx.TradingFee < 0 || tx.TradingFee > AMM_MAX_TRADING_FEE) { + throw new ValidationError( + `AMMCreate: TradingFee must be between 0 and ${AMM_MAX_TRADING_FEE}`, + ) + } +} diff --git a/packages/xahau/src/models/transactions/AMMDelete.ts b/packages/xahau/src/models/transactions/AMMDelete.ts new file mode 100644 index 0000000000..6e64f8c58b --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMDelete.ts @@ -0,0 +1,55 @@ +import { ValidationError } from '../../errors' +import { Currency } from '../common' + +import { BaseTransaction, isCurrency, validateBaseTransaction } from './common' + +/** + * Delete an empty Automated Market Maker (AMM) instance that could not be fully deleted automatically. + * + * Tip: The AMMWithdraw transaction automatically tries to delete an AMM, along with associated ledger + * entries such as empty trust lines, if it withdrew all the assets from the AMM's pool. + * However, if there are too many trust lines to the AMM account to remove in one transaction, + * it may stop before fully removing the AMM. Similarly, an AMMDelete transaction removes up to + * a maximum number of trust lines; in extreme cases, it may take several AMMDelete transactions + * to fully delete the trust lines and the associated AMM. + * In all cases, the AMM ledger entry and AMM account are deleted by the last such transaction. + */ +export interface AMMDelete extends BaseTransaction { + TransactionType: 'AMMDelete' + + /** + * The definition for one of the assets in the AMM's pool. + */ + Asset: Currency + + /** + * The definition for the other asset in the AMM's pool. + */ + Asset2: Currency +} + +/** + * Verify the form and type of an AMMDelete at runtime. + * + * @param tx - An AMMDelete Transaction. + * @throws When the AMMDelete is Malformed. + */ +export function validateAMMDelete(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Asset == null) { + throw new ValidationError('AMMDelete: missing field Asset') + } + + if (!isCurrency(tx.Asset)) { + throw new ValidationError('AMMDelete: Asset must be a Currency') + } + + if (tx.Asset2 == null) { + throw new ValidationError('AMMDelete: missing field Asset2') + } + + if (!isCurrency(tx.Asset2)) { + throw new ValidationError('AMMDelete: Asset2 must be a Currency') + } +} diff --git a/packages/xahau/src/models/transactions/AMMDeposit.ts b/packages/xahau/src/models/transactions/AMMDeposit.ts new file mode 100644 index 0000000000..2dd8d27e39 --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMDeposit.ts @@ -0,0 +1,131 @@ +import { ValidationError } from '../../errors' +import { Amount, Currency, IssuedCurrencyAmount } from '../common' + +import { + BaseTransaction, + GlobalFlags, + isAmount, + isCurrency, + isIssuedCurrency, + validateBaseTransaction, +} from './common' + +/** + * Enum representing values for AMMDeposit Transaction Flags. + * + * @category Transaction Flags + */ +export enum AMMDepositFlags { + tfLPToken = 0x00010000, + tfSingleAsset = 0x00080000, + tfTwoAsset = 0x00100000, + tfOneAssetLPToken = 0x00200000, + tfLimitLPToken = 0x00400000, + tfTwoAssetIfEmpty = 0x00800000, +} + +export interface AMMDepositFlagsInterface extends GlobalFlags { + tfLPToken?: boolean + tfSingleAsset?: boolean + tfTwoAsset?: boolean + tfOneAssetLPToken?: boolean + tfLimitLPToken?: boolean + tfTwoAssetIfEmpty?: boolean +} + +/** + * Deposit funds into an Automated Market Maker (AMM) instance + * and receive the AMM's liquidity provider tokens (LP Tokens) in exchange. + * + * You can deposit one or both of the assets in the AMM's pool. + * If successful, this transaction creates a trust line to the AMM Account (limit 0) to hold the LP Tokens. + */ +export interface AMMDeposit extends BaseTransaction { + TransactionType: 'AMMDeposit' + + /** + * The definition for one of the assets in the AMM's pool. + */ + Asset: Currency + + /** + * The definition for the other asset in the AMM's pool. + */ + Asset2: Currency + + /** + * The amount of one asset to deposit to the AMM. + * If present, this must match the type of one of the assets (tokens or XRP) in the AMM's pool. + */ + Amount?: Amount + + /** + * The amount of another asset to add to the AMM. + * If present, this must match the type of the other asset in the AMM's pool and cannot be the same asset as Amount. + */ + Amount2?: Amount + + /** + * The maximum effective price, in the deposit asset, to pay for each LP Token received. + */ + EPrice?: Amount + + /** + * How many of the AMM's LP Tokens to buy. + */ + LPTokenOut?: IssuedCurrencyAmount +} + +/** + * Verify the form and type of an AMMDeposit at runtime. + * + * @param tx - An AMMDeposit Transaction. + * @throws When the AMMDeposit is Malformed. + */ +export function validateAMMDeposit(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Asset == null) { + throw new ValidationError('AMMDeposit: missing field Asset') + } + + if (!isCurrency(tx.Asset)) { + throw new ValidationError('AMMDeposit: Asset must be a Currency') + } + + if (tx.Asset2 == null) { + throw new ValidationError('AMMDeposit: missing field Asset2') + } + + if (!isCurrency(tx.Asset2)) { + throw new ValidationError('AMMDeposit: Asset2 must be a Currency') + } + + if (tx.Amount2 != null && tx.Amount == null) { + throw new ValidationError('AMMDeposit: must set Amount with Amount2') + } else if (tx.EPrice != null && tx.Amount == null) { + throw new ValidationError('AMMDeposit: must set Amount with EPrice') + } else if (tx.LPTokenOut == null && tx.Amount == null) { + throw new ValidationError( + 'AMMDeposit: must set at least LPTokenOut or Amount', + ) + } + + if (tx.LPTokenOut != null && !isIssuedCurrency(tx.LPTokenOut)) { + throw new ValidationError( + 'AMMDeposit: LPTokenOut must be an IssuedCurrencyAmount', + ) + } + + if (tx.Amount != null && !isAmount(tx.Amount)) { + throw new ValidationError('AMMDeposit: Amount must be an Amount') + } + + if (tx.Amount2 != null && !isAmount(tx.Amount2)) { + throw new ValidationError('AMMDeposit: Amount2 must be an Amount') + } + + if (tx.EPrice != null && !isAmount(tx.EPrice)) { + throw new ValidationError('AMMDeposit: EPrice must be an Amount') + } +} diff --git a/packages/xahau/src/models/transactions/AMMVote.ts b/packages/xahau/src/models/transactions/AMMVote.ts new file mode 100644 index 0000000000..0d469fa0a8 --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMVote.ts @@ -0,0 +1,71 @@ +import { ValidationError } from '../../errors' +import { Currency } from '../common' + +import { AMM_MAX_TRADING_FEE } from './AMMCreate' +import { BaseTransaction, isCurrency, validateBaseTransaction } from './common' + +/** + * Vote on the trading fee for an Automated Market Maker (AMM) instance. + * + * Up to 8 accounts can vote in proportion to the amount of the AMM's LP Tokens they hold. + * Each new vote re-calculates the AMM's trading fee based on a weighted average of the votes. + */ +export interface AMMVote extends BaseTransaction { + TransactionType: 'AMMVote' + + /** + * The definition for one of the assets in the AMM's pool. + */ + Asset: Currency + + /** + * The definition for the other asset in the AMM's pool. + */ + Asset2: Currency + + /** + * The proposed fee to vote for, in units of 1/100,000; a value of 1 is equivalent to 0.001%. + * The maximum value is 1000, indicating a 1% fee. + */ + TradingFee: number +} + +/** + * Verify the form and type of an AMMVote at runtime. + * + * @param tx - An AMMVote Transaction. + * @throws When the AMMVote is Malformed. + */ +export function validateAMMVote(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Asset == null) { + throw new ValidationError('AMMVote: missing field Asset') + } + + if (!isCurrency(tx.Asset)) { + throw new ValidationError('AMMVote: Asset must be a Currency') + } + + if (tx.Asset2 == null) { + throw new ValidationError('AMMVote: missing field Asset2') + } + + if (!isCurrency(tx.Asset2)) { + throw new ValidationError('AMMVote: Asset2 must be a Currency') + } + + if (tx.TradingFee == null) { + throw new ValidationError('AMMVote: missing field TradingFee') + } + + if (typeof tx.TradingFee !== 'number') { + throw new ValidationError('AMMVote: TradingFee must be a number') + } + + if (tx.TradingFee < 0 || tx.TradingFee > AMM_MAX_TRADING_FEE) { + throw new ValidationError( + `AMMVote: TradingFee must be between 0 and ${AMM_MAX_TRADING_FEE}`, + ) + } +} diff --git a/packages/xahau/src/models/transactions/AMMWithdraw.ts b/packages/xahau/src/models/transactions/AMMWithdraw.ts new file mode 100644 index 0000000000..fcce5912b3 --- /dev/null +++ b/packages/xahau/src/models/transactions/AMMWithdraw.ts @@ -0,0 +1,125 @@ +import { ValidationError } from '../../errors' +import { Amount, Currency, IssuedCurrencyAmount } from '../common' + +import { + BaseTransaction, + GlobalFlags, + isAmount, + isCurrency, + isIssuedCurrency, + validateBaseTransaction, +} from './common' + +/** + * Enum representing values for AMMWithdrawFlags Transaction Flags. + * + * @category Transaction Flags + */ +export enum AMMWithdrawFlags { + tfLPToken = 0x00010000, + tfWithdrawAll = 0x00020000, + tfOneAssetWithdrawAll = 0x00040000, + tfSingleAsset = 0x00080000, + tfTwoAsset = 0x00100000, + tfOneAssetLPToken = 0x00200000, + tfLimitLPToken = 0x00400000, +} + +export interface AMMWithdrawFlagsInterface extends GlobalFlags { + tfLPToken?: boolean + tfWithdrawAll?: boolean + tfOneAssetWithdrawAll?: boolean + tfSingleAsset?: boolean + tfTwoAsset?: boolean + tfOneAssetLPToken?: boolean + tfLimitLPToken?: boolean +} + +/** + * Withdraw assets from an Automated Market Maker (AMM) instance by returning the AMM's liquidity provider tokens (LP Tokens). + */ +export interface AMMWithdraw extends BaseTransaction { + TransactionType: 'AMMWithdraw' + + /** + * The definition for one of the assets in the AMM's pool. + */ + Asset: Currency + + /** + * The definition for the other asset in the AMM's pool. + */ + Asset2: Currency + + /** + * The amount of one asset to withdraw from the AMM. + * This must match the type of one of the assets (tokens or XRP) in the AMM's pool. + */ + Amount?: Amount + + /** + * The amount of another asset to withdraw from the AMM. + * If present, this must match the type of the other asset in the AMM's pool and cannot be the same type as Amount. + */ + Amount2?: Amount + + /** + * The minimum effective price, in LP Token returned, to pay per unit of the asset to withdraw. + */ + EPrice?: Amount + + /** + * How many of the AMM's LP Tokens to redeem. + */ + LPTokenIn?: IssuedCurrencyAmount +} + +/** + * Verify the form and type of an AMMWithdraw at runtime. + * + * @param tx - An AMMWithdraw Transaction. + * @throws When the AMMWithdraw is Malformed. + */ +export function validateAMMWithdraw(tx: Record): void { + validateBaseTransaction(tx) + + if (tx.Asset == null) { + throw new ValidationError('AMMWithdraw: missing field Asset') + } + + if (!isCurrency(tx.Asset)) { + throw new ValidationError('AMMWithdraw: Asset must be a Currency') + } + + if (tx.Asset2 == null) { + throw new ValidationError('AMMWithdraw: missing field Asset2') + } + + if (!isCurrency(tx.Asset2)) { + throw new ValidationError('AMMWithdraw: Asset2 must be a Currency') + } + + if (tx.Amount2 != null && tx.Amount == null) { + throw new ValidationError('AMMWithdraw: must set Amount with Amount2') + } else if (tx.EPrice != null && tx.Amount == null) { + throw new ValidationError('AMMWithdraw: must set Amount with EPrice') + } + + if (tx.LPTokenIn != null && !isIssuedCurrency(tx.LPTokenIn)) { + throw new ValidationError( + 'AMMWithdraw: LPTokenIn must be an IssuedCurrencyAmount', + ) + } + + if (tx.Amount != null && !isAmount(tx.Amount)) { + throw new ValidationError('AMMWithdraw: Amount must be an Amount') + } + + if (tx.Amount2 != null && !isAmount(tx.Amount2)) { + throw new ValidationError('AMMWithdraw: Amount2 must be an Amount') + } + + if (tx.EPrice != null && !isAmount(tx.EPrice)) { + throw new ValidationError('AMMWithdraw: EPrice must be an Amount') + } +} diff --git a/packages/xahau/src/models/transactions/index.ts b/packages/xahau/src/models/transactions/index.ts index 9e96bae2c1..27d299b68f 100644 --- a/packages/xahau/src/models/transactions/index.ts +++ b/packages/xahau/src/models/transactions/index.ts @@ -13,6 +13,25 @@ export { AccountSetFlagsInterface, AccountSet, } from './accountSet' +export { AMMBid } from './AMMBid' +export { + AMMClawbackFlags, + AMMClawbackFlagsInterface, + AMMClawback, +} from './AMMClawback' +export { AMMCreate } from './AMMCreate' +export { AMMDelete } from './AMMDelete' +export { + AMMDepositFlags, + AMMDepositFlagsInterface, + AMMDeposit, +} from './AMMDeposit' +export { AMMVote } from './AMMVote' +export { + AMMWithdrawFlags, + AMMWithdrawFlagsInterface, + AMMWithdraw, +} from './AMMWithdraw' export { CheckCancel } from './checkCancel' export { CheckCash } from './checkCash' export { CheckCreate } from './checkCreate' diff --git a/packages/xahau/src/models/transactions/transaction.ts b/packages/xahau/src/models/transactions/transaction.ts index cea1b30f06..6c2d4836c8 100644 --- a/packages/xahau/src/models/transactions/transaction.ts +++ b/packages/xahau/src/models/transactions/transaction.ts @@ -1,11 +1,19 @@ +/* eslint-disable max-lines -- need to work with a lot of transactions in a switch statement */ /* eslint-disable max-lines-per-function -- need to work with a lot of Tx verifications */ import { ValidationError } from '../../errors' -import { IssuedCurrencyAmount, Memo } from '../common' +import { Memo } from '../common' import { isHex } from '../utils' import { setTransactionFlagsToNumber } from '../utils/flags' import { AccountSet, validateAccountSet } from './accountSet' +import { AMMBid, validateAMMBid } from './AMMBid' +import { AMMClawback, validateAMMClawback } from './AMMClawback' +import { AMMCreate, validateAMMCreate } from './AMMCreate' +import { AMMDelete, validateAMMDelete } from './AMMDelete' +import { AMMDeposit, validateAMMDeposit } from './AMMDeposit' +import { AMMVote, validateAMMVote } from './AMMVote' +import { AMMWithdraw, validateAMMWithdraw } from './AMMWithdraw' import { CheckCancel, validateCheckCancel } from './checkCancel' import { CheckCash, validateCheckCash } from './checkCash' import { CheckCreate, validateCheckCreate } from './checkCreate' @@ -65,6 +73,13 @@ import { URITokenMint, validateURITokenMint } from './uriTokenMint' */ export type SubmittableTransaction = | AccountSet + | AMMBid + | AMMClawback + | AMMCreate + | AMMDelete + | AMMDeposit + | AMMVote + | AMMWithdraw | CheckCancel | CheckCash | CheckCreate @@ -171,9 +186,9 @@ export function validate(transaction: Record): void { Object.keys(tx).forEach((key) => { const standard_currency_code_len = 3 - if (tx[key] && isIssuedCurrency(tx[key])) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- needed - const txCurrency = (tx[key] as IssuedCurrencyAmount).currency + const value = tx[key] + if (value && isIssuedCurrency(value)) { + const txCurrency = value.currency if ( txCurrency.length === standard_currency_code_len && @@ -193,6 +208,34 @@ export function validate(transaction: Record): void { validateAccountSet(tx) break + case 'AMMBid': + validateAMMBid(tx) + break + + case 'AMMClawback': + validateAMMClawback(tx) + break + + case 'AMMCreate': + validateAMMCreate(tx) + break + + case 'AMMDelete': + validateAMMDelete(tx) + break + + case 'AMMDeposit': + validateAMMDeposit(tx) + break + + case 'AMMVote': + validateAMMVote(tx) + break + + case 'AMMWithdraw': + validateAMMWithdraw(tx) + break + case 'CheckCancel': validateCheckCancel(tx) break diff --git a/packages/xahau/src/models/utils/flags.ts b/packages/xahau/src/models/utils/flags.ts index 99608bd9c3..b6c1965de0 100644 --- a/packages/xahau/src/models/utils/flags.ts +++ b/packages/xahau/src/models/utils/flags.ts @@ -7,6 +7,9 @@ import { AccountRootFlags, } from '../ledger/AccountRoot' import { AccountSetTfFlags } from '../transactions/accountSet' +import { AMMClawbackFlags } from '../transactions/AMMClawback' +import { AMMDepositFlags } from '../transactions/AMMDeposit' +import { AMMWithdrawFlags } from '../transactions/AMMWithdraw' import { GlobalFlags } from '../transactions/common' import { CronSetFlags } from '../transactions/cronSet' import { OfferCreateFlags } from '../transactions/offerCreate' @@ -49,6 +52,9 @@ export function parseAccountRootFlags( const txToFlag = { AccountSet: AccountSetTfFlags, + AMMClawback: AMMClawbackFlags, + AMMDeposit: AMMDepositFlags, + AMMWithdraw: AMMWithdrawFlags, OfferCreate: OfferCreateFlags, PaymentChannelClaim: PaymentChannelClaimFlags, Payment: PaymentFlags, diff --git a/packages/xahau/test/integration/requests/ammInfo.test.ts b/packages/xahau/test/integration/requests/ammInfo.test.ts new file mode 100644 index 0000000000..4f1bab4f40 --- /dev/null +++ b/packages/xahau/test/integration/requests/ammInfo.test.ts @@ -0,0 +1,45 @@ +import { assert } from 'chai' + +import { AMMInfoResponse, isValidClassicAddress } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupAMMPool, + setupClient, + teardownClient, + type TestAMMPool, + type XrplIntegrationTestContext, +} from '../setup' + +describe('AMMInfo', function () { + let testContext: XrplIntegrationTestContext + let ammPool: TestAMMPool + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + ammPool = await setupAMMPool(testContext.client) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const { asset, asset2 } = ammPool + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + const { amm } = ammInfoRes.result + + assert.ok(asset2.issuer) + + assert.isTrue(isValidClassicAddress(amm.account)) + assert.equal(amm.amount, '1250') + assert.deepEqual(amm.amount2, { + currency: asset2.currency, + // @ts-expect-error: asset2.issuer should be defined at this point + issuer: asset2.issuer, + value: '250', + }) + assert.equal(amm.trading_fee, 12) + }) +}) diff --git a/packages/xahau/test/integration/setup.ts b/packages/xahau/test/integration/setup.ts index ac24d827a6..866e8c689a 100644 --- a/packages/xahau/test/integration/setup.ts +++ b/packages/xahau/test/integration/setup.ts @@ -1,7 +1,27 @@ -import { Client, Wallet } from '../../src' +import { + AMMDeposit, + AMMDepositFlags, + Client, + IssuedCurrency, + Wallet, + XAH, +} from '../../src' import serverUrl from './serverUrl' -import { fundAccount } from './utils' +import { + createAMMPool, + fundAccount, + generateFundedWallet, + testTransaction, +} from './utils' + +export interface TestAMMPool { + issuerWallet: Wallet + lpWallet: Wallet + testWallet: Wallet + asset: XAH + asset2: IssuedCurrency +} export interface XrplIntegrationTestContext { client: Client @@ -46,3 +66,30 @@ export async function setupClient( return context }) } + +export async function setupAMMPool(client: Client): Promise { + const testAMMPool = await createAMMPool(client) + const { issuerWallet, lpWallet, asset, asset2 } = testAMMPool + + const testWallet = await generateFundedWallet(client) + + // Need to deposit (be an LP) to make bid/vote/withdraw eligible in tests for testContext.wallet + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '1000', + Flags: AMMDepositFlags.tfSingleAsset, + } + + await testTransaction(client, ammDepositTx, testWallet) + + return { + issuerWallet, + lpWallet, + testWallet, + asset, + asset2, + } +} diff --git a/packages/xahau/test/integration/transactions/ammBid.test.ts b/packages/xahau/test/integration/transactions/ammBid.test.ts new file mode 100644 index 0000000000..a39b26eaa2 --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammBid.test.ts @@ -0,0 +1,138 @@ +import { assert } from 'chai' + +import { AMMBid, AMMInfoResponse } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupAMMPool, + setupClient, + teardownClient, + type TestAMMPool, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +describe('AMMBid', function () { + let testContext: XrplIntegrationTestContext + let ammPool: TestAMMPool + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + ammPool = await setupAMMPool(testContext.client) + }) + afterAll(async () => teardownClient(testContext)) + + it('bid', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { auction_slot: preAuctionSlot, lp_token: preLPToken } = preAmm + + assert.ok(preAuctionSlot) + + const ammBidTx: AMMBid = { + TransactionType: 'AMMBid', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + } + + await testTransaction(testContext.client, ammBidTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { auction_slot, lp_token } = amm + + assert.ok(auction_slot) + + // @ts-expect-error: auction_slot should be defined at this point + const afterPriceValue = parseFloat(auction_slot.price.value) + // @ts-expect-error: preAuctionSlot should be defined at this point + const beforePriceValue = parseFloat(preAuctionSlot.price.value) + const diffPriceValue = 0.00268319257224121 + const expectedPriceValue = beforePriceValue + diffPriceValue + + const afterLPTokenValue = parseFloat(lp_token.value) + const beforeLPTokenValue = parseFloat(preLPToken.value) + const diffLPTokenValue = -0.0026831925721 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterPriceValue, expectedPriceValue) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('vote with AuthAccounts, BidMin, BidMax', async function () { + const { asset, asset2, issuerWallet, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { auction_slot: preAuctionSlot, lp_token: preLPToken } = preAmm + + assert.ok(preAuctionSlot) + + const ammBidTx: AMMBid = { + TransactionType: 'AMMBid', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + AuthAccounts: [ + { + AuthAccount: { + Account: issuerWallet.classicAddress, + }, + }, + ], + BidMin: { ...preLPToken, value: '5' }, + BidMax: { ...preLPToken, value: '10' }, + } + + await testTransaction(testContext.client, ammBidTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { auction_slot, lp_token } = amm + + assert.ok(auction_slot) + + // @ts-expect-error: auction_slot should be defined at this point + const afterPriceValue = parseFloat(auction_slot.price.value) + // @ts-expect-error: auction_slot should be defined at this point + const beforePriceValue = parseFloat(preAuctionSlot.price.value) + const diffPriceValue = 4.997316807427759 + const expectedPriceValue = beforePriceValue + diffPriceValue + + const afterLPTokenValue = parseFloat(lp_token.value) + const beforeLPTokenValue = parseFloat(preLPToken.value) + const diffLPTokenValue = -4.9974509670563 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterPriceValue, expectedPriceValue) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + // @ts-expect-error: auction_slot should be defined at this point + assert.deepEqual(auction_slot.auth_accounts, [ + { + account: issuerWallet.classicAddress, + }, + ]) + }) +}) diff --git a/packages/xahau/test/integration/transactions/ammClawback.test.ts b/packages/xahau/test/integration/transactions/ammClawback.test.ts new file mode 100644 index 0000000000..09ecd9708e --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammClawback.test.ts @@ -0,0 +1,56 @@ +import { AMMClawback, AMMDeposit, AMMDepositFlags, XAH } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { createAMMPool, testTransaction } from '../utils' + +describe('AMMClawback', function () { + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const ammPool = await createAMMPool(testContext.client, true) + const { issuerWallet } = ammPool + const holderWallet = ammPool.lpWallet + + const asset = { + currency: 'USD', + issuer: issuerWallet.classicAddress, + } + const asset2 = { + currency: 'XAH', + } as XAH + + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: holderWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: { + currency: 'USD', + issuer: issuerWallet.address, + value: '10', + }, + Flags: AMMDepositFlags.tfSingleAsset, + } + + await testTransaction(testContext.client, ammDepositTx, holderWallet) + + const ammClawback: AMMClawback = { + TransactionType: 'AMMClawback', + Account: issuerWallet.address, + Holder: holderWallet.address, + Asset: asset, + Asset2: asset2, + } + + await testTransaction(testContext.client, ammClawback, issuerWallet) + }) +}) diff --git a/packages/xahau/test/integration/transactions/ammCreate.test.ts b/packages/xahau/test/integration/transactions/ammCreate.test.ts new file mode 100644 index 0000000000..3f2b4885fd --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammCreate.test.ts @@ -0,0 +1,44 @@ +import { assert } from 'chai' + +import { AMMInfoResponse, isValidClassicAddress } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { createAMMPool } from '../utils' + +describe('AMMCreate', function () { + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const ammPool = await createAMMPool(testContext.client) + + const { asset, asset2 } = ammPool + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + const { amm } = ammInfoRes.result + + assert.ok(asset2.issuer) + + assert.isTrue(isValidClassicAddress(amm.account)) + assert.equal(amm.amount, '250') + assert.deepEqual(amm.amount2, { + currency: asset2.currency, + // @ts-expect-error: asset2.issuer should be defined at this point + issuer: asset2.issuer, + value: '250', + }) + assert.equal(amm.trading_fee, 12) + }) +}) diff --git a/packages/xahau/test/integration/transactions/ammDeposit.test.ts b/packages/xahau/test/integration/transactions/ammDeposit.test.ts new file mode 100644 index 0000000000..c51706627d --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammDeposit.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable max-statements -- necessary for readibility */ +import { assert } from 'chai' + +import { AMMDeposit, AMMDepositFlags, AMMInfoResponse } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupAMMPool, + setupClient, + teardownClient, + type TestAMMPool, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +describe('AMMDeposit', function () { + let testContext: XrplIntegrationTestContext + let ammPool: TestAMMPool + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + ammPool = await setupAMMPool(testContext.client) + }) + afterAll(async () => teardownClient(testContext)) + + it('deposit with Amount', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { amount: preAmount, amount2: preAmount2 } = preAmm + + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '1000', + Flags: AMMDepositFlags.tfSingleAsset, + } + + await testTransaction(testContext.client, ammDepositTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = 1000 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + + const afterLPToken = lp_token + const beforeLPToken = preAmm.lp_token + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = 191 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.deepEqual(afterAmount2, beforeAmount2) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('deposit with Amount and Amount2', async function () { + const { asset, asset2, issuerWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { amount: preAmount, amount2: preAmount2 } = preAmm + + assert.ok(asset2.issuer) + + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: issuerWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '100', + Amount2: { + currency: asset2.currency, + // @ts-expect-error: asset2.issuer should be defined at this point + issuer: asset2.issuer, + value: '100', + }, + Flags: AMMDepositFlags.tfTwoAsset, + } + + await testTransaction(testContext.client, ammDepositTx, issuerWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = 100 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + // @ts-expect-error: afterAmount2 should be an object + const afterAmount2Value = parseInt(afterAmount2.value, 10) + // @ts-expect-error: beforeAmount2 should be an object + const beforeAmount2Value = parseInt(beforeAmount2.value, 10) + const diffAmount2Value = 11 + const expectedAmount2Value = beforeAmount2Value + diffAmount2Value + + const afterLPToken = lp_token + const beforeLPToken = preAmm.lp_token + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = 34 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.equal(afterAmount2Value, expectedAmount2Value) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('deposit with Amount and LPTokenOut', async function () { + const { asset, asset2, issuerWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + const lptokenOut = { ...preLPToken, value: '5' } + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: issuerWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '100', + LPTokenOut: lptokenOut, + Flags: AMMDepositFlags.tfOneAssetLPToken, + } + + await testTransaction(testContext.client, ammDepositTx, issuerWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = 30 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = 5 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.deepEqual(afterAmount2, beforeAmount2) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('deposit with LPTokenOut', async function () { + const { asset, asset2, issuerWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + const lptokenOut = { ...preLPToken, value: '5' } + const ammDepositTx: AMMDeposit = { + TransactionType: 'AMMDeposit', + Account: issuerWallet.classicAddress, + Asset: asset, + Asset2: asset2, + LPTokenOut: lptokenOut, + Flags: AMMDepositFlags.tfLPToken, + } + + await testTransaction(testContext.client, ammDepositTx, issuerWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = 15 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + // @ts-expect-error: afterAmount2 should be an object + const afterAmount2Value = parseInt(afterAmount2.value, 10) + // @ts-expect-error: beforeAmount2 should be an object + const beforeAmount2Value = parseInt(beforeAmount2.value, 10) + const diffAmount2Value = 1 + const expectedAmount2Value = beforeAmount2Value + diffAmount2Value + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = 5 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.equal(afterAmount2Value, expectedAmount2Value) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) +}) diff --git a/packages/xahau/test/integration/transactions/ammVote.test.ts b/packages/xahau/test/integration/transactions/ammVote.test.ts new file mode 100644 index 0000000000..d05f1b5ac6 --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammVote.test.ts @@ -0,0 +1,90 @@ +/* eslint-disable max-statements -- necessary for readibility */ +import { assert } from 'chai' + +import { AMMInfoResponse, AMMVote } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupAMMPool, + setupClient, + teardownClient, + type TestAMMPool, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +describe('AMMVote', function () { + let testContext: XrplIntegrationTestContext + let ammPool: TestAMMPool + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + ammPool = await setupAMMPool(testContext.client) + }) + afterAll(async () => teardownClient(testContext)) + + it('vote', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + auction_slot: preAuctionSlot, + trading_fee: preTradingFee, + vote_slots: preVoteSlots, + } = preAmm + + assert.ok(preAuctionSlot) + assert.ok(preVoteSlots) + + // @ts-expect-error: preAuctionSlot should be defined at this point + const { discounted_fee: preDiscountedFee } = preAuctionSlot + + const ammVoteTx: AMMVote = { + TransactionType: 'AMMVote', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + TradingFee: 150, + } + + await testTransaction(testContext.client, ammVoteTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { auction_slot, trading_fee, vote_slots } = amm + + assert.ok(auction_slot) + + // @ts-expect-error: auction_slot should be defined at this point + const { discounted_fee } = auction_slot + + assert.ok(vote_slots) + + const afterTradingFee = trading_fee + const beforeTradingFee = preTradingFee + const diffTradingFee = 76 + const expectedTradingFee = beforeTradingFee + diffTradingFee + + const afterDiscountedFee = discounted_fee + const beforeDiscountedFee = preDiscountedFee + const diffDiscountedFee = 7 + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands -- this is valid + const expectedDiscountedFee = beforeDiscountedFee + diffDiscountedFee + + assert.equal(afterTradingFee, expectedTradingFee) + assert.equal(afterDiscountedFee, expectedDiscountedFee) + // @ts-expect-error: preVoteSlots should be defined at this point + assert.equal(vote_slots.length - preVoteSlots.length, 1) + }) +}) diff --git a/packages/xahau/test/integration/transactions/ammWithdraw.test.ts b/packages/xahau/test/integration/transactions/ammWithdraw.test.ts new file mode 100644 index 0000000000..33b4877a21 --- /dev/null +++ b/packages/xahau/test/integration/transactions/ammWithdraw.test.ts @@ -0,0 +1,297 @@ +/* eslint-disable max-statements -- necessary for readibility */ +import { assert } from 'chai' + +import { AMMInfoResponse, AMMWithdraw, AMMWithdrawFlags } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupAMMPool, + setupClient, + teardownClient, + type TestAMMPool, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +describe('AMMWithdraw', function () { + let testContext: XrplIntegrationTestContext + let ammPool: TestAMMPool + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + ammPool = await setupAMMPool(testContext.client) + }) + afterAll(async () => teardownClient(testContext)) + + it('withdraw with Amount', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + const ammWithdrawTx: AMMWithdraw = { + TransactionType: 'AMMWithdraw', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '500', + Flags: AMMWithdrawFlags.tfSingleAsset, + } + + await testTransaction(testContext.client, ammWithdrawTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = -500 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = -126 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.deepEqual(afterAmount2, beforeAmount2) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('withdraw with Amount and Amount2', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + assert.ok(asset2.issuer) + + const ammWithdrawTx: AMMWithdraw = { + TransactionType: 'AMMWithdraw', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '50', + Amount2: { + currency: asset2.currency, + // @ts-expect-error: asset2.issuer should be defined at this point + issuer: asset2.issuer, + value: '50', + }, + Flags: AMMWithdrawFlags.tfTwoAsset, + } + + await testTransaction(testContext.client, ammWithdrawTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = -50 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + // @ts-expect-error: afterAmount2 should be an object + const afterAmount2Value = parseInt(afterAmount2.value, 10) + // @ts-expect-error: beforeAmount2 should be an object + const beforeAmount2Value = parseInt(beforeAmount2.value, 10) + const diffAmount2Value = -17 + const expectedAmount2Value = beforeAmount2Value + diffAmount2Value + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = -28 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.equal(afterAmount2Value, expectedAmount2Value) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('withdraw with Amount and LPTokenIn', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + const lptokenIn = { ...preLPToken, value: '5' } + const ammWithdrawTx: AMMWithdraw = { + TransactionType: 'AMMWithdraw', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + Amount: '5', + LPTokenIn: lptokenIn, + Flags: AMMWithdrawFlags.tfOneAssetLPToken, + } + + await testTransaction(testContext.client, ammWithdrawTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = -17 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = -5 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.deepEqual(afterAmount2, beforeAmount2) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) + + it('withdraw with LPTokenIn', async function () { + const { asset, asset2, testWallet } = ammPool + + const preAmmInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm: preAmm } = preAmmInfoRes.result + const { + amount: preAmount, + amount2: preAmount2, + lp_token: preLPToken, + } = preAmm + + const lptokenIn = { ...preLPToken, value: '5' } + const ammWithdrawTx: AMMWithdraw = { + TransactionType: 'AMMWithdraw', + Account: testWallet.classicAddress, + Asset: asset, + Asset2: asset2, + LPTokenIn: lptokenIn, + Flags: AMMWithdrawFlags.tfLPToken, + } + + await testTransaction(testContext.client, ammWithdrawTx, testWallet) + + const ammInfoRes: AMMInfoResponse = await testContext.client.request({ + command: 'amm_info', + asset, + asset2, + }) + + const { amm } = ammInfoRes.result + const { amount, amount2, lp_token } = amm + + expect(typeof amount).toBe('string') + expect(typeof preAmount).toBe('string') + expect(typeof amount2).toBe('object') + expect(typeof preAmount2).toBe('object') + + // @ts-expect-error: amount should be a string + const afterAmountDrops = parseInt(amount, 10) + // @ts-expect-error: preAmount should be a string + const beforeAmountDrops = parseInt(preAmount, 10) + const diffAmountDrops = -9 + const expectedAmountDrops = beforeAmountDrops + diffAmountDrops + + const afterAmount2 = amount2 + const beforeAmount2 = preAmount2 + // @ts-expect-error: afterAmount2 should be an object + const afterAmount2Value = parseInt(afterAmount2.value, 10) + // @ts-expect-error: beforeAmount2 should be an object + const beforeAmount2Value = parseInt(beforeAmount2.value, 10) + const diffAmount2Value = -3 + const expectedAmount2Value = beforeAmount2Value + diffAmount2Value + + const afterLPToken = lp_token + const beforeLPToken = preLPToken + const afterLPTokenValue = parseInt(afterLPToken.value, 10) + const beforeLPTokenValue = parseInt(beforeLPToken.value, 10) + const diffLPTokenValue = -5 + const expectedLPTokenValue = beforeLPTokenValue + diffLPTokenValue + + assert.equal(afterAmountDrops, expectedAmountDrops) + assert.equal(afterAmount2Value, expectedAmount2Value) + assert.equal(afterLPTokenValue, expectedLPTokenValue) + }) +}) diff --git a/packages/xahau/test/integration/utils.ts b/packages/xahau/test/integration/utils.ts index cdf0255a57..a501c58982 100644 --- a/packages/xahau/test/integration/utils.ts +++ b/packages/xahau/test/integration/utils.ts @@ -13,11 +13,17 @@ import { ECDSA, AccountLinesRequest, IssuedCurrency, + XAH, } from '../../src' import { + AccountSet, + AccountSetAsfFlags, + AMMCreate, Payment, SubmittableTransaction, Transaction, + TrustSet, + TrustSetFlags, } from '../../src/models/transactions' import { hashSignedTx } from '../../src/utils/hashes' @@ -366,3 +372,88 @@ export async function getIOUBalance( } return (await client.request(request)).result.lines[0].balance } + +export async function createAMMPool( + client: Client, + enableAMMClawback = false, +): Promise<{ + issuerWallet: Wallet + lpWallet: Wallet + asset: XAH + asset2: IssuedCurrency +}> { + const lpWallet = await generateFundedWallet(client) + const issuerWallet = await generateFundedWallet(client) + const currencyCode = 'USD' + + const accountSetTx: AccountSet = { + TransactionType: 'AccountSet', + Account: issuerWallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfDefaultRipple, + } + + await testTransaction(client, accountSetTx, issuerWallet) + + if (enableAMMClawback) { + const accountSetTx2: AccountSet = { + TransactionType: 'AccountSet', + Account: issuerWallet.classicAddress, + SetFlag: AccountSetAsfFlags.asfAllowTrustLineClawback, + } + + await testTransaction(client, accountSetTx2, issuerWallet) + } + + const trustSetTx: TrustSet = { + TransactionType: 'TrustSet', + Flags: TrustSetFlags.tfClearNoRipple, + Account: lpWallet.classicAddress, + LimitAmount: { + currency: currencyCode, + issuer: issuerWallet.classicAddress, + value: '1000', + }, + } + + await testTransaction(client, trustSetTx, lpWallet) + + const paymentTx: Payment = { + TransactionType: 'Payment', + Account: issuerWallet.classicAddress, + Destination: lpWallet.classicAddress, + Amount: { + currency: currencyCode, + issuer: issuerWallet.classicAddress, + value: '500', + }, + } + + await testTransaction(client, paymentTx, issuerWallet) + + const ammCreateTx: AMMCreate = { + TransactionType: 'AMMCreate', + Account: lpWallet.classicAddress, + Amount: '250', + Amount2: { + currency: currencyCode, + issuer: issuerWallet.classicAddress, + value: '250', + }, + TradingFee: 12, + } + + await testTransaction(client, ammCreateTx, lpWallet) + + const asset: XAH = { currency: 'XAH' } + const asset2: IssuedCurrency = { + currency: currencyCode, + issuer: issuerWallet.classicAddress, + } + + return { + issuerWallet, + lpWallet, + asset, + asset2, + } +} diff --git a/packages/xahau/test/models/AMMBid.test.ts b/packages/xahau/test/models/AMMBid.test.ts new file mode 100644 index 0000000000..6d75a459e5 --- /dev/null +++ b/packages/xahau/test/models/AMMBid.test.ts @@ -0,0 +1,176 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateAMMBid } from '../../src/models/transactions/AMMBid' + +/** + * AMMBid Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMBid', function () { + let bid + + beforeEach(function () { + bid = { + TransactionType: 'AMMBid', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Asset: { + currency: 'XAH', + }, + Asset2: { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + }, + BidMin: { + currency: '039C99CD9AB0B70B32ECDA51EAAE471625608EA2', + issuer: 'rE54zDvgnghAoPopCgvtiqWNq3dU5y836S', + value: '50', + }, + BidMax: { + currency: '039C99CD9AB0B70B32ECDA51EAAE471625608EA2', + issuer: 'rE54zDvgnghAoPopCgvtiqWNq3dU5y836S', + value: '100', + }, + AuthAccounts: [ + { + AuthAccount: { + Account: 'rNZdsTBP5tH1M6GHC6bTreHAp6ouP8iZSh', + }, + }, + { + AuthAccount: { + Account: 'rfpFv97Dwu89FTyUwPjtpZBbuZxTqqgTmH', + }, + }, + { + AuthAccount: { + Account: 'rzzYHPGb8Pa64oqxCzmuffm122bitq3Vb', + }, + }, + { + AuthAccount: { + Account: 'rhwxHxaHok86fe4LykBom1jSJ3RYQJs1h4', + }, + }, + ], + Sequence: 1337, + } as any + }) + + it(`verifies valid AMMBid`, function () { + assert.doesNotThrow(() => validateAMMBid(bid)) + assert.doesNotThrow(() => validate(bid)) + }) + + it(`throws w/ missing field Asset`, function () { + delete bid.Asset + const errorMessage = 'AMMBid: missing field Asset' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ Asset must be a Currency`, function () { + bid.Asset = 1234 + const errorMessage = 'AMMBid: Asset must be a Currency' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ missing field Asset2`, function () { + delete bid.Asset2 + const errorMessage = 'AMMBid: missing field Asset2' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ Asset2 must be a Currency`, function () { + bid.Asset2 = 1234 + const errorMessage = 'AMMBid: Asset2 must be a Currency' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ BidMin must be an Amount`, function () { + bid.BidMin = 5 + const errorMessage = 'AMMBid: BidMin must be an Amount' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ BidMax must be an Amount`, function () { + bid.BidMax = 10 + const errorMessage = 'AMMBid: BidMax must be an Amount' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ AuthAccounts length must not be greater than 4`, function () { + bid.AuthAccounts.push({ + AuthAccount: { + Account: 'r3X6noRsvaLapAKCG78zAtWcbhB3sggS1s', + }, + }) + const errorMessage = + 'AMMBid: AuthAccounts length must not be greater than 4' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ AuthAccounts must be an AuthAccount array`, function () { + bid.AuthAccounts = 1234 + const errorMessage = 'AMMBid: AuthAccounts must be an AuthAccount array' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ invalid AuthAccounts when AuthAccount is null`, function () { + bid.AuthAccounts[0] = { + AuthAccount: null, + } + const errorMessage = 'AMMBid: invalid AuthAccounts' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ invalid AuthAccounts when AuthAccount is undefined`, function () { + bid.AuthAccounts[0] = { + AuthAccount: undefined, + } + const errorMessage = 'AMMBid: invalid AuthAccounts' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ invalid AuthAccounts when AuthAccount is not an object`, function () { + bid.AuthAccounts[0] = { + AuthAccount: 1234, + } + const errorMessage = 'AMMBid: invalid AuthAccounts' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ invalid AuthAccounts when AuthAccount.Account is not a string`, function () { + bid.AuthAccounts[0] = { + AuthAccount: { + Account: 1234, + }, + } + const errorMessage = 'AMMBid: invalid AuthAccounts' + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) + + it(`throws w/ AuthAccounts must not include sender's address`, function () { + bid.AuthAccounts[0] = { + AuthAccount: { + Account: bid.Account, + }, + } + const errorMessage = + "AMMBid: AuthAccounts must not include sender's address" + assert.throws(() => validateAMMBid(bid), ValidationError, errorMessage) + assert.throws(() => validate(bid), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMClawback.test.ts b/packages/xahau/test/models/AMMClawback.test.ts new file mode 100644 index 0000000000..df196b4ecb --- /dev/null +++ b/packages/xahau/test/models/AMMClawback.test.ts @@ -0,0 +1,176 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { + AMMClawbackFlags, + validateAMMClawback, +} from '../../src/models/transactions/AMMClawback' + +/** + * AMMClawback Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMClawback', function () { + let ammClawback + + beforeEach(function () { + ammClawback = { + TransactionType: 'AMMClawback', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Holder: 'rPyfep3gcLzkosKC9XiE77Y8DZWG6iWDT9', + Asset: { + currency: 'USD', + issuer: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + }, + Asset2: { + currency: 'XAH', + }, + Amount: { + currency: 'USD', + issuer: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + value: '1000', + }, + Sequence: 1337, + } as any + }) + + it(`verifies valid AMMClawback`, function () { + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`verifies valid AMMClawback without Amount`, function () { + delete ammClawback.Amount + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`verifies valid AMMClawback with tfClawTwoAssets`, function () { + ammClawback.flags = AMMClawbackFlags.tfClawTwoAssets + assert.doesNotThrow(() => validateAMMClawback(ammClawback)) + assert.doesNotThrow(() => validate(ammClawback)) + }) + + it(`throws w/ missing Holder`, function () { + delete ammClawback.Holder + const errorMessage = 'AMMClawback: missing field Holder' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Holder`, function () { + ammClawback.Holder = 1234 + const errorMessage = 'AMMClawback: invalid field Holder' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Holder and Asset.issuer must be distinct`, function () { + ammClawback.Holder = ammClawback.Asset.issuer + const errorMessage = 'AMMClawback: Holder and Asset.issuer must be distinct' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ missing Asset`, function () { + delete ammClawback.Asset + const errorMessage = 'AMMClawback: missing field Asset' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Asset`, function () { + ammClawback.Asset = '1000' + const errorMessage = 'AMMClawback: invalid field Asset' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Account must be the same as Asset.issuer`, function () { + ammClawback.Account = 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn' + const errorMessage = 'AMMClawback: Account must be the same as Asset.issuer' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ missing Asset2`, function () { + delete ammClawback.Asset2 + const errorMessage = 'AMMClawback: missing field Asset2' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Asset2`, function () { + ammClawback.Asset2 = '1000' + const errorMessage = 'AMMClawback: invalid field Asset2' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ invalid field Amount`, function () { + ammClawback.Amount = 1000 + const errorMessage = 'AMMClawback: invalid field Amount' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Amount.currency must match Asset.currency`, function () { + ammClawback.Amount.currency = 'ETH' + const errorMessage = + 'AMMClawback: Amount.currency must match Asset.currency' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) + + it(`throws w/ Amount.issuer must match Amount.issuer`, function () { + ammClawback.Amount.issuer = 'rnYgaEtpqpNRt3wxE39demVpDAA817rQEY' + const errorMessage = 'AMMClawback: Amount.issuer must match Amount.issuer' + assert.throws( + () => validateAMMClawback(ammClawback), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammClawback), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMCreate.test.ts b/packages/xahau/test/models/AMMCreate.test.ts new file mode 100644 index 0000000000..56242140ab --- /dev/null +++ b/packages/xahau/test/models/AMMCreate.test.ts @@ -0,0 +1,121 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateAMMCreate } from '../../src/models/transactions/AMMCreate' + +/** + * AMMCreate Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMCreate', function () { + let ammCreate + + beforeEach(function () { + ammCreate = { + TransactionType: 'AMMCreate', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Amount: '1000', + Amount2: { + currency: 'USD', + issuer: 'rPyfep3gcLzkosKC9XiE77Y8DZWG6iWDT9', + value: '1000', + }, + TradingFee: 12, + Sequence: 1337, + } as any + }) + + it(`verifies valid AMMCreate`, function () { + assert.doesNotThrow(() => validateAMMCreate(ammCreate)) + assert.doesNotThrow(() => validate(ammCreate)) + }) + + it(`throws w/ missing Amount`, function () { + delete ammCreate.Amount + const errorMessage = 'AMMCreate: missing field Amount' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws w/ Amount must be an Amount`, function () { + ammCreate.Amount = 1000 + const errorMessage = 'AMMCreate: Amount must be an Amount' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws w/ missing Amount2`, function () { + delete ammCreate.Amount2 + const errorMessage = 'AMMCreate: missing field Amount2' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws w/ Amount2 must be an Amount`, function () { + ammCreate.Amount2 = 1000 + const errorMessage = 'AMMCreate: Amount2 must be an Amount' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws w/ missing TradingFee`, function () { + delete ammCreate.TradingFee + const errorMessage = 'AMMCreate: missing field TradingFee' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws w/ TradingFee must be a number`, function () { + ammCreate.TradingFee = '12' + const errorMessage = 'AMMCreate: TradingFee must be a number' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws when TradingFee is greater than 1000`, function () { + ammCreate.TradingFee = 1001 + const errorMessage = 'AMMCreate: TradingFee must be between 0 and 1000' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) + + it(`throws when TradingFee is a negative number`, function () { + ammCreate.TradingFee = -1 + const errorMessage = 'AMMCreate: TradingFee must be between 0 and 1000' + assert.throws( + () => validateAMMCreate(ammCreate), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammCreate), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMDelete.test.ts b/packages/xahau/test/models/AMMDelete.test.ts new file mode 100644 index 0000000000..3935058f65 --- /dev/null +++ b/packages/xahau/test/models/AMMDelete.test.ts @@ -0,0 +1,78 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateAMMDelete } from '../../src/models/transactions/AMMDelete' + +/** + * AMMDelete Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMDelete', function () { + let ammDelete + + beforeEach(function () { + ammDelete = { + TransactionType: 'AMMDelete', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Asset: { + currency: 'XAH', + }, + Asset2: { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + }, + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid AMMDelete`, function () { + assert.doesNotThrow(() => validateAMMDelete(ammDelete)) + assert.doesNotThrow(() => validate(ammDelete)) + }) + + it(`throws w/ missing field Asset`, function () { + delete ammDelete.Asset + const errorMessage = 'AMMDelete: missing field Asset' + assert.throws( + () => validateAMMDelete(ammDelete), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammDelete), ValidationError, errorMessage) + }) + + it(`throws w/ Asset must be a Currency`, function () { + ammDelete.Asset = 1234 + const errorMessage = 'AMMDelete: Asset must be a Currency' + assert.throws( + () => validateAMMDelete(ammDelete), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammDelete), ValidationError, errorMessage) + }) + + it(`throws w/ missing field Asset2`, function () { + delete ammDelete.Asset2 + const errorMessage = 'AMMDelete: missing field Asset2' + assert.throws( + () => validateAMMDelete(ammDelete), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammDelete), ValidationError, errorMessage) + }) + + it(`throws w/ Asset2 must be a Currency`, function () { + ammDelete.Asset2 = 1234 + const errorMessage = 'AMMDelete: Asset2 must be a Currency' + assert.throws( + () => validateAMMDelete(ammDelete), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(ammDelete), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMDeposit.test.ts b/packages/xahau/test/models/AMMDeposit.test.ts new file mode 100644 index 0000000000..1ceea7eaa9 --- /dev/null +++ b/packages/xahau/test/models/AMMDeposit.test.ts @@ -0,0 +1,204 @@ +/* eslint-disable no-bitwise -- bitwise necessary for enabling flags */ +import { assert } from 'chai' + +import { AMMDepositFlags, validate, ValidationError } from '../../src' +import { validateAMMDeposit } from '../../src/models/transactions/AMMDeposit' + +/** + * AMMDeposit Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMDeposit', function () { + const LPTokenOut = { + currency: 'B3813FCAB4EE68B3D0D735D6849465A9113EE048', + issuer: 'rH438jEAzTs5PYtV6CHZqpDpwCKQmPW9Cg', + value: '1000', + } + let deposit + + beforeEach(function () { + deposit = { + TransactionType: 'AMMDeposit', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Asset: { + currency: 'XAH', + }, + Asset2: { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + }, + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid AMMDeposit with LPTokenOut`, function () { + deposit.LPTokenOut = LPTokenOut + deposit.Flags |= AMMDepositFlags.tfLPToken + assert.doesNotThrow(() => validateAMMDeposit(deposit)) + assert.doesNotThrow(() => validate(deposit)) + }) + + it(`verifies valid AMMDeposit with Amount`, function () { + deposit.Amount = '1000' + deposit.Flags |= AMMDepositFlags.tfSingleAsset + assert.doesNotThrow(() => validateAMMDeposit(deposit)) + assert.doesNotThrow(() => validate(deposit)) + }) + + it(`verifies valid AMMDeposit with Amount and Amount2`, function () { + deposit.Amount = '1000' + deposit.Amount2 = { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + value: '2.5', + } + deposit.Flags |= AMMDepositFlags.tfTwoAsset + assert.doesNotThrow(() => validateAMMDeposit(deposit)) + assert.doesNotThrow(() => validate(deposit)) + }) + + it(`verifies valid AMMDeposit with Amount and LPTokenOut`, function () { + deposit.Amount = '1000' + deposit.LPTokenOut = LPTokenOut + deposit.Flags |= AMMDepositFlags.tfOneAssetLPToken + assert.doesNotThrow(() => validateAMMDeposit(deposit)) + assert.doesNotThrow(() => validate(deposit)) + }) + + it(`verifies valid AMMDeposit with Amount and EPrice`, function () { + deposit.Amount = '1000' + deposit.EPrice = '25' + deposit.Flags |= AMMDepositFlags.tfLimitLPToken + assert.doesNotThrow(() => validateAMMDeposit(deposit)) + assert.doesNotThrow(() => validate(deposit)) + }) + + it(`throws w/ missing field Asset`, function () { + delete deposit.Asset + const errorMessage = 'AMMDeposit: missing field Asset' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ Asset must be a Currency`, function () { + deposit.Asset = 1234 + const errorMessage = 'AMMDeposit: Asset must be a Currency' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ missing field Asset2`, function () { + delete deposit.Asset2 + const errorMessage = 'AMMDeposit: missing field Asset2' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ Asset2 must be a Currency`, function () { + deposit.Asset2 = 1234 + const errorMessage = 'AMMDeposit: Asset2 must be a Currency' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ must set at least LPTokenOut or Amount`, function () { + const errorMessage = 'AMMDeposit: must set at least LPTokenOut or Amount' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ must set Amount with Amount2`, function () { + deposit.Amount2 = { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + value: '2.5', + } + const errorMessage = 'AMMDeposit: must set Amount with Amount2' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ must set Amount with EPrice`, function () { + deposit.EPrice = '25' + const errorMessage = 'AMMDeposit: must set Amount with EPrice' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ LPTokenOut must be an IssuedCurrencyAmount`, function () { + deposit.LPTokenOut = 1234 + const errorMessage = + 'AMMDeposit: LPTokenOut must be an IssuedCurrencyAmount' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ Amount must be an Amount`, function () { + deposit.Amount = 1234 + const errorMessage = 'AMMDeposit: Amount must be an Amount' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ Amount2 must be an Amount`, function () { + deposit.Amount = '1000' + deposit.Amount2 = 1234 + const errorMessage = 'AMMDeposit: Amount2 must be an Amount' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) + + it(`throws w/ EPrice must be an Amount`, function () { + deposit.Amount = '1000' + deposit.EPrice = 1234 + const errorMessage = 'AMMDeposit: EPrice must be an Amount' + assert.throws( + () => validateAMMDeposit(deposit), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(deposit), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMVote.test.ts b/packages/xahau/test/models/AMMVote.test.ts new file mode 100644 index 0000000000..3f63d53596 --- /dev/null +++ b/packages/xahau/test/models/AMMVote.test.ts @@ -0,0 +1,90 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateAMMVote } from '../../src/models/transactions/AMMVote' + +/** + * AMMVote Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMVote', function () { + let vote + + beforeEach(function () { + vote = { + TransactionType: 'AMMVote', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Asset: { + currency: 'XAH', + }, + Asset2: { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + }, + TradingFee: 25, + Sequence: 1337, + } as any + }) + + it(`verifies valid AMMVote`, function () { + assert.doesNotThrow(() => validateAMMVote(vote)) + assert.doesNotThrow(() => validate(vote)) + }) + + it(`throws w/ missing field Asset`, function () { + delete vote.Asset + const errorMessage = 'AMMVote: missing field Asset' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws w/ Asset must be a Currency`, function () { + vote.Asset = 1234 + const errorMessage = 'AMMVote: Asset must be a Currency' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws w/ missing field Asset2`, function () { + delete vote.Asset2 + const errorMessage = 'AMMVote: missing field Asset2' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws w/ Asset2 must be a Currency`, function () { + vote.Asset2 = 1234 + const errorMessage = 'AMMVote: Asset2 must be a Currency' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws w/ missing field TradingFee`, function () { + delete vote.TradingFee + const errorMessage = 'AMMVote: missing field TradingFee' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws w/ TradingFee must be a number`, function () { + vote.TradingFee = '25' + const errorMessage = 'AMMVote: TradingFee must be a number' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws when TradingFee is greater than AMM_MAX_TRADING_FEE`, function () { + vote.TradingFee = 1001 + const errorMessage = 'AMMVote: TradingFee must be between 0 and 1000' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) + + it(`throws when TradingFee is a negative number`, function () { + vote.TradingFee = -1 + const errorMessage = 'AMMVote: TradingFee must be between 0 and 1000' + assert.throws(() => validateAMMVote(vote), ValidationError, errorMessage) + assert.throws(() => validate(vote), ValidationError, errorMessage) + }) +}) diff --git a/packages/xahau/test/models/AMMWithdraw.test.ts b/packages/xahau/test/models/AMMWithdraw.test.ts new file mode 100644 index 0000000000..c355be1680 --- /dev/null +++ b/packages/xahau/test/models/AMMWithdraw.test.ts @@ -0,0 +1,207 @@ +/* eslint-disable no-bitwise -- bitwise necessary for enabling flags */ +import { assert } from 'chai' + +import { AMMWithdrawFlags, validate, ValidationError } from '../../src' +import { validateAMMWithdraw } from '../../src/models/transactions/AMMWithdraw' + +/** + * AMMWithdraw Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('AMMWithdraw', function () { + const LPTokenIn = { + currency: 'B3813FCAB4EE68B3D0D735D6849465A9113EE048', + issuer: 'rH438jEAzTs5PYtV6CHZqpDpwCKQmPW9Cg', + value: '1000', + } + let withdraw + + beforeEach(function () { + withdraw = { + TransactionType: 'AMMWithdraw', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Asset: { + currency: 'XAH', + }, + Asset2: { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + }, + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid AMMWithdraw with LPTokenIn`, function () { + withdraw.LPTokenIn = LPTokenIn + withdraw.Flags |= AMMWithdrawFlags.tfLPToken + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw with Amount`, function () { + withdraw.Amount = '1000' + withdraw.Flags |= AMMWithdrawFlags.tfSingleAsset + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw with Amount and Amount2`, function () { + withdraw.Amount = '1000' + withdraw.Amount2 = { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + value: '2.5', + } + withdraw.Flags |= AMMWithdrawFlags.tfTwoAsset + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw with Amount and LPTokenIn`, function () { + withdraw.Amount = '1000' + withdraw.LPTokenIn = LPTokenIn + withdraw.Flags |= AMMWithdrawFlags.tfOneAssetLPToken + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw with Amount and EPrice`, function () { + withdraw.Amount = '1000' + withdraw.EPrice = '25' + withdraw.Flags |= AMMWithdrawFlags.tfLimitLPToken + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw one asset withdraw all`, function () { + withdraw.Amount = '1000' + withdraw.Flags |= AMMWithdrawFlags.tfOneAssetWithdrawAll + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`verifies valid AMMWithdraw withdraw all`, function () { + withdraw.Flags |= AMMWithdrawFlags.tfWithdrawAll + assert.doesNotThrow(() => validateAMMWithdraw(withdraw)) + assert.doesNotThrow(() => validate(withdraw)) + }) + + it(`throws w/ missing field Asset`, function () { + delete withdraw.Asset + const errorMessage = 'AMMWithdraw: missing field Asset' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ Asset must be a Currency`, function () { + withdraw.Asset = 1234 + const errorMessage = 'AMMWithdraw: Asset must be a Currency' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ missing field Asset2`, function () { + delete withdraw.Asset2 + const errorMessage = 'AMMWithdraw: missing field Asset2' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ Asset2 must be a Currency`, function () { + withdraw.Asset2 = 1234 + const errorMessage = 'AMMWithdraw: Asset2 must be a Currency' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ must set Amount with Amount2`, function () { + withdraw.Amount2 = { + currency: 'ETH', + issuer: 'rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd', + value: '2.5', + } + const errorMessage = 'AMMWithdraw: must set Amount with Amount2' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ must set Amount with EPrice`, function () { + withdraw.EPrice = '25' + const errorMessage = 'AMMWithdraw: must set Amount with EPrice' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ LPTokenIn must be an IssuedCurrencyAmount`, function () { + withdraw.LPTokenIn = 1234 + const errorMessage = + 'AMMWithdraw: LPTokenIn must be an IssuedCurrencyAmount' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ Amount must be an Amount`, function () { + withdraw.Amount = 1234 + const errorMessage = 'AMMWithdraw: Amount must be an Amount' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ Amount2 must be an Amount`, function () { + withdraw.Amount = '1000' + withdraw.Amount2 = 1234 + const errorMessage = 'AMMWithdraw: Amount2 must be an Amount' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) + + it(`throws w/ EPrice must be an Amount`, function () { + withdraw.Amount = '1000' + withdraw.EPrice = 1234 + const errorMessage = 'AMMWithdraw: EPrice must be an Amount' + assert.throws( + () => validateAMMWithdraw(withdraw), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(withdraw), ValidationError, errorMessage) + }) +})