diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index f4b13336e6..291f87b730 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -170,3 +170,11 @@ fixNFTokenRemint # 2.0.0 Amendments XChainBridge DID +# 2.2.0-b3 Amendments +fixNFTokenReserve +fixInnerObjTemplate +fixAMMOverflowOffer +PriceOracle +fixEmptyDID +fixXChainRewardRounding +fixPreviousTxnID diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index d996daf125..652bef8bac 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -4,7 +4,7 @@ name: Node.js CI env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:2.0.0-b4 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:2.2.0-b3 on: push: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 787117cb2a..860c086db6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ This should be run from the `xrpl.js` top level folder (one above the `packages` ```bash npm run build # sets up the rippled standalone Docker container - you can skip this step if you already have it set up -docker run -p 6006:6006 --interactive -t --volume $PWD/.ci-config:/opt/ripple/etc/ --platform linux/amd64 rippleci/rippled:2.0.0-b3 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg +docker run -p 6006:6006 --interactive -t --volume $PWD/.ci-config:/opt/ripple/etc/ --platform linux/amd64 rippleci/rippled:2.2.0-b3 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg npm run test:browser ``` diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 871c202084..8b5df813a8 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +* Support for the Price Oracles amendment (XLS-47). + ### Fixed * Better error handling/error messages for serialization/deserialization errors. diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index b8fd9a8a1b..797be9ce21 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -23,6 +23,7 @@ "UInt512": 23, "Issue": 24, "XChainBridge": 25, + "Currency": 26, "Transaction": 10001, "LedgerEntry": 10002, "Validation": 10003, @@ -51,6 +52,7 @@ "NFTokenOffer": 55, "AMM": 121, "DID": 73, + "Oracle": 128, "Any": -3, "Child": -2, "Nickname": 110, @@ -208,6 +210,16 @@ "type": "UInt8" } ], + [ + "Scale", + { + "nth": 4, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt8" + } + ], [ "TickSize", { @@ -498,6 +510,16 @@ "type": "UInt32" } ], + [ + "LastUpdateTime", + { + "nth": 15, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "HighQualityIn", { @@ -828,6 +850,16 @@ "type": "UInt32" } ], + [ + "OracleDocumentID", + { + "nth": 51, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1028,6 +1060,16 @@ "type": "UInt64" } ], + [ + "AssetPrice", + { + "nth": 23, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1918,6 +1960,26 @@ "type": "Blob" } ], + [ + "AssetClass", + { + "nth": 28, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Blob" + } + ], + [ + "Provider", + { + "nth": 29, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Blob" + } + ], [ "Account", { @@ -2128,6 +2190,26 @@ "type": "PathSet" } ], + [ + "BaseAsset", + { + "nth": 1, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Currency" + } + ], + [ + "QuoteAsset", + { + "nth": 2, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "Currency" + } + ], [ "LockingChainIssue", { @@ -2458,6 +2540,16 @@ "type": "STObject" } ], + [ + "PriceData", + { + "nth": 32, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], [ "Signers", { @@ -2628,6 +2720,16 @@ "type": "STArray" } ], + [ + "PriceDataSeries", + { + "nth": 24, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "AuthAccounts", { @@ -2656,6 +2758,7 @@ "telWRONG_NETWORK": -386, "telREQUIRES_NETWORK_ID": -385, "telNETWORK_ID_MAKES_TX_NON_CANONICAL": -384, + "telENV_RPC_FAILED": -383, "temMALFORMED": -299, "temBAD_AMOUNT": -298, @@ -2703,6 +2806,8 @@ "temXCHAIN_BRIDGE_BAD_MIN_ACCOUNT_CREATE_AMOUNT": -256, "temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255, "temEMPTY_DID": -254, + "temARRAY_EMPTY": -253, + "temARRAY_TOO_LARGE": -252, "tefFAILURE": -199, "tefALREADY": -198, @@ -2739,7 +2844,6 @@ "terQUEUED": -89, "terPRE_TICKET": -88, "terNO_AMM": -87, - "terSUBMITTED": -86, "tesSUCCESS": 0, @@ -2815,7 +2919,11 @@ "tecXCHAIN_SELF_COMMIT": 184, "tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR": 185, "tecXCHAIN_CREATE_ACCOUNT_DISABLED": 186, - "tecEMPTY_DID": 187 + "tecEMPTY_DID": 187, + "tecINVALID_UPDATE_TIME": 188, + "tecTOKEN_PAIR_NOT_FOUND": 189, + "tecARRAY_EMPTY": 190, + "tecARRAY_TOO_LARGE": 191 }, "TRANSACTION_TYPES": { "Invalid": -1, @@ -2864,6 +2972,8 @@ "XChainCreateBridge": 48, "DIDSet": 49, "DIDDelete": 50, + "OracleSet": 51, + "OracleDelete": 52, "EnableAmendment": 100, "SetFee": 101, "UNLModify": 102 diff --git a/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json b/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json index 1f4f9616eb..fb3abf0080 100644 --- a/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json +++ b/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json @@ -4868,6 +4868,36 @@ "TxnSignature": "AACD31A04CAE14670FC483A1382F393AA96B49C84479B58067F049FBD772999325667A6AA2520A63756EE84F3657298815019DD56A1AECE796B08535C4009C08", "URI": "6469645F6578616D706C65" } + }, + { + "binary": "1200332FFFFFFFFF2033000004D2750B6469645F6578616D706C65701C0863757272656E6379701D0870726F7669646572811401476926B590BA3245F63C829116A0A3AF7F382DF018E020301700000000000001E2041003011A0000000000000000000000000000000000000000021A0000000000000000000000005553440000000000E1F1", + "json": { + "TransactionType": "OracleSet", + "Account": "rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8", + "OracleDocumentID": 1234, + "LastUpdateTime": 4294967295, + "PriceDataSeries": [ + { + "PriceData": { + "BaseAsset": "XRP", + "QuoteAsset": "USD", + "AssetPrice": "00000000000001E2", + "Scale": 3 + } + } + ], + "Provider": "70726F7669646572", + "URI": "6469645F6578616D706C65", + "AssetClass": "63757272656E6379" + } + }, + { + "binary": "1200342033000004D2811401476926B590BA3245F63C829116A0A3AF7F382D", + "json": { + "TransactionType": "OracleDelete", + "Account": "rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8", + "OracleDocumentID": 1234 + } } ], "ledgerData": [{ diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 7ff93a2f21..d2ac33be4d 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -6,6 +6,9 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### BREAKING CHANGES * Small fix in the API to use a new flag name `tfNoDirectRipple` instead of the existing flag name `tfNoRippleDirect` +### Added +* Support for the Price Oracles amendment (XLS-47). + ### Fixed * Typo in `Channel` type `source_tab` -> `source_tag` * Fix `client.requestAll` to handle filters better diff --git a/packages/xrpl/src/models/common/index.ts b/packages/xrpl/src/models/common/index.ts index a695bd8e13..5b240c93aa 100644 --- a/packages/xrpl/src/models/common/index.ts +++ b/packages/xrpl/src/models/common/index.ts @@ -155,3 +155,36 @@ export interface XChainBridge { IssuingChainDoor: string IssuingChainIssue: Currency } + +/** + * A PriceData object represents the price information for a token pair. + * + */ +export interface PriceData { + PriceData: { + /** + * The primary asset in a trading pair. Any valid identifier, such as a stock symbol, bond CUSIP, or currency code is allowed. + * For example, in the BTC/USD pair, BTC is the base asset; in 912810RR9/BTC, 912810RR9 is the base asset. + */ + BaseAsset: string + + /** + * The quote asset in a trading pair. The quote asset denotes the price of one unit of the base asset. For example, in the + * BTC/USD pair,BTC is the base asset; in 912810RR9/BTC, 912810RR9 is the base asset. + */ + QuoteAsset: string + + /** + * The asset price after applying the Scale precision level. It's not included if the last update transaction didn't include + * the BaseAsset/QuoteAsset pair. + */ + AssetPrice?: number | string + + /** + * The scaling factor to apply to an asset price. For example, if Scale is 6 and original price is 0.155, then the scaled + * price is 155000. Valid scale ranges are 0-10. It's not included if the last update transaction didn't include the + * BaseAsset/QuoteAsset pair. + */ + Scale?: number + } +} diff --git a/packages/xrpl/src/models/ledger/LedgerEntry.ts b/packages/xrpl/src/models/ledger/LedgerEntry.ts index b2252dc5e4..1f8f3ec32b 100644 --- a/packages/xrpl/src/models/ledger/LedgerEntry.ts +++ b/packages/xrpl/src/models/ledger/LedgerEntry.ts @@ -10,6 +10,7 @@ import FeeSettings from './FeeSettings' import LedgerHashes from './LedgerHashes' import NegativeUNL from './NegativeUNL' import Offer from './Offer' +import Oracle from './Oracle' import PayChannel from './PayChannel' import RippleState from './RippleState' import SignerList from './SignerList' @@ -30,6 +31,7 @@ type LedgerEntry = | LedgerHashes | NegativeUNL | Offer + | Oracle | PayChannel | RippleState | SignerList @@ -52,6 +54,7 @@ type LedgerEntryFilter = | 'nft_offer' | 'nft_page' | 'offer' + | 'oracle' | 'payment_channel' | 'signer_list' | 'state' diff --git a/packages/xrpl/src/models/ledger/Oracle.ts b/packages/xrpl/src/models/ledger/Oracle.ts new file mode 100644 index 0000000000..37d77b6c8d --- /dev/null +++ b/packages/xrpl/src/models/ledger/Oracle.ts @@ -0,0 +1,43 @@ +import { PriceData } from '../common' + +import { BaseLedgerEntry, HasPreviousTxnID } from './BaseLedgerEntry' + +/** + * The Oracle object type describes a single Price Oracle instance. + * + * @category Ledger Entries + */ +export default interface Oracle extends BaseLedgerEntry, HasPreviousTxnID { + LedgerEntryType: 'Oracle' + + /** + * The time the data was last updated, represented as a unix timestamp in seconds. + */ + LastUpdateTime: number + + /** + * The XRPL account with update and delete privileges for the oracle. + */ + Owner: string + + /** + * Describes the type of asset, such as "currency", "commodity", or "index". + */ + AssetClass: string + + /** + * The oracle provider, such as Chainlink, Band, or DIA. + */ + Provider: string + + /** + * An array of up to 10 PriceData objects. + */ + PriceDataSeries: PriceData[] + + /** + * A bit-map of boolean flags. No flags are defined for the Oracle object + * type, so this value is always 0. + */ + Flags: 0 +} diff --git a/packages/xrpl/src/models/ledger/index.ts b/packages/xrpl/src/models/ledger/index.ts index a2460df745..1cf9263b37 100644 --- a/packages/xrpl/src/models/ledger/index.ts +++ b/packages/xrpl/src/models/ledger/index.ts @@ -22,6 +22,7 @@ import NegativeUNL, { NEGATIVE_UNL_ID } from './NegativeUNL' import { NFTokenOffer } from './NFTokenOffer' import { NFToken, NFTokenPage } from './NFTokenPage' import Offer, { OfferFlags } from './Offer' +import Oracle from './Oracle' import PayChannel from './PayChannel' import RippleState, { RippleStateFlags } from './RippleState' import SignerList, { SignerListFlags } from './SignerList' @@ -58,6 +59,7 @@ export { NFToken, Offer, OfferFlags, + Oracle, PayChannel, RippleState, RippleStateFlags, diff --git a/packages/xrpl/src/models/methods/getAggregatePrice.ts b/packages/xrpl/src/models/methods/getAggregatePrice.ts new file mode 100644 index 0000000000..82d9ecfdd3 --- /dev/null +++ b/packages/xrpl/src/models/methods/getAggregatePrice.ts @@ -0,0 +1,119 @@ +import { BaseRequest, BaseResponse } from './baseMethod' + +/** + * The `get_aggregate_price` method retrieves the aggregate price of specified Oracle objects, + * returning three price statistics: mean, median, and trimmed mean. + * Returns an {@link GetAggregatePriceResponse}. + * + * @category Requests + */ +export interface GetAggregatePriceRequest extends BaseRequest { + command: 'get_aggregate_price' + + /** + * The currency code of the asset to be priced. + */ + base_asset: string + + /** + * The currency code of the asset to quote the price of the base asset. + */ + quote_asset: string + + /** + * The oracle identifier. + */ + oracles: Array<{ + /** + * The XRPL account that controls the Oracle object. + */ + account: string + + /** + * A unique identifier of the price oracle for the Account + */ + oracle_document_id: string | number + }> + + /** + * The percentage of outliers to trim. Valid trim range is 1-25. If included, the API returns statistics for the trimmed mean. + */ + trim?: number + + /** + * Defines a time range in seconds for filtering out older price data. Default value is 0, which doesn't filter any data. + */ + trim_threshold?: number +} + +/** + * Response expected from an {@link GetAggregatePriceRequest}. + * + * @category Responses + */ +export interface GetAggregatePriceResponse extends BaseResponse { + result: { + /** + * The statistics from the collected oracle prices. + */ + entire_set: { + /** + * The simple mean. + */ + mean: string + + /** + * The size of the data set to calculate the mean. + */ + size: number + + /** + * The standard deviation. + */ + standard_deviation: string + } + + /** + * The trimmed statistics from the collected oracle prices. Only appears if the trim field was specified in the request. + */ + trimmed_set?: { + /** + * The simple mean of the trimmed data. + */ + mean: string + + /** + * The size of the data to calculate the trimmed mean. + */ + size: number + + /** + * The standard deviation of the trimmed data. + */ + standard_deviation: string + } + + /** + * The median of the collected oracle prices. + */ + median: string + + /** + * The most recent timestamp out of all LastUpdateTime values. + */ + time: number + + /** + * The ledger index of the ledger version that was used to generate this + * response. + */ + ledger_current_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/xrpl/src/models/methods/index.ts b/packages/xrpl/src/models/methods/index.ts index f6929f8cc9..bd0ca20647 100644 --- a/packages/xrpl/src/models/methods/index.ts +++ b/packages/xrpl/src/models/methods/index.ts @@ -66,6 +66,10 @@ import { GatewayBalancesRequest, GatewayBalancesResponse, } from './gatewayBalances' +import { + GetAggregatePriceRequest, + GetAggregatePriceResponse, +} from './getAggregatePrice' import { LedgerBinary, LedgerModifiedOfferCreateTransaction, @@ -210,6 +214,8 @@ type Request = | NFTHistoryRequest // AMM methods | AMMInfoRequest + // Price Oracle methods + | GetAggregatePriceRequest /** * @category Responses @@ -264,6 +270,8 @@ type Response = | NFTHistoryResponse // AMM methods | AMMInfoResponse + // Price Oracle methods + | GetAggregatePriceResponse export type RequestResponseMap = T extends AccountChannelsRequest ? AccountChannelsResponse @@ -285,6 +293,8 @@ export type RequestResponseMap = T extends AccountChannelsRequest ? AMMInfoResponse : T extends GatewayBalancesRequest ? GatewayBalancesResponse + : T extends GetAggregatePriceRequest + ? GetAggregatePriceResponse : T extends NoRippleCheckRequest ? NoRippleCheckResponse : // NOTE: The order of these LedgerRequest types is important @@ -473,6 +483,8 @@ export { GatewayBalance, GatewayBalancesRequest, GatewayBalancesResponse, + GetAggregatePriceRequest, + GetAggregatePriceResponse, NoRippleCheckRequest, NoRippleCheckResponse, // ledger methods diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index 5f77dfd2bc..c7a8120758 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -58,6 +58,8 @@ export { OfferCreateFlagsInterface, OfferCreate, } from './offerCreate' +export { OracleDelete } from './oracleDelete' +export { OracleSet } from './oracleSet' export { PaymentFlags, PaymentFlagsInterface, Payment } from './payment' export { PaymentChannelClaimFlags, diff --git a/packages/xrpl/src/models/transactions/oracleDelete.ts b/packages/xrpl/src/models/transactions/oracleDelete.ts new file mode 100644 index 0000000000..2fda7a91e2 --- /dev/null +++ b/packages/xrpl/src/models/transactions/oracleDelete.ts @@ -0,0 +1,32 @@ +import { + BaseTransaction, + isNumber, + validateBaseTransaction, + validateRequiredField, +} from './common' + +/** + * Delete an Oracle ledger entry. + * + * @category Transaction Models + */ +export interface OracleDelete extends BaseTransaction { + TransactionType: 'OracleDelete' + + /** + * A unique identifier of the price oracle for the Account. + */ + OracleDocumentID: number +} + +/** + * Verify the form and type of a OracleDelete at runtime. + * + * @param tx - A OracleDelete Transaction. + * @throws When the OracleDelete is malformed. + */ +export function validateOracleDelete(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'OracleDocumentID', isNumber) +} diff --git a/packages/xrpl/src/models/transactions/oracleSet.ts b/packages/xrpl/src/models/transactions/oracleSet.ts new file mode 100644 index 0000000000..0ba214ae38 --- /dev/null +++ b/packages/xrpl/src/models/transactions/oracleSet.ts @@ -0,0 +1,164 @@ +import { ValidationError } from '../../errors' +import { PriceData } from '../common' + +import { + BaseTransaction, + isNumber, + isString, + validateBaseTransaction, + validateOptionalField, + validateRequiredField, +} from './common' + +const PRICE_DATA_SERIES_MAX_LENGTH = 10 +const SCALE_MAX = 10 + +/** + * Creates a new Oracle ledger entry or updates the fields of an existing one, using the Oracle ID. + * + * The oracle provider must complete these steps before submitting this transaction: + * 1. Create or own the XRPL account in the Owner field and have enough XRP to meet the reserve and transaction fee requirements. + * 2. Publish the XRPL account public key, so it can be used for verification by dApps. + * 3. Publish a registry of available price oracles with their unique OracleDocumentID. + * + * @category Transaction Models + */ +export interface OracleSet extends BaseTransaction { + TransactionType: 'OracleSet' + + /** + * A unique identifier of the price oracle for the Account. + */ + OracleDocumentID: number + + /** + * The time the data was last updated, represented as a unix timestamp in seconds. + */ + LastUpdateTime: number + + /** + * An array of up to 10 PriceData objects, each representing the price information + * for a token pair. More than five PriceData objects require two owner reserves. + */ + PriceDataSeries: PriceData[] + + /** + * An arbitrary value that identifies an oracle provider, such as Chainlink, Band, + * or DIA. This field is a string, up to 256 ASCII hex encoded characters (0x20-0x7E). + * This field is required when creating a new Oracle ledger entry, but is optional for updates. + */ + Provider?: string + + /** + * An optional Universal Resource Identifier to reference price data off-chain. This field is limited to 256 bytes. + */ + URI?: string + + /** + * Describes the type of asset, such as "currency", "commodity", or "index". This field is a string, up to 16 ASCII + * hex encoded characters (0x20-0x7E). This field is required when creating a new Oracle ledger entry, but is optional + * for updates. + */ + AssetClass?: string +} + +/** + * Verify the form and type of a OracleSet at runtime. + * + * @param tx - A OracleSet Transaction. + * @throws When the OracleSet is malformed. + */ +// eslint-disable-next-line max-lines-per-function -- necessary to validate many fields +export function validateOracleSet(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'OracleDocumentID', isNumber) + + validateRequiredField(tx, 'LastUpdateTime', isNumber) + + validateOptionalField(tx, 'Provider', isString) + + validateOptionalField(tx, 'URI', isString) + + validateOptionalField(tx, 'AssetClass', isString) + + // eslint-disable-next-line max-lines-per-function -- necessary to validate many fields + validateRequiredField(tx, 'PriceDataSeries', (value) => { + if (!Array.isArray(value)) { + throw new ValidationError('OracleSet: PriceDataSeries must be an array') + } + + if (value.length > PRICE_DATA_SERIES_MAX_LENGTH) { + throw new ValidationError( + `OracleSet: PriceDataSeries must have at most ${PRICE_DATA_SERIES_MAX_LENGTH} PriceData objects`, + ) + } + + // TODO: add support for handling inner objects easier (similar to validateRequiredField/validateOptionalField) + for (const priceData of value) { + if (typeof priceData !== 'object') { + throw new ValidationError( + 'OracleSet: PriceDataSeries must be an array of objects', + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + if (priceData.PriceData == null) { + throw new ValidationError( + 'OracleSet: PriceDataSeries must have a `PriceData` object', + ) + } + + // check if priceData only has PriceData + if (Object.keys(priceData).length !== 1) { + throw new ValidationError( + 'OracleSet: PriceDataSeries must only have a single PriceData object', + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + if (typeof priceData.PriceData.BaseAsset !== 'string') { + throw new ValidationError( + 'OracleSet: PriceDataSeries must have a `BaseAsset` string', + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + if (typeof priceData.PriceData.QuoteAsset !== 'string') { + throw new ValidationError( + 'OracleSet: PriceDataSeries must have a `QuoteAsset` string', + ) + } + + if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + 'AssetPrice' in priceData.PriceData && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + !isNumber(priceData.PriceData.AssetPrice) + ) { + throw new ValidationError('OracleSet: invalid field AssetPrice') + } + + if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + 'Scale' in priceData.PriceData && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + !isNumber(priceData.PriceData.Scale) + ) { + throw new ValidationError('OracleSet: invalid field Scale') + } + + if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + priceData.PriceData.Scale < 0 || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- we are validating the type + priceData.PriceData.Scale > SCALE_MAX + ) { + throw new ValidationError( + `OracleSet: Scale must be in range 0-${SCALE_MAX}`, + ) + } + } + return true + }) +} diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index 1207d81a19..0ddc719539 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -43,6 +43,8 @@ import { import { NFTokenMint, validateNFTokenMint } from './NFTokenMint' import { OfferCancel, validateOfferCancel } from './offerCancel' import { OfferCreate, validateOfferCreate } from './offerCreate' +import { OracleDelete, validateOracleDelete } from './oracleDelete' +import { OracleSet, validateOracleSet } from './oracleSet' import { Payment, validatePayment } from './payment' import { PaymentChannelClaim, @@ -120,6 +122,8 @@ export type SubmittableTransaction = | NFTokenMint | OfferCancel | OfferCreate + | OracleDelete + | OracleSet | Payment | PaymentChannelClaim | PaymentChannelCreate @@ -330,6 +334,14 @@ export function validate(transaction: Record): void { validateOfferCreate(tx) break + case 'OracleDelete': + validateOracleDelete(tx) + break + + case 'OracleSet': + validateOracleSet(tx) + break + case 'Payment': validatePayment(tx) break diff --git a/packages/xrpl/test/integration/fundWallet.test.ts b/packages/xrpl/test/integration/fundWallet.test.ts index 0a404f21d3..576ffb08bb 100644 --- a/packages/xrpl/test/integration/fundWallet.test.ts +++ b/packages/xrpl/test/integration/fundWallet.test.ts @@ -104,7 +104,7 @@ describe('fundWallet', function () { }) assert.equal(dropsToXrp(info.result.account_data.Balance), balance) - assert.equal(balance, 10000) + assert.equal(balance, 1000) /* * No test for fund given wallet because the hooks v3 testnet faucet diff --git a/packages/xrpl/test/integration/requests/getAggregatePrice.test.ts b/packages/xrpl/test/integration/requests/getAggregatePrice.test.ts new file mode 100644 index 0000000000..8bfee665a0 --- /dev/null +++ b/packages/xrpl/test/integration/requests/getAggregatePrice.test.ts @@ -0,0 +1,78 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { OracleSet } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +// how long before each test case times out +const TIMEOUT = 20000 + +describe('get_aggregate_price', function () { + let testContext: XrplIntegrationTestContext + + beforeEach(async () => { + testContext = await setupClient(serverUrl) + }) + afterEach(async () => teardownClient(testContext)) + + it( + 'base', + async () => { + const tx: OracleSet = { + TransactionType: 'OracleSet', + Account: testContext.wallet.classicAddress, + OracleDocumentID: 1234, + LastUpdateTime: Math.floor(Date.now() / 1000), + PriceDataSeries: [ + { + PriceData: { + BaseAsset: 'XRP', + QuoteAsset: 'USD', + AssetPrice: 740, + Scale: 3, + }, + }, + ], + Provider: stringToHex('chainlink'), + URI: '6469645F6578616D706C65', + AssetClass: stringToHex('currency'), + } + + await testTransaction(testContext.client, tx, testContext.wallet) + + // confirm that the Oracle was actually created + const getAggregatePriceResponse = await testContext.client.request({ + command: 'get_aggregate_price', + account: testContext.wallet.classicAddress, + base_asset: 'XRP', + quote_asset: 'USD', + trim: 20, + oracles: [ + { + account: testContext.wallet.classicAddress, + oracle_document_id: 1234, + }, + ], + }) + assert.deepEqual(getAggregatePriceResponse.result.entire_set, { + mean: '0.74', + size: 1, + standard_deviation: '0', + }) + assert.deepEqual(getAggregatePriceResponse.result.trimmed_set, { + mean: '0.74', + size: 1, + standard_deviation: '0', + }) + assert.equal(getAggregatePriceResponse.result.median, '0.74') + assert.equal(getAggregatePriceResponse.result.time, tx.LastUpdateTime) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/oracleDelete.test.ts b/packages/xrpl/test/integration/transactions/oracleDelete.test.ts new file mode 100644 index 0000000000..5629873285 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/oracleDelete.test.ts @@ -0,0 +1,76 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { OracleSet, OracleDelete } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +// how long before each test case times out +const TIMEOUT = 20000 + +describe('OracleDelete', function () { + let testContext: XrplIntegrationTestContext + + beforeEach(async () => { + testContext = await setupClient(serverUrl) + }) + afterEach(async () => teardownClient(testContext)) + + it( + 'base', + async () => { + const setTx: OracleSet = { + TransactionType: 'OracleSet', + Account: testContext.wallet.classicAddress, + OracleDocumentID: 1234, + LastUpdateTime: Math.floor(Date.now() / 1000), + PriceDataSeries: [ + { + PriceData: { + BaseAsset: 'XRP', + QuoteAsset: 'USD', + AssetPrice: 740, + Scale: 3, + }, + }, + ], + Provider: stringToHex('chainlink'), + URI: '6469645F6578616D706C65', + AssetClass: stringToHex('currency'), + } + + await testTransaction(testContext.client, setTx, testContext.wallet) + + const aoResult = await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + type: 'oracle', + }) + + // confirm that the Oracle was created + assert.equal(aoResult.result.account_objects.length, 1) + + const deleteTx: OracleDelete = { + TransactionType: 'OracleDelete', + Account: testContext.wallet.classicAddress, + OracleDocumentID: 1234, + } + + await testTransaction(testContext.client, deleteTx, testContext.wallet) + + const aoResult2 = await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + }) + + // confirm that the Oracle was actually deleted + assert.equal(aoResult2.result.account_objects.length, 0) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/integration/transactions/oracleSet.test.ts b/packages/xrpl/test/integration/transactions/oracleSet.test.ts new file mode 100644 index 0000000000..5927963d26 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/oracleSet.test.ts @@ -0,0 +1,74 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { OracleSet } from '../../../src' +import { Oracle } from '../../../src/models/ledger' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { testTransaction } from '../utils' + +// how long before each test case times out +const TIMEOUT = 20000 + +describe('OracleSet', function () { + let testContext: XrplIntegrationTestContext + + beforeEach(async () => { + testContext = await setupClient(serverUrl) + }) + afterEach(async () => teardownClient(testContext)) + + it( + 'base', + async () => { + const tx: OracleSet = { + TransactionType: 'OracleSet', + Account: testContext.wallet.classicAddress, + OracleDocumentID: 1234, + LastUpdateTime: Math.floor(Date.now() / 1000), + PriceDataSeries: [ + { + PriceData: { + BaseAsset: 'XRP', + QuoteAsset: 'USD', + AssetPrice: 740, + Scale: 3, + }, + }, + ], + Provider: stringToHex('chainlink'), + URI: '6469645F6578616D706C65', + AssetClass: stringToHex('currency'), + } + + await testTransaction(testContext.client, tx, testContext.wallet) + + const result = await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + type: 'oracle', + }) + + // confirm that the Oracle was actually created + assert.equal(result.result.account_objects.length, 1) + + // confirm details of Oracle ledger entry object + const oracle = result.result.account_objects[0] as Oracle + assert.equal(oracle.LastUpdateTime, tx.LastUpdateTime) + assert.equal(oracle.Owner, testContext.wallet.classicAddress) + assert.equal(oracle.AssetClass, tx.AssetClass) + assert.equal(oracle.Provider, tx.Provider) + assert.equal(oracle.PriceDataSeries.length, 1) + assert.equal(oracle.PriceDataSeries[0].PriceData.BaseAsset, 'XRP') + assert.equal(oracle.PriceDataSeries[0].PriceData.QuoteAsset, 'USD') + assert.equal(oracle.PriceDataSeries[0].PriceData.AssetPrice, '2e4') + assert.equal(oracle.PriceDataSeries[0].PriceData.Scale, 3) + assert.equal(oracle.Flags, 0) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/models/oracleDelete.test.ts b/packages/xrpl/test/models/oracleDelete.test.ts new file mode 100644 index 0000000000..78188638bc --- /dev/null +++ b/packages/xrpl/test/models/oracleDelete.test.ts @@ -0,0 +1,40 @@ +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateOracleDelete } from '../../src/models/transactions/oracleDelete' + +/** + * OracleDelete Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('OracleDelete', function () { + let tx + + beforeEach(function () { + tx = { + TransactionType: 'OracleDelete', + Account: 'rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8', + OracleDocumentID: 1234, + } as any + }) + + it('verifies valid OracleDelete', function () { + assert.doesNotThrow(() => validateOracleDelete(tx)) + assert.doesNotThrow(() => validate(tx)) + }) + + it(`throws w/ missing field OracleDocumentID`, function () { + delete tx.OracleDocumentID + const errorMessage = 'OracleDelete: missing field OracleDocumentID' + assert.throws(() => validateOracleDelete(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid OracleDocumentID`, function () { + tx.OracleDocumentID = '1234' + const errorMessage = 'OracleDelete: invalid field OracleDocumentID' + assert.throws(() => validateOracleDelete(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) +}) diff --git a/packages/xrpl/test/models/oracleSet.test.ts b/packages/xrpl/test/models/oracleSet.test.ts new file mode 100644 index 0000000000..c9e1de8130 --- /dev/null +++ b/packages/xrpl/test/models/oracleSet.test.ts @@ -0,0 +1,180 @@ +import { stringToHex } from '@xrplf/isomorphic/dist/utils' +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateOracleSet } from '../../src/models/transactions/oracleSet' + +/** + * OracleSet Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('OracleSet', function () { + let tx + + beforeEach(function () { + tx = { + TransactionType: 'OracleSet', + Account: 'rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8', + OracleDocumentID: 1234, + LastUpdateTime: 768062172, + PriceDataSeries: [ + { + PriceData: { + BaseAsset: 'XRP', + QuoteAsset: 'USD', + AssetPrice: 740, + Scale: 3, + }, + }, + ], + Provider: stringToHex('chainlink'), + URI: '6469645F6578616D706C65', + AssetClass: stringToHex('currency'), + } as any + }) + + it('verifies valid OracleSet', function () { + assert.doesNotThrow(() => validateOracleSet(tx)) + assert.doesNotThrow(() => validate(tx)) + }) + + it(`throws w/ missing field OracleDocumentID`, function () { + delete tx.OracleDocumentID + const errorMessage = 'OracleSet: missing field OracleDocumentID' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid OracleDocumentID`, function () { + tx.OracleDocumentID = '1234' + const errorMessage = 'OracleSet: invalid field OracleDocumentID' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing field LastUpdateTime`, function () { + delete tx.LastUpdateTime + const errorMessage = 'OracleSet: missing field LastUpdateTime' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid LastUpdateTime`, function () { + tx.LastUpdateTime = '768062172' + const errorMessage = 'OracleSet: invalid field LastUpdateTime' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing invalid Provider`, function () { + tx.Provider = 1234 + const errorMessage = 'OracleSet: invalid field Provider' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing invalid URI`, function () { + tx.URI = 1234 + const errorMessage = 'OracleSet: invalid field URI' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing invalid AssetClass`, function () { + tx.AssetClass = 1234 + const errorMessage = 'OracleSet: invalid field AssetClass' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid PriceDataSeries must be an array`, function () { + tx.PriceDataSeries = 1234 + const errorMessage = 'OracleSet: PriceDataSeries must be an array' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid PriceDataSeries must be an array of objects`, function () { + tx.PriceDataSeries = [1234] + const errorMessage = + 'OracleSet: PriceDataSeries must be an array of objects' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ PriceDataSeries must have at most 10 PriceData objects`, function () { + tx.PriceDataSeries = new Array(11).fill({ + PriceData: { + BaseAsset: 'XRP', + QuoteAsset: 'USD', + AssetPrice: 740, + Scale: 3, + }, + }) + const errorMessage = + 'OracleSet: PriceDataSeries must have at most 10 PriceData objects' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ PriceDataSeries must have a PriceData object`, function () { + delete tx.PriceDataSeries[0].PriceData + const errorMessage = + 'OracleSet: PriceDataSeries must have a `PriceData` object' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ PriceDataSeries must only have a single PriceData object`, function () { + tx.PriceDataSeries[0].ExtraProp = 'extraprop' + const errorMessage = + 'OracleSet: PriceDataSeries must only have a single PriceData object' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing BaseAsset of PriceDataSeries`, function () { + delete tx.PriceDataSeries[0].PriceData.BaseAsset + const errorMessage = + 'OracleSet: PriceDataSeries must have a `BaseAsset` string' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ missing QuoteAsset of PriceDataSeries`, function () { + delete tx.PriceDataSeries[0].PriceData.QuoteAsset + const errorMessage = + 'OracleSet: PriceDataSeries must have a `QuoteAsset` string' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid AssetPrice of PriceDataSeries`, function () { + tx.PriceDataSeries[0].PriceData.AssetPrice = '1234' + const errorMessage = 'OracleSet: invalid field AssetPrice' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ invalid Scale of PriceDataSeries`, function () { + tx.PriceDataSeries[0].PriceData.Scale = '1234' + const errorMessage = 'OracleSet: invalid field Scale' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ Scale must be in range 0-10 when above max`, function () { + tx.PriceDataSeries[0].PriceData.Scale = 11 + const errorMessage = 'OracleSet: Scale must be in range 0-10' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) + + it(`throws w/ Scale must be in range 0-10 when below min`, function () { + tx.PriceDataSeries[0].PriceData.Scale = -1 + const errorMessage = 'OracleSet: Scale must be in range 0-10' + assert.throws(() => validateOracleSet(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) + }) +})