diff --git a/packages/xahau-binary-codec/HISTORY.md b/packages/xahau-binary-codec/HISTORY.md index a0710d4e..8b5df813 100644 --- a/packages/xahau-binary-codec/HISTORY.md +++ b/packages/xahau-binary-codec/HISTORY.md @@ -2,8 +2,6 @@ ## Unreleased -## 2.1.0 (2024-06-03) - ### Added * Support for the Price Oracles amendment (XLS-47). diff --git a/packages/xahau-binary-codec/src/enums/definitions.json b/packages/xahau-binary-codec/src/enums/definitions.json index e20ba8c8..aae1cc80 100644 --- a/packages/xahau-binary-codec/src/enums/definitions.json +++ b/packages/xahau-binary-codec/src/enums/definitions.json @@ -340,6 +340,16 @@ "type": "UInt32" } ], + [ + "LastUpdateTime", + { + "nth": 15, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "HighQualityIn", { @@ -770,6 +780,16 @@ "type": "UInt32" } ], + [ + "OracleDocumentID", + { + "nth": 51, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1060,6 +1080,16 @@ "type": "UInt64" } ], + [ + "AssetPrice", + { + "nth": 23, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -2170,6 +2200,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", { @@ -2750,6 +2800,16 @@ "type": "STObject" } ], + [ + "PriceData", + { + "nth": 32, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], [ "Signers", { @@ -2930,6 +2990,16 @@ "type": "STArray" } ], + [ + "PriceDataSeries", + { + "nth": 24, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "AuthAccounts", { diff --git a/packages/xahau-binary-codec/test/fixtures/codec-fixtures.json b/packages/xahau-binary-codec/test/fixtures/codec-fixtures.json index 91645494..096e96eb 100644 --- a/packages/xahau-binary-codec/test/fixtures/codec-fixtures.json +++ b/packages/xahau-binary-codec/test/fixtures/codec-fixtures.json @@ -4449,6 +4449,36 @@ "Flags": 0, "Sequence": 62 } + }, + { + "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/xahau/HISTORY.md b/packages/xahau/HISTORY.md index 40eef2b2..e681d4c3 100644 --- a/packages/xahau/HISTORY.md +++ b/packages/xahau/HISTORY.md @@ -8,6 +8,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr * Support for IOUClaimReward Amendment * Support for NamedHooks * Support for the `simulate` RPC ([XLS-69](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0069-simulate)) +* Support for the Price Oracles amendment (XLS-47). ## 4.0.4 (2026-05-27) @@ -66,6 +67,9 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Added * Support for the Price Oracles amendment (XLS-47). +### 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/xahau/src/models/ledger/LedgerEntry.ts b/packages/xahau/src/models/ledger/LedgerEntry.ts index b804b1aa..d0f9dec9 100644 --- a/packages/xahau/src/models/ledger/LedgerEntry.ts +++ b/packages/xahau/src/models/ledger/LedgerEntry.ts @@ -14,6 +14,7 @@ import ImportVLSequence from './ImportVLSequence' 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' @@ -38,6 +39,7 @@ type LedgerEntry = | LedgerHashes | NegativeUNL | Offer + | Oracle | PayChannel | RippleState | SignerList @@ -60,6 +62,7 @@ type LedgerEntryFilter = | 'import_vl_sequence' | 'hashes' | 'offer' + | 'oracle' | 'payment_channel' | 'signer_list' | 'state' diff --git a/packages/xahau/src/models/ledger/Oracle.ts b/packages/xahau/src/models/ledger/Oracle.ts new file mode 100644 index 00000000..37d77b6c --- /dev/null +++ b/packages/xahau/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/xahau/src/models/ledger/index.ts b/packages/xahau/src/models/ledger/index.ts index 31b928e2..5ffc01c5 100644 --- a/packages/xahau/src/models/ledger/index.ts +++ b/packages/xahau/src/models/ledger/index.ts @@ -23,6 +23,7 @@ import { LedgerEntry, LedgerEntryFilter } from './LedgerEntry' import LedgerHashes from './LedgerHashes' import NegativeUNL, { NEGATIVE_UNL_ID } from './NegativeUNL' import Offer, { OfferFlags } from './Offer' +import Oracle from './Oracle' import PayChannel from './PayChannel' import RippleState, { RippleStateFlags } from './RippleState' import SignerList, { SignerListFlags } from './SignerList' @@ -60,6 +61,7 @@ export { NegativeUNL, Offer, OfferFlags, + Oracle, PayChannel, RippleState, RippleStateFlags, diff --git a/packages/xahau/src/models/methods/getAggregatePrice.ts b/packages/xahau/src/models/methods/getAggregatePrice.ts new file mode 100644 index 00000000..82d9ecfd --- /dev/null +++ b/packages/xahau/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/xahau/src/models/methods/index.ts b/packages/xahau/src/models/methods/index.ts index 6eb06988..d9a22c69 100644 --- a/packages/xahau/src/models/methods/index.ts +++ b/packages/xahau/src/models/methods/index.ts @@ -78,6 +78,10 @@ import { GatewayBalancesRequest, GatewayBalancesResponse, } from './gatewayBalances' +import { + GetAggregatePriceRequest, + GetAggregatePriceResponse, +} from './getAggregatePrice' import { LedgerBinary, LedgerModifiedOfferCreateTransaction, @@ -221,6 +225,8 @@ type Request = // utility methods | PingRequest | RandomRequest + // Price Oracle methods + | GetAggregatePriceRequest /** * @category Responses @@ -269,6 +275,8 @@ type Response = // utility methods | PingResponse | RandomResponse + // Price Oracle methods + | GetAggregatePriceResponse export type RequestResponseMap< T, @@ -289,6 +297,8 @@ export type RequestResponseMap< ? AccountTxVersionResponseMap : T extends GatewayBalancesRequest ? GatewayBalancesResponse + : T extends GetAggregatePriceRequest + ? GetAggregatePriceResponse : T extends NoRippleCheckRequest ? NoRippleCheckResponse : // NOTE: The order of these LedgerRequest types is important @@ -481,6 +491,8 @@ export { GatewayBalance, GatewayBalancesRequest, GatewayBalancesResponse, + GetAggregatePriceRequest, + GetAggregatePriceResponse, NoRippleCheckRequest, NoRippleCheckResponse, // ledger methods diff --git a/packages/xahau/src/models/transactions/index.ts b/packages/xahau/src/models/transactions/index.ts index f65412b7..c578d6b3 100644 --- a/packages/xahau/src/models/transactions/index.ts +++ b/packages/xahau/src/models/transactions/index.ts @@ -32,6 +32,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/xahau/src/models/transactions/oracleDelete.ts b/packages/xahau/src/models/transactions/oracleDelete.ts new file mode 100644 index 00000000..2fda7a91 --- /dev/null +++ b/packages/xahau/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/xahau/src/models/transactions/oracleSet.ts b/packages/xahau/src/models/transactions/oracleSet.ts new file mode 100644 index 00000000..0ba214ae --- /dev/null +++ b/packages/xahau/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/xahau/src/models/transactions/transaction.ts b/packages/xahau/src/models/transactions/transaction.ts index cea1b30f..97767f92 100644 --- a/packages/xahau/src/models/transactions/transaction.ts +++ b/packages/xahau/src/models/transactions/transaction.ts @@ -24,6 +24,8 @@ import { Invoke, validateInvoke } from './invoke' import { TransactionMetadata } from './metadata' 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, @@ -79,6 +81,8 @@ export type SubmittableTransaction = | Invoke | OfferCancel | OfferCreate + | OracleDelete + | OracleSet | Payment | PaymentChannelClaim | PaymentChannelCreate @@ -249,6 +253,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/xahau/test/integration/requests/getAggregatePrice.test.ts b/packages/xahau/test/integration/requests/getAggregatePrice.test.ts new file mode 100644 index 00000000..8bfee665 --- /dev/null +++ b/packages/xahau/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/xahau/test/integration/transactions/oracleDelete.test.ts b/packages/xahau/test/integration/transactions/oracleDelete.test.ts new file mode 100644 index 00000000..56298732 --- /dev/null +++ b/packages/xahau/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/xahau/test/integration/transactions/oracleSet.test.ts b/packages/xahau/test/integration/transactions/oracleSet.test.ts new file mode 100644 index 00000000..5927963d --- /dev/null +++ b/packages/xahau/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/xahau/test/models/oracleDelete.test.ts b/packages/xahau/test/models/oracleDelete.test.ts new file mode 100644 index 00000000..78188638 --- /dev/null +++ b/packages/xahau/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/xahau/test/models/oracleSet.test.ts b/packages/xahau/test/models/oracleSet.test.ts new file mode 100644 index 00000000..c9e1de81 --- /dev/null +++ b/packages/xahau/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) + }) +})