diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 023c80c2f3..673c1e3505 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -178,8 +178,9 @@ PriceOracle fixEmptyDID fixXChainRewardRounding fixPreviousTxnID -# 2.3.0-rc1 Amendments fixAMMv1_1 +# 2.3.0 Amendments +fixAMMv1_2 Credentials NFTokenMintOffer MPTokensV1 diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 6888d96f21..012537fa3f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -106,7 +106,7 @@ jobs: - name: Run docker in background run: | - docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_nfo || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" + docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -162,7 +162,7 @@ jobs: - name: Run docker in background run: | - docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_nfo || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" + docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" - name: Setup npm version 10 run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d66ea41e8f..2b3f978dce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,18 +64,20 @@ From the top-level xrpl.js folder (one level above `packages`), run the followin ```bash npm install # 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-b4 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg +docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:2.3.0-rc1 -c 'rippled -a' npm run build npm run test:integration ``` Breaking down the command: * `docker run -p 6006:6006` starts a Docker container with an open port for admin WebSocket requests. -* `--interactive` allows you to interact with the container. -* `-t` starts a terminal in the container for you to send commands to. -* `--volume $PWD/.ci-config:/config/` identifies the `rippled.cfg` and `validators.txt` to import. It must be an absolute path, so we use `$PWD` instead of `./`. + `--rm` tells docker to close the container after processes are done running. +* `-it` allows you to interact with the container. + `--name rippled_standalone` is an instance name for clarity +* `--volume $PWD/.ci-config:/etc/opt/ripple/` identifies the `rippled.cfg` and `validators.txt` to import. It must be an absolute path, so we use `$PWD` instead of `./`. * `rippleci/rippled` is an image that is regularly updated with the latest `rippled` releases -* `/opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg` starts `rippled` in standalone mode +* `--entrypoint bash rippleci/rippled:2.3.0-rc1` manually overrides the entrypoint (for versions of rippled >= 2.3.0) +* `-c 'rippled -a'` provides the bash command to start `rippled` in standalone mode from the manual entrypoint ### Browser Tests @@ -90,7 +92,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.2.0-b3 /opt/ripple/bin/rippled -a --conf /opt/ripple/etc/rippled.cfg +docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:2.3.0-rc1 -c 'rippled -a' npm run test:browser ``` diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 47379b4a6f..1e8d094430 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -910,6 +910,26 @@ "type": "UInt64" } ], + [ + "IssuerNode", + { + "nth": 27, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt64" + } + ], + [ + "SubjectNode", + { + "nth": 28, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1810,6 +1830,16 @@ "type": "Blob" } ], + [ + "CredentialType", + { + "nth": 31, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Blob" + } + ], [ "Account", { @@ -1980,6 +2010,16 @@ "type": "AccountID" } ], + [ + "Subject", + { + "nth": 24, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "AccountID" + } + ], [ "TransactionMetaData", { @@ -2270,6 +2310,16 @@ "type": "STObject" } ], + [ + "Credential", + { + "nth": 33, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STObject" + } + ], [ "Signers", { @@ -2460,6 +2510,26 @@ "type": "STArray" } ], + [ + "AuthorizeCredentials", + { + "nth": 26, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], + [ + "UnauthorizeCredentials", + { + "nth": 27, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "STArray" + } + ], [ "CloseResolution", { @@ -2640,6 +2710,16 @@ "type": "Vector256" } ], + [ + "CredentialIDs", + { + "nth": 5, + "isVLEncoded": true, + "isSerialized": true, + "isSigningField": true, + "type": "Vector256" + } + ], [ "MPTokenIssuanceID", { @@ -2781,6 +2861,7 @@ "NegativeUNL": 78, "Offer": 111, "Oracle": 128, + "Credential": 129, "PayChannel": 120, "RippleState": 114, "SignerList": 83, @@ -2797,6 +2878,7 @@ "tecAMM_NOT_EMPTY": 167, "tecARRAY_EMPTY": 190, "tecARRAY_TOO_LARGE": 191, + "tecBAD_CREDENTIALS": 193, "tecCANT_ACCEPT_OWN_NFTOKEN_OFFER": 158, "tecCLAIM": 100, "tecCRYPTOCONDITION_ERROR": 146, @@ -2982,6 +3064,9 @@ "CheckCash": 17, "CheckCreate": 16, "Clawback": 30, + "CredentialCreate": 58, + "CredentialAccept": 59, + "CredentialDelete": 60, "DIDDelete": 50, "DIDSet": 49, "DepositPreauth": 19, diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index e1854eeebe..69ae6fa19d 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -5,9 +5,10 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ## Unreleased Changes ### Added -* parseTransactionFlags as a utility function in the xrpl package to streamline transactions flags-to-map conversion * Added new MPT transaction definitions (XLS-33) * New `MPTAmount` type support for `Payment` and `Clawback` transactions +* `parseTransactionFlags` as a utility function in the xrpl package to streamline transactions flags-to-map conversion +* Support for XLS-70d (Credentials) ### Fixed * `TransactionStream` model supports APIv2 diff --git a/packages/xrpl/src/models/common/index.ts b/packages/xrpl/src/models/common/index.ts index 6a0e316a9f..cd16b08a49 100644 --- a/packages/xrpl/src/models/common/index.ts +++ b/packages/xrpl/src/models/common/index.ts @@ -162,6 +162,16 @@ export interface AuthAccount { } } +export interface AuthorizeCredential { + Credential: { + /** The issuer of the credential. */ + Issuer: string + + /** A hex-encoded value to identify the type of credential from the issuer. */ + CredentialType: string + } +} + export interface XChainBridge { LockingChainDoor: string LockingChainIssue: Currency diff --git a/packages/xrpl/src/models/ledger/Credential.ts b/packages/xrpl/src/models/ledger/Credential.ts new file mode 100644 index 0000000000..7716409ece --- /dev/null +++ b/packages/xrpl/src/models/ledger/Credential.ts @@ -0,0 +1,47 @@ +import { GlobalFlags } from '../transactions/common' + +import { BaseLedgerEntry, HasPreviousTxnID } from './BaseLedgerEntry' + +export interface CredentialFlags extends GlobalFlags { + lsfAccepted?: boolean +} + +/** + * + * A Credential object describes a credential, similar to a passport, which is an issuable identity verifier + * that can be used as a prerequisite for other transactions + * + * @category Ledger Entries + */ +export default interface Credential extends BaseLedgerEntry, HasPreviousTxnID { + LedgerEntryType: 'Credential' + /** + * A bit-map of boolean flags + */ + Flags: number | CredentialFlags + + /** The account that the credential is for. */ + Subject: string + + /** The issuer of the credential. */ + Issuer: string + + /** A hex-encoded value to identify the type of credential from the issuer. */ + CredentialType: string + + /** A hint indicating which page of the subject's owner directory links to this object, + * in case the directory consists of multiple pages. + */ + SubjectNode: string + + /** A hint indicating which page of the issuer's owner directory links to this object, + * in case the directory consists of multiple pages. + */ + IssuerNode: string + + /** Credential expiration. */ + Expiration?: number + + /** Additional data about the credential (such as a link to the VC document). */ + URI?: string +} diff --git a/packages/xrpl/src/models/ledger/DepositPreauth.ts b/packages/xrpl/src/models/ledger/DepositPreauth.ts index 70ba7d24a3..7d5d0804ae 100644 --- a/packages/xrpl/src/models/ledger/DepositPreauth.ts +++ b/packages/xrpl/src/models/ledger/DepositPreauth.ts @@ -1,3 +1,5 @@ +import { AuthorizeCredential } from '../common' + import { BaseLedgerEntry, HasPreviousTxnID } from './BaseLedgerEntry' /** @@ -12,8 +14,6 @@ export default interface DepositPreauth LedgerEntryType: 'DepositPreauth' /** The account that granted the preauthorization. */ Account: string - /** The account that received the preauthorization. */ - Authorize: string /** * A bit-map of boolean flags. No flags are defined for DepositPreauth * objects, so this value is always 0. @@ -24,4 +24,8 @@ export default interface DepositPreauth * object, in case the directory consists of multiple pages. */ OwnerNode: string + /** The account that received the preauthorization. */ + Authorize?: string + /** The credential(s) that received the preauthorization. */ + AuthorizeCredentials?: AuthorizeCredential[] } diff --git a/packages/xrpl/src/models/ledger/LedgerEntry.ts b/packages/xrpl/src/models/ledger/LedgerEntry.ts index ce9917db59..a9f7e4aa46 100644 --- a/packages/xrpl/src/models/ledger/LedgerEntry.ts +++ b/packages/xrpl/src/models/ledger/LedgerEntry.ts @@ -3,6 +3,7 @@ import Amendments from './Amendments' import AMM from './AMM' import Bridge from './Bridge' import Check from './Check' +import Credential from './Credential' import DepositPreauth from './DepositPreauth' import DirectoryNode from './DirectoryNode' import Escrow from './Escrow' @@ -24,6 +25,7 @@ type LedgerEntry = | AMM | Bridge | Check + | Credential | DepositPreauth | DirectoryNode | Escrow @@ -45,6 +47,7 @@ type LedgerEntryFilter = | 'amm' | 'bridge' | 'check' + | 'credential' | 'deposit_preauth' | 'did' | 'directory' diff --git a/packages/xrpl/src/models/ledger/index.ts b/packages/xrpl/src/models/ledger/index.ts index 4907babbba..4ac148f3d7 100644 --- a/packages/xrpl/src/models/ledger/index.ts +++ b/packages/xrpl/src/models/ledger/index.ts @@ -6,6 +6,7 @@ import Amendments, { Majority, AMENDMENTS_ID } from './Amendments' import AMM, { VoteSlot } from './AMM' import Bridge from './Bridge' import Check from './Check' +import Credential from './Credential' import DepositPreauth from './DepositPreauth' import DID from './DID' import DirectoryNode from './DirectoryNode' @@ -41,6 +42,7 @@ export { AMM, Bridge, Check, + Credential, DepositPreauth, DirectoryNode, DID, diff --git a/packages/xrpl/src/models/methods/depositAuthorized.ts b/packages/xrpl/src/models/methods/depositAuthorized.ts index 47952ad47a..c64b964e7f 100644 --- a/packages/xrpl/src/models/methods/depositAuthorized.ts +++ b/packages/xrpl/src/models/methods/depositAuthorized.ts @@ -15,6 +15,12 @@ export interface DepositAuthorizedRequest source_account: string /** The recipient of a possible payment. */ destination_account: string + /** + * The object IDs of Credential objects. If this field is included, then the + * credential will be taken into account when analyzing whether the sender can send + * funds to the destination. + */ + credentials?: string[] } /** @@ -52,5 +58,9 @@ export interface DepositAuthorizedResponse extends BaseResponse { source_account: string /** If true, the information comes from a validated ledger version. */ validated?: boolean + /** The object IDs of `Credential` objects. If this field is included, + * then the credential will be taken into account when analyzing whether + * the sender can send funds to the destination. */ + credentials?: string[] } } diff --git a/packages/xrpl/src/models/methods/ledgerEntry.ts b/packages/xrpl/src/models/methods/ledgerEntry.ts index f86512c50b..64fc64c493 100644 --- a/packages/xrpl/src/models/methods/ledgerEntry.ts +++ b/packages/xrpl/src/models/methods/ledgerEntry.ts @@ -83,6 +83,23 @@ export interface LedgerEntryRequest extends BaseRequest, LookupByLedgerRequest { /** The object ID of a Check object to retrieve. */ check?: string + /* Specify the Credential to retrieve. If a string, must be the ledger entry ID of + * the entry, as hexadecimal. If an object, requires subject, issuer, and + * credential_type sub-fields. + */ + credential?: + | { + /** The account that is the subject of the credential. */ + subject: string + + /** The account that issued the credential. */ + issuer: string + + /** The type of the credential, as issued. */ + credentialType: string + } + | string + /** * Specify a DepositPreauth object to retrieve. If a string, must be the * object ID of the DepositPreauth object, as hexadecimal. If an object, diff --git a/packages/xrpl/src/models/transactions/CredentialAccept.ts b/packages/xrpl/src/models/transactions/CredentialAccept.ts new file mode 100644 index 0000000000..cd0906fc71 --- /dev/null +++ b/packages/xrpl/src/models/transactions/CredentialAccept.ts @@ -0,0 +1,44 @@ +import { + BaseTransaction, + isString, + validateBaseTransaction, + validateCredentialType, + validateRequiredField, +} from './common' + +/** + * Accepts a credential issued to the Account (i.e. the Account is the Subject of the Credential object). + * Credentials are represented in hex. Whilst they are allowed a maximum length of 64 + * bytes, every byte requires 2 hex characters for representation. + * The credential is not considered valid until it has been transferred/accepted. + * + * @category Transaction Models + * */ +export interface CredentialAccept extends BaseTransaction { + TransactionType: 'CredentialAccept' + + /** The subject of the credential. */ + Account: string + + /** The issuer of the credential. */ + Issuer: string + + /** A hex-encoded value to identify the type of credential from the issuer. */ + CredentialType: string +} + +/** + * Verify the form and type of a CredentialAccept at runtime. + * + * @param tx - A CredentialAccept Transaction. + * @throws When the CredentialAccept is Malformed. + */ +export function validateCredentialAccept(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'Account', isString) + + validateRequiredField(tx, 'Issuer', isString) + + validateCredentialType(tx) +} diff --git a/packages/xrpl/src/models/transactions/CredentialCreate.ts b/packages/xrpl/src/models/transactions/CredentialCreate.ts new file mode 100644 index 0000000000..8c82b7c74c --- /dev/null +++ b/packages/xrpl/src/models/transactions/CredentialCreate.ts @@ -0,0 +1,81 @@ +import { HEX_REGEX } from '@xrplf/isomorphic/utils' + +import { ValidationError } from '../../errors' + +import { + BaseTransaction, + isNumber, + isString, + validateBaseTransaction, + validateCredentialType, + validateOptionalField, + validateRequiredField, +} from './common' + +const MAX_URI_LENGTH = 256 + +/** + * Creates a Credential object. It must be sent by the issuer. + * + * @category Transaction Models + * */ +export interface CredentialCreate extends BaseTransaction { + TransactionType: 'CredentialCreate' + + /** The issuer of the credential. */ + Account: string + + /** The subject of the credential. */ + Subject: string + + /** A hex-encoded value to identify the type of credential from the issuer. */ + CredentialType: string + + /** Credential expiration. */ + Expiration?: number + + /** Additional data about the credential (such as a link to the VC document). */ + URI?: string +} + +/** + * Verify the form and type of a CredentialCreate at runtime. + * + * @param tx - A CredentialCreate Transaction. + * @throws When the CredentialCreate is Malformed. + */ +export function validateCredentialCreate(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'Account', isString) + + validateRequiredField(tx, 'Subject', isString) + + validateCredentialType(tx) + + validateOptionalField(tx, 'Expiration', isNumber) + + validateURI(tx.URI) +} + +function validateURI(URI: unknown): void { + if (URI === undefined) { + return + } + + if (typeof URI !== 'string') { + throw new ValidationError('CredentialCreate: invalid field URI') + } + + if (URI.length === 0) { + throw new ValidationError('CredentialCreate: URI cannot be an empty string') + } else if (URI.length > MAX_URI_LENGTH) { + throw new ValidationError( + `CredentialCreate: URI length must be <= ${MAX_URI_LENGTH}`, + ) + } + + if (!HEX_REGEX.test(URI)) { + throw new ValidationError('CredentialCreate: URI must be encoded in hex') + } +} diff --git a/packages/xrpl/src/models/transactions/CredentialDelete.ts b/packages/xrpl/src/models/transactions/CredentialDelete.ts new file mode 100644 index 0000000000..d1b4ab0244 --- /dev/null +++ b/packages/xrpl/src/models/transactions/CredentialDelete.ts @@ -0,0 +1,55 @@ +import { ValidationError } from '../../errors' + +import { + BaseTransaction, + isString, + validateBaseTransaction, + validateCredentialType, + validateOptionalField, + validateRequiredField, +} from './common' + +/** + * Deletes a Credential object. + * + * @category Transaction Models + * */ +export interface CredentialDelete extends BaseTransaction { + TransactionType: 'CredentialDelete' + + /** The transaction submitter. */ + Account: string + + /** A hex-encoded value to identify the type of credential from the issuer. */ + CredentialType: string + + /** The person that the credential is for. If omitted, Account is assumed to be the subject. */ + Subject?: string + + /** The issuer of the credential. If omitted, Account is assumed to be the issuer. */ + Issuer?: string +} + +/** + * Verify the form and type of a CredentialDelete at runtime. + * + * @param tx - A CredentialDelete Transaction. + * @throws When the CredentialDelete is Malformed. + */ +export function validateCredentialDelete(tx: Record): void { + validateBaseTransaction(tx) + + if (!tx.Subject && !tx.Issuer) { + throw new ValidationError( + 'CredentialDelete: either `Issuer` or `Subject` must be provided', + ) + } + + validateRequiredField(tx, 'Account', isString) + + validateCredentialType(tx) + + validateOptionalField(tx, 'Subject', isString) + + validateOptionalField(tx, 'Issuer', isString) +} diff --git a/packages/xrpl/src/models/transactions/accountDelete.ts b/packages/xrpl/src/models/transactions/accountDelete.ts index bf9282ad5b..92344f2d18 100644 --- a/packages/xrpl/src/models/transactions/accountDelete.ts +++ b/packages/xrpl/src/models/transactions/accountDelete.ts @@ -4,6 +4,7 @@ import { isAccount, isNumber, validateBaseTransaction, + validateCredentialsList, validateOptionalField, validateRequiredField, } from './common' @@ -28,6 +29,12 @@ export interface AccountDelete extends BaseTransaction { * information for the recipient of the deleted account's leftover XRP. */ DestinationTag?: number + /** + * Credentials associated with sender of this transaction. The credentials included + * must not be expired. The list must not be empty when specified and cannot contain + * more than 8 credentials. + */ + CredentialIDs?: string[] } /** @@ -41,4 +48,11 @@ export function validateAccountDelete(tx: Record): void { validateRequiredField(tx, 'Destination', isAccount) validateOptionalField(tx, 'DestinationTag', isNumber) + + validateCredentialsList( + tx.CredentialIDs, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known from base check + tx.TransactionType as string, + true, + ) } diff --git a/packages/xrpl/src/models/transactions/common.ts b/packages/xrpl/src/models/transactions/common.ts index eb5b56fa1a..d82625355f 100644 --- a/packages/xrpl/src/models/transactions/common.ts +++ b/packages/xrpl/src/models/transactions/common.ts @@ -1,9 +1,12 @@ +/* eslint-disable max-lines -- common utility file */ +import { HEX_REGEX } from '@xrplf/isomorphic/utils' import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec' import { TRANSACTION_TYPES } from 'ripple-binary-codec' import { ValidationError } from '../../errors' import { Amount, + AuthorizeCredential, Currency, IssuedCurrencyAmount, Memo, @@ -14,6 +17,9 @@ import { import { onlyHasFields } from '../utils' const MEMO_SIZE = 3 +const MAX_CREDENTIALS_LIST_LENGTH = 8 +const MAX_CREDENTIAL_BYTE_LENGTH = 64 +const MAX_CREDENTIAL_TYPE_LENGTH = MAX_CREDENTIAL_BYTE_LENGTH * 2 function isMemo(obj: { Memo?: unknown }): boolean { if (obj.Memo == null) { @@ -61,6 +67,7 @@ const ISSUE_SIZE = 2 const ISSUED_CURRENCY_SIZE = 3 const XCHAIN_BRIDGE_SIZE = 4 const MPTOKEN_SIZE = 2 +const AUTHORIZE_CREDENTIAL_SIZE = 1 function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' @@ -121,6 +128,22 @@ export function isIssuedCurrency( ) } +/** + * Verify the form and type of an AuthorizeCredential at runtime + * + * @param input - The input to check the form and type of + * @returns Whether the AuthorizeCredential is properly formed + */ +function isAuthorizeCredential(input: unknown): input is AuthorizeCredential { + return ( + isRecord(input) && + isRecord(input.Credential) && + Object.keys(input).length === AUTHORIZE_CREDENTIAL_SIZE && + typeof input.Credential.CredentialType === 'string' && + typeof input.Credential.Issuer === 'string' + ) +} + /** * Verify the form and type of an MPT at runtime. * @@ -387,3 +410,97 @@ export function parseAmountValue(amount: unknown): number { } return parseFloat(amount.value) } + +/** + * Verify the form and type of a CredentialType at runtime. + * + * @param tx A CredentialType Transaction. + * @throws when the CredentialType is malformed. + */ +export function validateCredentialType(tx: Record): void { + if (typeof tx.TransactionType !== 'string') { + throw new ValidationError('Invalid TransactionType') + } + if (tx.CredentialType === undefined) { + throw new ValidationError( + `${tx.TransactionType}: missing field CredentialType`, + ) + } + + if (!isString(tx.CredentialType)) { + throw new ValidationError( + `${tx.TransactionType}: CredentialType must be a string`, + ) + } + if (tx.CredentialType.length === 0) { + throw new ValidationError( + `${tx.TransactionType}: CredentialType cannot be an empty string`, + ) + } else if (tx.CredentialType.length > MAX_CREDENTIAL_TYPE_LENGTH) { + throw new ValidationError( + `${tx.TransactionType}: CredentialType length cannot be > ${MAX_CREDENTIAL_TYPE_LENGTH}`, + ) + } + + if (!HEX_REGEX.test(tx.CredentialType)) { + throw new ValidationError( + `${tx.TransactionType}: CredentialType must be encoded in hex`, + ) + } +} + +/** + * Check a CredentialAuthorize array for parameter errors + * + * @param credentials An array of credential IDs to check for errors + * @param transactionType The transaction type to include in error messages + * @param isStringID Toggle for if array contains IDs instead of AuthorizeCredential objects + * @throws Validation Error if the formatting is incorrect + */ +// eslint-disable-next-line max-lines-per-function -- separating logic further will add unnecessary complexity +export function validateCredentialsList( + credentials: unknown, + transactionType: string, + isStringID: boolean, +): void { + if (credentials == null) { + return + } + if (!Array.isArray(credentials)) { + throw new ValidationError( + `${transactionType}: Credentials must be an array`, + ) + } + if (credentials.length > MAX_CREDENTIALS_LIST_LENGTH) { + throw new ValidationError( + `${transactionType}: Credentials length cannot exceed ${MAX_CREDENTIALS_LIST_LENGTH} elements`, + ) + } else if (credentials.length === 0) { + throw new ValidationError( + `${transactionType}: Credentials cannot be an empty array`, + ) + } + credentials.forEach((credential) => { + if (isStringID) { + if (!isString(credential)) { + throw new ValidationError( + `${transactionType}: Invalid Credentials ID list format`, + ) + } + } else if (!isAuthorizeCredential(credential)) { + throw new ValidationError( + `${transactionType}: Invalid Credentials format`, + ) + } + }) + if (containsDuplicates(credentials)) { + throw new ValidationError( + `${transactionType}: Credentials cannot contain duplicate elements`, + ) + } +} + +function containsDuplicates(objectList: object[]): boolean { + const objSet = new Set(objectList.map((obj) => JSON.stringify(obj))) + return objSet.size !== objectList.length +} diff --git a/packages/xrpl/src/models/transactions/depositPreauth.ts b/packages/xrpl/src/models/transactions/depositPreauth.ts index eaf186e8b1..71476d42d4 100644 --- a/packages/xrpl/src/models/transactions/depositPreauth.ts +++ b/packages/xrpl/src/models/transactions/depositPreauth.ts @@ -1,6 +1,11 @@ import { ValidationError } from '../../errors' +import { AuthorizeCredential } from '../common' -import { BaseTransaction, validateBaseTransaction } from './common' +import { + BaseTransaction, + validateBaseTransaction, + validateCredentialsList, +} from './common' /** * A DepositPreauth transaction gives another account pre-approval to deliver @@ -18,6 +23,16 @@ export interface DepositPreauth extends BaseTransaction { * revoked. */ Unauthorize?: string + + /** + * The credential(s) to preauthorize. + */ + AuthorizeCredentials?: AuthorizeCredential[] + + /** + * The credential(s) whose preauthorization should be revoked. + */ + UnauthorizeCredentials?: AuthorizeCredential[] } /** @@ -29,17 +44,7 @@ export interface DepositPreauth extends BaseTransaction { export function validateDepositPreauth(tx: Record): void { validateBaseTransaction(tx) - if (tx.Authorize !== undefined && tx.Unauthorize !== undefined) { - throw new ValidationError( - "DepositPreauth: can't provide both Authorize and Unauthorize fields", - ) - } - - if (tx.Authorize === undefined && tx.Unauthorize === undefined) { - throw new ValidationError( - 'DepositPreauth: must provide either Authorize or Unauthorize field', - ) - } + validateSingleAuthorizationFieldProvided(tx) if (tx.Authorize !== undefined) { if (typeof tx.Authorize !== 'string') { @@ -51,9 +56,7 @@ export function validateDepositPreauth(tx: Record): void { "DepositPreauth: Account can't preauthorize its own address", ) } - } - - if (tx.Unauthorize !== undefined) { + } else if (tx.Unauthorize !== undefined) { if (typeof tx.Unauthorize !== 'string') { throw new ValidationError('DepositPreauth: Unauthorize must be a string') } @@ -63,5 +66,38 @@ export function validateDepositPreauth(tx: Record): void { "DepositPreauth: Account can't unauthorize its own address", ) } + } else if (tx.AuthorizeCredentials !== undefined) { + validateCredentialsList( + tx.AuthorizeCredentials, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- confirmed in base transaction check + tx.TransactionType as string, + false, + ) + } else if (tx.UnauthorizeCredentials !== undefined) { + validateCredentialsList( + tx.UnauthorizeCredentials, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- confirmed in base transaction check + tx.TransactionType as string, + false, + ) + } +} + +// Boolean logic to ensure exactly one of 4 inputs was provided +function validateSingleAuthorizationFieldProvided( + tx: Record, +): void { + const fields = [ + 'Authorize', + 'Unauthorize', + 'AuthorizeCredentials', + 'UnauthorizeCredentials', + ] + const countProvided = fields.filter((key) => tx[key] !== undefined).length + + if (countProvided !== 1) { + throw new ValidationError( + 'DepositPreauth: Requires exactly one field of the following: Authorize, Unauthorize, AuthorizeCredentials, UnauthorizeCredentials.', + ) } } diff --git a/packages/xrpl/src/models/transactions/escrowFinish.ts b/packages/xrpl/src/models/transactions/escrowFinish.ts index e89da3c522..cba8fff202 100644 --- a/packages/xrpl/src/models/transactions/escrowFinish.ts +++ b/packages/xrpl/src/models/transactions/escrowFinish.ts @@ -5,6 +5,7 @@ import { BaseTransaction, isAccount, validateBaseTransaction, + validateCredentialsList, validateRequiredField, } from './common' @@ -32,6 +33,10 @@ export interface EscrowFinish extends BaseTransaction { * the held payment's Condition. */ Fulfillment?: string + /** Credentials associated with the sender of this transaction. + * The credentials included must not be expired. + */ + CredentialIDs?: string[] } /** @@ -45,6 +50,13 @@ export function validateEscrowFinish(tx: Record): void { validateRequiredField(tx, 'Owner', isAccount) + validateCredentialsList( + tx.CredentialIDs, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known from base check + tx.TransactionType as string, + true, + ) + if (tx.OfferSequence == null) { throw new ValidationError('EscrowFinish: missing field OfferSequence') } diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index c3c31c3901..d7e7effadb 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -32,6 +32,9 @@ export { CheckCancel } from './checkCancel' export { CheckCash } from './checkCash' export { CheckCreate } from './checkCreate' export { Clawback } from './clawback' +export { CredentialAccept } from './CredentialAccept' +export { CredentialCreate } from './CredentialCreate' +export { CredentialDelete } from './CredentialDelete' export { DIDDelete } from './DIDDelete' export { DIDSet } from './DIDSet' export { DepositPreauth } from './depositPreauth' diff --git a/packages/xrpl/src/models/transactions/payment.ts b/packages/xrpl/src/models/transactions/payment.ts index cd190ee9d7..25f3dc8974 100644 --- a/packages/xrpl/src/models/transactions/payment.ts +++ b/packages/xrpl/src/models/transactions/payment.ts @@ -12,6 +12,7 @@ import { validateOptionalField, isNumber, Account, + validateCredentialsList, } from './common' import type { TransactionMetadataBase } from './metadata' @@ -149,6 +150,11 @@ export interface Payment extends BaseTransaction { * field names are lower-case. */ DeliverMin?: Amount | MPTAmount + /** + * Credentials associated with the sender of this transaction. + * The credentials included must not be expired. + */ + CredentialIDs?: string[] Flags?: number | PaymentFlagsInterface } @@ -177,6 +183,13 @@ export function validatePayment(tx: Record): void { validateRequiredField(tx, 'Destination', isAccount) validateOptionalField(tx, 'DestinationTag', isNumber) + validateCredentialsList( + tx.CredentialIDs, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known from base check + tx.TransactionType as string, + true, + ) + if (tx.InvoiceID !== undefined && typeof tx.InvoiceID !== 'string') { throw new ValidationError('PaymentTransaction: InvoiceID must be a string') } diff --git a/packages/xrpl/src/models/transactions/paymentChannelClaim.ts b/packages/xrpl/src/models/transactions/paymentChannelClaim.ts index c673f181bc..f99368b388 100644 --- a/packages/xrpl/src/models/transactions/paymentChannelClaim.ts +++ b/packages/xrpl/src/models/transactions/paymentChannelClaim.ts @@ -1,6 +1,11 @@ import { ValidationError } from '../../errors' -import { BaseTransaction, GlobalFlags, validateBaseTransaction } from './common' +import { + BaseTransaction, + GlobalFlags, + validateBaseTransaction, + validateCredentialsList, +} from './common' /** * Enum representing values for PaymentChannelClaim transaction flags. @@ -127,6 +132,11 @@ export interface PaymentChannelClaim extends BaseTransaction { * field is omitted. */ PublicKey?: string + /** + * Credentials associated with the sender of this transaction. + * The credentials included must not be expired. + */ + CredentialIDs?: string[] } /** @@ -138,6 +148,13 @@ export interface PaymentChannelClaim extends BaseTransaction { export function validatePaymentChannelClaim(tx: Record): void { validateBaseTransaction(tx) + validateCredentialsList( + tx.CredentialIDs, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known from base check + tx.TransactionType as string, + true, + ) + if (tx.Channel === undefined) { throw new ValidationError('PaymentChannelClaim: missing Channel') } diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index f199930c6b..44840187bb 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -19,6 +19,9 @@ import { CheckCash, validateCheckCash } from './checkCash' import { CheckCreate, validateCheckCreate } from './checkCreate' import { Clawback, validateClawback } from './clawback' import { BaseTransaction, isIssuedCurrency } from './common' +import { CredentialAccept, validateCredentialAccept } from './CredentialAccept' +import { CredentialCreate, validateCredentialCreate } from './CredentialCreate' +import { CredentialDelete, validateCredentialDelete } from './CredentialDelete' import { DepositPreauth, validateDepositPreauth } from './depositPreauth' import { DIDDelete, validateDIDDelete } from './DIDDelete' import { DIDSet, validateDIDSet } from './DIDSet' @@ -122,6 +125,9 @@ export type SubmittableTransaction = | CheckCash | CheckCreate | Clawback + | CredentialAccept + | CredentialCreate + | CredentialDelete | DIDDelete | DIDSet | DepositPreauth @@ -299,6 +305,18 @@ export function validate(transaction: Record): void { validateClawback(tx) break + case 'CredentialAccept': + validateCredentialAccept(tx) + break + + case 'CredentialCreate': + validateCredentialCreate(tx) + break + + case 'CredentialDelete': + validateCredentialDelete(tx) + break + case 'DIDDelete': validateDIDDelete(tx) break diff --git a/packages/xrpl/test/integration/README.md b/packages/xrpl/test/integration/README.md index e4bdec3e33..cece8ff40c 100644 --- a/packages/xrpl/test/integration/README.md +++ b/packages/xrpl/test/integration/README.md @@ -1,7 +1,7 @@ To run integration tests: 1. Run rippled in standalone node, either in a docker container (preferred) or by installing rippled. * Go to the top-level of the `xrpl.js` repo, just above the `packages` folder. - * With docker, run `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` + * With docker, run `docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:2.3.0-rc1 -c 'rippled -a'` * Or [download and build rippled](https://xrpl.org/install-rippled.html) and run `./rippled -a --start` * If you'd like to use the latest rippled amendments, you should modify your `rippled.cfg` file to enable amendments in the `[amendments]` section. You can view `.ci-config/rippled.cfg` in the top level folder as an example of this. 2. Run `npm run test:integration` or `npm run test:browser` diff --git a/packages/xrpl/test/integration/transactions/credentialAccept.test.ts b/packages/xrpl/test/integration/transactions/credentialAccept.test.ts new file mode 100644 index 0000000000..7e8967c57d --- /dev/null +++ b/packages/xrpl/test/integration/transactions/credentialAccept.test.ts @@ -0,0 +1,62 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { + AccountObjectsResponse, + CredentialAccept, + CredentialCreate, +} from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { generateFundedWallet, testTransaction } from '../utils' + +describe('CredentialAccept', function () { + // testContext wallet acts as issuer in this test + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const subjectWallet = await generateFundedWallet(testContext.client) + + const credentialCreateTx: CredentialCreate = { + TransactionType: 'CredentialCreate', + Account: testContext.wallet.classicAddress, + Subject: subjectWallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialCreateTx, + testContext.wallet, + ) + + const credentialAcceptTx: CredentialAccept = { + TransactionType: 'CredentialAccept', + Account: subjectWallet.classicAddress, + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction(testContext.client, credentialAcceptTx, subjectWallet) + + // Credential is now an object in recipient's wallet after accept + const accountObjectsResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: subjectWallet.classicAddress, + type: 'credential', + }) + const { account_objects } = accountObjectsResponse.result + + assert.equal(account_objects.length, 1) + }) +}) diff --git a/packages/xrpl/test/integration/transactions/credentialCreate.test.ts b/packages/xrpl/test/integration/transactions/credentialCreate.test.ts new file mode 100644 index 0000000000..1d26595853 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/credentialCreate.test.ts @@ -0,0 +1,49 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { AccountObjectsResponse, CredentialCreate } from '../../../src' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { generateFundedWallet, testTransaction } from '../utils' + +describe('CredentialCreate', function () { + // testContext wallet acts as issuer in this test + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const subjectWallet = await generateFundedWallet(testContext.client) + + const credentialCreateTx: CredentialCreate = { + TransactionType: 'CredentialCreate', + Account: testContext.wallet.classicAddress, + Subject: subjectWallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialCreateTx, + testContext.wallet, + ) + + // Unaccepted credential still belongs to issuer's account + const accountObjectsResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + type: 'credential', + }) + const { account_objects } = accountObjectsResponse.result + + assert.equal(account_objects.length, 1) + }) +}) diff --git a/packages/xrpl/test/integration/transactions/credentialDelete.test.ts b/packages/xrpl/test/integration/transactions/credentialDelete.test.ts new file mode 100644 index 0000000000..246397c66f --- /dev/null +++ b/packages/xrpl/test/integration/transactions/credentialDelete.test.ts @@ -0,0 +1,105 @@ +import { stringToHex } from '@xrplf/isomorphic/utils' +import { assert } from 'chai' + +import { + AccountObjectsResponse, + CredentialAccept, + CredentialCreate, +} from '../../../src' +import { CredentialDelete } from '../../../src/models/transactions/CredentialDelete' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { generateFundedWallet, testTransaction } from '../utils' + +describe('CredentialDelete', function () { + // testContext wallet acts as issuer in this test + let testContext: XrplIntegrationTestContext + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + }) + afterAll(async () => teardownClient(testContext)) + + it('base', async function () { + const subjectWallet = await generateFundedWallet(testContext.client) + + const credentialCreateTx: CredentialCreate = { + TransactionType: 'CredentialCreate', + Account: testContext.wallet.classicAddress, + Subject: subjectWallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialCreateTx, + testContext.wallet, + ) + + const createAccountObjectsResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + type: 'credential', + }) + + assert.equal(createAccountObjectsResponse.result.account_objects.length, 1) + + const credentialAcceptTx: CredentialAccept = { + TransactionType: 'CredentialAccept', + Account: subjectWallet.classicAddress, + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction(testContext.client, credentialAcceptTx, subjectWallet) + + // Credential is now an object in recipient's wallet after accept + const acceptAccountObjectsResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: subjectWallet.classicAddress, + type: 'credential', + }) + + assert.equal(acceptAccountObjectsResponse.result.account_objects.length, 1) + + const credentialDeleteTx: CredentialDelete = { + TransactionType: 'CredentialDelete', + Account: subjectWallet.classicAddress, + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction(testContext.client, credentialDeleteTx, subjectWallet) + + // Check both issuer and subject no longer have a credential tied to the account + const SubjectAccountObjectsDeleteResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: subjectWallet.classicAddress, + type: 'credential', + }) + + assert.equal( + SubjectAccountObjectsDeleteResponse.result.account_objects.length, + 0, + ) + + const IssuerAccountObjectsDeleteResponse: AccountObjectsResponse = + await testContext.client.request({ + command: 'account_objects', + account: testContext.wallet.classicAddress, + type: 'credential', + }) + + assert.equal( + IssuerAccountObjectsDeleteResponse.result.account_objects.length, + 0, + ) + }) +}) diff --git a/packages/xrpl/test/integration/transactions/depositPreauth.test.ts b/packages/xrpl/test/integration/transactions/depositPreauth.test.ts index 4a8c8dcc3e..13960ffe01 100644 --- a/packages/xrpl/test/integration/transactions/depositPreauth.test.ts +++ b/packages/xrpl/test/integration/transactions/depositPreauth.test.ts @@ -1,11 +1,19 @@ -import { DepositPreauth, Wallet } from '../../../src' +import { stringToHex } from '@xrplf/isomorphic/utils' + +import { + AuthorizeCredential, + CredentialAccept, + CredentialCreate, + DepositPreauth, + Wallet, +} from '../../../src' import serverUrl from '../serverUrl' import { setupClient, teardownClient, type XrplIntegrationTestContext, } from '../setup' -import { fundAccount, testTransaction } from '../utils' +import { fundAccount, generateFundedWallet, testTransaction } from '../utils' // how long before each test case times out const TIMEOUT = 20000 @@ -32,4 +40,119 @@ describe('DepositPreauth', function () { }, TIMEOUT, ) + + it( + 'AuthorizeCredential base case', + async () => { + const subjectWallet = await generateFundedWallet(testContext.client) + + const credentialCreateTx: CredentialCreate = { + TransactionType: 'CredentialCreate', + Account: testContext.wallet.classicAddress, + Subject: subjectWallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialCreateTx, + testContext.wallet, + ) + + const credentialAcceptTx: CredentialAccept = { + TransactionType: 'CredentialAccept', + Account: subjectWallet.classicAddress, + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialAcceptTx, + subjectWallet, + ) + + const authorizeCredentialObj: AuthorizeCredential = { + Credential: { + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + }, + } + + const wallet2 = Wallet.generate() + await fundAccount(testContext.client, wallet2) + const tx: DepositPreauth = { + TransactionType: 'DepositPreauth', + Account: testContext.wallet.classicAddress, + AuthorizeCredentials: [authorizeCredentialObj], + } + await testTransaction(testContext.client, tx, testContext.wallet) + }, + TIMEOUT, + ) + + it( + 'UnauthorizeCredential base case', + async () => { + const subjectWallet = await generateFundedWallet(testContext.client) + + const credentialCreateTx: CredentialCreate = { + TransactionType: 'CredentialCreate', + Account: testContext.wallet.classicAddress, + Subject: subjectWallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialCreateTx, + testContext.wallet, + ) + + const credentialAcceptTx: CredentialAccept = { + TransactionType: 'CredentialAccept', + Account: subjectWallet.classicAddress, + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + } + + await testTransaction( + testContext.client, + credentialAcceptTx, + subjectWallet, + ) + + const authorizeCredentialObj: AuthorizeCredential = { + Credential: { + Issuer: testContext.wallet.classicAddress, + CredentialType: stringToHex('Test Credential Type'), + }, + } + + const wallet2 = Wallet.generate() + await fundAccount(testContext.client, wallet2) + const authCredDepositPreauthTx: DepositPreauth = { + TransactionType: 'DepositPreauth', + Account: testContext.wallet.classicAddress, + AuthorizeCredentials: [authorizeCredentialObj], + } + await testTransaction( + testContext.client, + authCredDepositPreauthTx, + testContext.wallet, + ) + + const UnauthCredDepositPreauthTx: DepositPreauth = { + TransactionType: 'DepositPreauth', + Account: testContext.wallet.classicAddress, + UnauthorizeCredentials: [authorizeCredentialObj], + } + await testTransaction( + testContext.client, + UnauthCredDepositPreauthTx, + testContext.wallet, + ) + }, + TIMEOUT, + ) }) diff --git a/packages/xrpl/test/integration/transactions/payment.test.ts b/packages/xrpl/test/integration/transactions/payment.test.ts index 17fba05f8a..2cccfc2c77 100644 --- a/packages/xrpl/test/integration/transactions/payment.test.ts +++ b/packages/xrpl/test/integration/transactions/payment.test.ts @@ -135,7 +135,7 @@ describe('Payment', function () { const meta = txResponse.result .meta as TransactionMetadata - const mptID = meta.mpt_issuance_id + const mptID = meta.mpt_issuance_id! let accountObjectsResponse = await testContext.client.request({ command: 'account_objects', @@ -151,7 +151,7 @@ describe('Payment', function () { const authTx: MPTokenAuthorize = { TransactionType: 'MPTokenAuthorize', Account: wallet2.classicAddress, - MPTokenIssuanceID: mptID!, + MPTokenIssuanceID: mptID, } await testTransaction(testContext.client, authTx, wallet2) @@ -173,7 +173,7 @@ describe('Payment', function () { Account: testContext.wallet.classicAddress, Destination: wallet2.classicAddress, Amount: { - mpt_issuance_id: mptID!, + mpt_issuance_id: mptID, value: '100', }, } diff --git a/packages/xrpl/test/models/CredentialAccept.test.ts b/packages/xrpl/test/models/CredentialAccept.test.ts new file mode 100644 index 0000000000..042101bd1f --- /dev/null +++ b/packages/xrpl/test/models/CredentialAccept.test.ts @@ -0,0 +1,153 @@ +import { stringToHex } from '@xrplf/isomorphic/dist/utils' +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateCredentialAccept } from '../../src/models/transactions/CredentialAccept' + +/** + * CredentialAccept Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('CredentialAccept', function () { + let credentialAccept + + beforeEach(function () { + credentialAccept = { + TransactionType: 'CredentialAccept', + Issuer: 'r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ', + Account: 'rNdY9XDnQ4Dr1EgefwU3CBRuAjt3sAutGg', + CredentialType: stringToHex('Passport'), + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid CredentialAccept`, function () { + assert.doesNotThrow(() => validateCredentialAccept(credentialAccept)) + assert.doesNotThrow(() => validate(credentialAccept)) + }) + + it(`throws w/ missing field Account`, function () { + credentialAccept.Account = undefined + const errorMessage = 'CredentialAccept: missing field Account' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Account not a string`, function () { + credentialAccept.Account = 123 + const errorMessage = 'CredentialAccept: invalid field Account' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field Issuer`, function () { + credentialAccept.Issuer = undefined + const errorMessage = 'CredentialAccept: missing field Issuer' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Issuer not a string`, function () { + credentialAccept.Issuer = 123 + const errorMessage = 'CredentialAccept: invalid field Issuer' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field CredentialType`, function () { + credentialAccept.CredentialType = undefined + const errorMessage = 'CredentialAccept: missing field CredentialType' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field too long`, function () { + credentialAccept.CredentialType = stringToHex('A'.repeat(129)) + const errorMessage = + 'CredentialAccept: CredentialType length cannot be > 128' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field empty`, function () { + credentialAccept.CredentialType = '' + const errorMessage = + 'CredentialAccept: CredentialType cannot be an empty string' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field not hex`, function () { + credentialAccept.CredentialType = 'this is not hex' + const errorMessage = + 'CredentialAccept: CredentialType must be encoded in hex' + assert.throws( + () => validateCredentialAccept(credentialAccept), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialAccept), + ValidationError, + errorMessage, + ) + }) +}) diff --git a/packages/xrpl/test/models/CredentialCreate.test.ts b/packages/xrpl/test/models/CredentialCreate.test.ts new file mode 100644 index 0000000000..96a530c538 --- /dev/null +++ b/packages/xrpl/test/models/CredentialCreate.test.ts @@ -0,0 +1,230 @@ +import { stringToHex } from '@xrplf/isomorphic/dist/utils' +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateCredentialCreate } from '../../src/models/transactions/CredentialCreate' + +/** + * CredentialCreate Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('credentialCreate', function () { + let credentialCreate + + beforeEach(function () { + credentialCreate = { + TransactionType: 'CredentialCreate', + Account: 'r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ', + Subject: 'rNdY9XDnQ4Dr1EgefwU3CBRuAjt3sAutGg', + CredentialType: stringToHex('Passport'), + Expiration: 1212025, + URI: stringToHex('TestURI'), + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid credentialCreate`, function () { + assert.doesNotThrow(() => validateCredentialCreate(credentialCreate)) + assert.doesNotThrow(() => validate(credentialCreate)) + }) + + it(`throws w/ missing field Account`, function () { + credentialCreate.Account = undefined + const errorMessage = 'CredentialCreate: missing field Account' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Account not string`, function () { + credentialCreate.Account = 123 + const errorMessage = 'CredentialCreate: invalid field Account' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field Subject`, function () { + credentialCreate.Subject = undefined + const errorMessage = 'CredentialCreate: missing field Subject' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Subject not string`, function () { + credentialCreate.Subject = 123 + const errorMessage = 'CredentialCreate: invalid field Subject' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field credentialType`, function () { + credentialCreate.CredentialType = undefined + const errorMessage = 'CredentialCreate: missing field CredentialType' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field too long`, function () { + credentialCreate.CredentialType = stringToHex('A'.repeat(129)) + const errorMessage = + 'CredentialCreate: CredentialType length cannot be > 128' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field empty`, function () { + credentialCreate.CredentialType = '' + const errorMessage = + 'CredentialCreate: CredentialType cannot be an empty string' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field not hex`, function () { + credentialCreate.CredentialType = 'this is not hex' + const errorMessage = + 'CredentialCreate: CredentialType must be encoded in hex' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Expiration field not number`, function () { + credentialCreate.Expiration = 'this is not a number' + const errorMessage = 'CredentialCreate: invalid field Expiration' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ URI field not a string`, function () { + credentialCreate.URI = 123 + const errorMessage = 'CredentialCreate: invalid field URI' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ URI field empty`, function () { + credentialCreate.URI = '' + const errorMessage = 'CredentialCreate: URI cannot be an empty string' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ URI field too long`, function () { + credentialCreate.URI = stringToHex('A'.repeat(129)) + const errorMessage = 'CredentialCreate: URI length must be <= 256' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ URI field not hex`, function () { + credentialCreate.URI = 'this is not hex' + const errorMessage = 'CredentialCreate: URI must be encoded in hex' + assert.throws( + () => validateCredentialCreate(credentialCreate), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialCreate), + ValidationError, + errorMessage, + ) + }) +}) diff --git a/packages/xrpl/test/models/CredentialDelete.test.ts b/packages/xrpl/test/models/CredentialDelete.test.ts new file mode 100644 index 0000000000..bb1ebc12c3 --- /dev/null +++ b/packages/xrpl/test/models/CredentialDelete.test.ts @@ -0,0 +1,171 @@ +import { stringToHex } from '@xrplf/isomorphic/dist/utils' +import { assert } from 'chai' + +import { validate, ValidationError } from '../../src' +import { validateCredentialDelete } from '../../src/models/transactions/CredentialDelete' + +/** + * CredentialDelete Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('CredentialDelete', function () { + let credentialDelete + + beforeEach(function () { + credentialDelete = { + TransactionType: 'CredentialDelete', + Issuer: 'r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ', + Subject: 'rNdY9XDnQ4Dr1EgefwU3CBRuAjt3sAutGg', + Account: 'rNdY9XDnQ4Dr1EgefwU3CBRuAjt3sAutGg', + CredentialType: stringToHex('Passport'), + Sequence: 1337, + Flags: 0, + } as any + }) + + it(`verifies valid credentialDelete`, function () { + assert.doesNotThrow(() => validateCredentialDelete(credentialDelete)) + assert.doesNotThrow(() => validate(credentialDelete)) + }) + + it(`throws w/ missing field Account`, function () { + credentialDelete.Account = undefined + const errorMessage = 'CredentialDelete: missing field Account' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Account not string`, function () { + credentialDelete.Account = 123 + const errorMessage = 'CredentialDelete: invalid field Account' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Subject not string`, function () { + credentialDelete.Subject = 123 + const errorMessage = 'CredentialDelete: invalid field Subject' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ Issuer not string`, function () { + credentialDelete.Issuer = 123 + const errorMessage = 'CredentialDelete: invalid field Issuer' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field Subject and Issuer`, function () { + credentialDelete.Subject = undefined + credentialDelete.Issuer = undefined + const errorMessage = + 'CredentialDelete: either `Issuer` or `Subject` must be provided' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ missing field credentialType`, function () { + credentialDelete.CredentialType = undefined + const errorMessage = 'CredentialDelete: missing field CredentialType' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field too long`, function () { + credentialDelete.CredentialType = stringToHex('A'.repeat(129)) + const errorMessage = + 'CredentialDelete: CredentialType length cannot be > 128' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field empty`, function () { + credentialDelete.CredentialType = '' + const errorMessage = + 'CredentialDelete: CredentialType cannot be an empty string' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ credentialType field not hex`, function () { + credentialDelete.CredentialType = 'this is not hex' + const errorMessage = + 'CredentialDelete: CredentialType must be encoded in hex' + assert.throws( + () => validateCredentialDelete(credentialDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(credentialDelete), + ValidationError, + errorMessage, + ) + }) +}) diff --git a/packages/xrpl/test/models/accountDelete.test.ts b/packages/xrpl/test/models/accountDelete.test.ts index 4729bd7f03..34b0cfa0f0 100644 --- a/packages/xrpl/test/models/accountDelete.test.ts +++ b/packages/xrpl/test/models/accountDelete.test.ts @@ -9,8 +9,10 @@ import { validateAccountDelete } from '../../src/models/transactions/accountDele * Providing runtime verification testing for each specific transaction type. */ describe('AccountDelete', function () { - it(`verifies valid AccountDelete`, function () { - const validAccountDelete = { + let validAccountDelete + + beforeEach(() => { + validAccountDelete = { TransactionType: 'AccountDelete', Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', Destination: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', @@ -18,76 +20,166 @@ describe('AccountDelete', function () { Fee: '5000000', Sequence: 2470665, Flags: 2147483648, + CredentialIDs: [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A', + ], } as any - + }) + it(`verifies valid AccountDelete`, function () { assert.doesNotThrow(() => validateAccountDelete(validAccountDelete)) }) it(`throws w/ missing Destination`, function () { - const invalidDestination = { - TransactionType: 'AccountDelete', - Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', - Fee: '5000000', - Sequence: 2470665, - Flags: 2147483648, - } as any + validAccountDelete.Destination = undefined + const errorMessage = 'AccountDelete: missing field Destination' assert.throws( - () => validateAccountDelete(invalidDestination), + () => validateAccountDelete(validAccountDelete), ValidationError, - 'AccountDelete: missing field Destination', + errorMessage, ) assert.throws( - () => validate(invalidDestination), + () => validate(validAccountDelete), ValidationError, - 'AccountDelete: missing field Destination', + errorMessage, ) }) it(`throws w/ invalid Destination`, function () { - const invalidDestination = { - TransactionType: 'AccountDelete', - Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', - Destination: 65478965, - Fee: '5000000', - Sequence: 2470665, - Flags: 2147483648, - } as any + validAccountDelete.Destination = 65478965 + const errorMessage = 'AccountDelete: invalid field Destination' assert.throws( - () => validateAccountDelete(invalidDestination), + () => validateAccountDelete(validAccountDelete), ValidationError, - 'AccountDelete: invalid field Destination', + errorMessage, ) assert.throws( - () => validate(invalidDestination), + () => validate(validAccountDelete), ValidationError, - 'AccountDelete: invalid field Destination', + errorMessage, ) }) it(`throws w/ invalid DestinationTag`, function () { - const invalidDestinationTag = { - TransactionType: 'AccountDelete', - Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', - Destination: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', - DestinationTag: 'gvftyujnbv', - Fee: '5000000', - Sequence: 2470665, - Flags: 2147483648, - } as any + validAccountDelete.DestinationTag = 'gvftyujnbv' + const errorMessage = 'AccountDelete: invalid field DestinationTag' + + assert.throws( + () => validateAccountDelete(validAccountDelete), + ValidationError, + errorMessage, + ) + + assert.throws( + () => validate(validAccountDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ non-array CredentialIDs`, function () { + validAccountDelete.CredentialIDs = + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A' + + const errorMessage = 'AccountDelete: Credentials must be an array' + + assert.throws( + () => validateAccountDelete(validAccountDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(validAccountDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws CredentialIDs length exceeds max length`, function () { + validAccountDelete.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66B', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66C', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66D', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66E', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66F', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F660', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F661', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = + 'AccountDelete: Credentials length cannot exceed 8 elements' assert.throws( - () => validateAccountDelete(invalidDestinationTag), + () => validateAccountDelete(validAccountDelete), ValidationError, - 'AccountDelete: invalid field DestinationTag', + errorMessage, ) + assert.throws( + () => validate(validAccountDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ empty CredentialIDs`, function () { + validAccountDelete.CredentialIDs = [] + const errorMessage = 'AccountDelete: Credentials cannot be an empty array' + + assert.throws( + () => validateAccountDelete(validAccountDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(validAccountDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ non-string CredentialIDs`, function () { + validAccountDelete.CredentialIDs = [ + 123123, + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = 'AccountDelete: Invalid Credentials ID list format' + + assert.throws( + () => validateAccountDelete(validAccountDelete), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(validAccountDelete), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ duplicate CredentialIDs`, function () { + validAccountDelete.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = + 'AccountDelete: Credentials cannot contain duplicate elements' + + assert.throws( + () => validateAccountDelete(validAccountDelete), + ValidationError, + errorMessage, + ) assert.throws( - () => validate(invalidDestinationTag), + () => validate(validAccountDelete), ValidationError, - 'AccountDelete: invalid field DestinationTag', + errorMessage, ) }) }) diff --git a/packages/xrpl/test/models/depositPreauth.test.ts b/packages/xrpl/test/models/depositPreauth.test.ts index 11e3305713..d2598ba452 100644 --- a/packages/xrpl/test/models/depositPreauth.test.ts +++ b/packages/xrpl/test/models/depositPreauth.test.ts @@ -1,6 +1,7 @@ +import { stringToHex } from '@xrplf/isomorphic/dist/utils' import { assert } from 'chai' -import { validate, ValidationError } from '../../src' +import { AuthorizeCredential, validate, ValidationError } from '../../src' import { validateDepositPreauth } from '../../src/models/transactions/depositPreauth' /** @@ -11,6 +12,13 @@ import { validateDepositPreauth } from '../../src/models/transactions/depositPre describe('DepositPreauth', function () { let depositPreauth + const validCredential = { + Credential: { + Issuer: 'rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW', + CredentialType: stringToHex('Passport'), + }, + } + beforeEach(function () { depositPreauth = { TransactionType: 'DepositPreauth', @@ -30,32 +38,73 @@ describe('DepositPreauth', function () { assert.doesNotThrow(() => validate(depositPreauth)) }) - it('throws when both Authorize and Unauthorize are provided', function () { + it('verifies valid DepositPreauth when only AuthorizeCredentials is provided', function () { + depositPreauth.AuthorizeCredentials = [validCredential] + assert.doesNotThrow(() => validateDepositPreauth(depositPreauth)) + assert.doesNotThrow(() => validate(depositPreauth)) + }) + + it('verifies valid DepositPreauth when only UnauthorizeCredentials is provided', function () { + depositPreauth.UnauthorizeCredentials = [validCredential] + assert.doesNotThrow(() => validateDepositPreauth(depositPreauth)) + assert.doesNotThrow(() => validate(depositPreauth)) + }) + + it('throws when multiple of Authorize, Unauthorize, AuthorizeCredentials, UnauthorizeCredentials are provided', function () { + const errorMessage = + 'DepositPreauth: Requires exactly one field of the following: Authorize, Unauthorize, AuthorizeCredentials, UnauthorizeCredentials.' + depositPreauth.Authorize = 'rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW' + depositPreauth.UnauthorizeCredentials = [validCredential] + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + depositPreauth.Unauthorize = 'raKEEVSGnKSD9Zyvxu4z6Pqpm4ABH8FS6n' assert.throws( () => validateDepositPreauth(depositPreauth), ValidationError, - "DepositPreauth: can't provide both Authorize and Unauthorize fields", + errorMessage, ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + + depositPreauth.AuthorizeCredentials = [validCredential] assert.throws( - () => validate(depositPreauth), + () => validateDepositPreauth(depositPreauth), ValidationError, - "DepositPreauth: can't provide both Authorize and Unauthorize fields", + errorMessage, ) - }) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) - it('throws when neither Authorize nor Unauthorize are provided', function () { + depositPreauth.Authorize = undefined assert.throws( () => validateDepositPreauth(depositPreauth), ValidationError, - 'DepositPreauth: must provide either Authorize or Unauthorize field', + errorMessage, ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + + depositPreauth.UnauthorizeCredentials = undefined assert.throws( - () => validate(depositPreauth), + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when none of Authorize, Unauthorize, AuthorizeCredentials, UnauthorizeCredentials are provided', function () { + const errorMessage = + 'DepositPreauth: Requires exactly one field of the following: Authorize, Unauthorize, AuthorizeCredentials, UnauthorizeCredentials.' + assert.throws( + () => validateDepositPreauth(depositPreauth), ValidationError, - 'DepositPreauth: must provide either Authorize or Unauthorize field', + errorMessage, ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) }) it('throws when Authorize is not a string', function () { @@ -108,4 +157,154 @@ describe('DepositPreauth', function () { "DepositPreauth: Account can't unauthorize its own address", ) }) + + it('throws when AuthorizeCredentials is not an array', function () { + const errorMessage = 'DepositPreauth: Credentials must be an array' + depositPreauth.AuthorizeCredentials = validCredential + + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when UnauthorizeCredentials is not an array', function () { + const errorMessage = 'DepositPreauth: Credentials must be an array' + depositPreauth.UnauthorizeCredentials = validCredential + + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when AuthorizeCredentials is empty array', function () { + const errorMessage = 'DepositPreauth: Credentials cannot be an empty array' + depositPreauth.AuthorizeCredentials = [] + + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when UnauthorizeCredentials is empty array', function () { + const errorMessage = 'DepositPreauth: Credentials cannot be an empty array' + depositPreauth.UnauthorizeCredentials = [] + + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when AuthorizeCredentials is too long', function () { + const sampleCredentials: AuthorizeCredential[] = [] + const errorMessage = + 'DepositPreauth: Credentials length cannot exceed 8 elements' + for (let index = 0; index < 9; index++) { + sampleCredentials.push({ + Credential: { + Issuer: `SampleIssuer${index}`, + CredentialType: stringToHex('Passport'), + }, + }) + } + depositPreauth.AuthorizeCredentials = sampleCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when UnauthorizeCredentials is too long', function () { + const sampleCredentials: AuthorizeCredential[] = [] + const errorMessage = + 'DepositPreauth: Credentials length cannot exceed 8 elements' + for (let index = 0; index < 9; index++) { + sampleCredentials.push({ + Credential: { + Issuer: `SampleIssuer${index}`, + CredentialType: stringToHex('Passport'), + }, + }) + } + depositPreauth.UnauthorizeCredentials = sampleCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when AuthorizeCredentials is invalid shape', function () { + const invalidCredentials = [ + { Credential: 'Invalid Shape' }, + { Credential: 'Another Invalid Shape' }, + ] + const errorMessage = 'DepositPreauth: Invalid Credentials format' + + depositPreauth.AuthorizeCredentials = invalidCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when UnauthorizeCredentials is invalid shape', function () { + const invalidCredentials = [ + { Credential: 'Invalid Shape' }, + { Credential: 'Another Invalid Shape' }, + ] + const errorMessage = 'DepositPreauth: Invalid Credentials format' + + depositPreauth.UnauthorizeCredentials = invalidCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when AuthorizeCredentials has duplicates', function () { + const invalidCredentials = [validCredential, validCredential] + const errorMessage = + 'DepositPreauth: Credentials cannot contain duplicate elements' + + depositPreauth.AuthorizeCredentials = invalidCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) + + it('throws when UnauthorizeCredentials has duplicates', function () { + const invalidCredentials = [validCredential, validCredential] + const errorMessage = + 'DepositPreauth: Credentials cannot contain duplicate elements' + + depositPreauth.UnauthorizeCredentials = invalidCredentials + assert.throws( + () => validateDepositPreauth(depositPreauth), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(depositPreauth), ValidationError, errorMessage) + }) }) diff --git a/packages/xrpl/test/models/escrowFinish.test.ts b/packages/xrpl/test/models/escrowFinish.test.ts index e9d772a872..cac92d1e15 100644 --- a/packages/xrpl/test/models/escrowFinish.test.ts +++ b/packages/xrpl/test/models/escrowFinish.test.ts @@ -20,6 +20,9 @@ describe('EscrowFinish', function () { Condition: 'A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100', Fulfillment: 'A0028000', + CredentialIDs: [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A', + ], } }) it(`verifies valid EscrowFinish`, function () { @@ -28,8 +31,9 @@ describe('EscrowFinish', function () { }) it(`verifies valid EscrowFinish w/o optional`, function () { - delete escrow.Condition - delete escrow.Fulfillment + escrow.Condition = undefined + escrow.Fulfillment = undefined + escrow.CredentialIDs = undefined assert.doesNotThrow(() => validateEscrowFinish(escrow)) assert.doesNotThrow(() => validate(escrow)) @@ -101,4 +105,88 @@ describe('EscrowFinish', function () { 'EscrowFinish: Fulfillment must be a string', ) }) + + it(`throws w/ non-array CredentialIDs`, function () { + escrow.CredentialIDs = + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A' + + const errorMessage = 'EscrowFinish: Credentials must be an array' + + assert.throws( + () => validateEscrowFinish(escrow), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(escrow), ValidationError, errorMessage) + }) + + it(`throws CredentialIDs length exceeds max length`, function () { + escrow.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66B', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66C', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66D', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66E', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66F', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F660', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F661', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = + 'EscrowFinish: Credentials length cannot exceed 8 elements' + + assert.throws( + () => validateEscrowFinish(escrow), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(escrow), ValidationError, errorMessage) + }) + + it(`throws w/ empty CredentialIDs`, function () { + escrow.CredentialIDs = [] + + const errorMessage = 'EscrowFinish: Credentials cannot be an empty array' + + assert.throws( + () => validateEscrowFinish(escrow), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(escrow), ValidationError, errorMessage) + }) + + it(`throws w/ non-string CredentialIDs`, function () { + escrow.CredentialIDs = [ + 123123, + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = 'EscrowFinish: Invalid Credentials ID list format' + + assert.throws( + () => validateEscrowFinish(escrow), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(escrow), ValidationError, errorMessage) + }) + + it(`throws w/ duplicate CredentialIDs`, function () { + escrow.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = + 'EscrowFinish: Credentials cannot contain duplicate elements' + + assert.throws( + () => validateEscrowFinish(escrow), + ValidationError, + errorMessage, + ) + assert.throws(() => validate(escrow), ValidationError, errorMessage) + }) }) diff --git a/packages/xrpl/test/models/payment.test.ts b/packages/xrpl/test/models/payment.test.ts index d4e36d5486..1af633e0a1 100644 --- a/packages/xrpl/test/models/payment.test.ts +++ b/packages/xrpl/test/models/payment.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-statements -- need additional tests for optional fields */ import { assert } from 'chai' import { validate, PaymentFlags, ValidationError } from '../../src' @@ -272,4 +273,107 @@ describe('Payment', function () { assert.doesNotThrow(() => validatePayment(mptPaymentTransaction)) assert.doesNotThrow(() => validate(mptPaymentTransaction)) }) + + it(`throws w/ non-array CredentialIDs`, function () { + paymentTransaction.CredentialIDs = + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A' + + const errorMessage = 'Payment: Credentials must be an array' + + assert.throws( + () => validatePayment(paymentTransaction), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(paymentTransaction), + ValidationError, + errorMessage, + ) + }) + + it(`throws CredentialIDs length exceeds max length`, function () { + paymentTransaction.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66A', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66B', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66C', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66D', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66E', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F66F', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F660', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F661', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = 'Payment: Credentials length cannot exceed 8 elements' + + assert.throws( + () => validatePayment(paymentTransaction), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(paymentTransaction), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ empty CredentialIDs`, function () { + paymentTransaction.CredentialIDs = [] + + const errorMessage = 'Payment: Credentials cannot be an empty array' + + assert.throws( + () => validatePayment(paymentTransaction), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(paymentTransaction), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ non-string CredentialIDs`, function () { + paymentTransaction.CredentialIDs = [ + 123123, + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = 'Payment: Invalid Credentials ID list format' + + assert.throws( + () => validatePayment(paymentTransaction), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(paymentTransaction), + ValidationError, + errorMessage, + ) + }) + + it(`throws w/ duplicate CredentialIDs`, function () { + paymentTransaction.CredentialIDs = [ + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + 'EA85602C1B41F6F1F5E83C0E6B87142FB8957BD209469E4CC347BA2D0C26F662', + ] + + const errorMessage = + 'Payment: Credentials cannot contain duplicate elements' + + assert.throws( + () => validatePayment(paymentTransaction), + ValidationError, + errorMessage, + ) + assert.throws( + () => validate(paymentTransaction), + ValidationError, + errorMessage, + ) + }) })