diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index a0710d4eba..2cf4b39c8a 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -6,6 +6,7 @@ ### Added * Support for the Price Oracles amendment (XLS-47). +* Add `NFTokenModify` transaction and add `tfMutable` flag in `NFTokenMint` ### Fixed * Better error handling/error messages for serialization/deserialization errors. diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 797be9ce21..473d6e74f4 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -2974,6 +2974,7 @@ "DIDDelete": 50, "OracleSet": 51, "OracleDelete": 52, + "NFTokenModify": 53, "EnableAmendment": 100, "SetFee": 101, "UNLModify": 102 diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 2c534d0545..c70d0e0eb5 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -10,6 +10,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Added * Add `nfts_by_issuer` clio-only API definition +* Add `NFTokenModify` transaction and add `tfMutable` flag in `NFTokenMint` ## 3.1.0 (2024-06-03) diff --git a/packages/xrpl/src/models/transactions/NFTokenMint.ts b/packages/xrpl/src/models/transactions/NFTokenMint.ts index 2630a6b9c6..1bd7947929 100644 --- a/packages/xrpl/src/models/transactions/NFTokenMint.ts +++ b/packages/xrpl/src/models/transactions/NFTokenMint.ts @@ -38,6 +38,10 @@ export enum NFTokenMintFlags { * issuer. */ tfTransferable = 0x00000008, + /** + * If set, indicates that this NFT's URI can be modified. + */ + tfMutable = 0x00000010, } /** @@ -51,6 +55,7 @@ export interface NFTokenMintFlagsInterface extends GlobalFlags { tfOnlyXRP?: boolean tfTrustLine?: boolean tfTransferable?: boolean + tfMutable?: boolean } /** diff --git a/packages/xrpl/src/models/transactions/NFTokenModify.ts b/packages/xrpl/src/models/transactions/NFTokenModify.ts new file mode 100644 index 0000000000..e67603fbf7 --- /dev/null +++ b/packages/xrpl/src/models/transactions/NFTokenModify.ts @@ -0,0 +1,67 @@ +import { ValidationError } from '../../errors' +import { isHex } from '../utils' + +import { + BaseTransaction, + validateBaseTransaction, + isAccount, + isString, + validateOptionalField, + Account, + validateRequiredField, +} from './common' + +/** + * The NFTokenModify transaction modifies an NFToken's URI + * if its tfMutable is set to true. + */ +export interface NFTokenModify extends BaseTransaction { + TransactionType: 'NFTokenModify' + /** + * Identifies the NFTokenID of the NFToken object that the + * offer references. + */ + NFTokenID: string + /** + * Indicates the AccountID of the account that owns the corresponding NFToken. + * Can be omitted if the owner is the account submitting this transaction + */ + Owner?: Account + /** + * URI that points to the data and/or metadata associated with the NFT. + * This field need not be an HTTP or HTTPS URL; it could be an IPFS URI, a + * magnet link, immediate data encoded as an RFC2379 "data" URL, or even an + * opaque issuer-specific encoding. The URI is NOT checked for validity, but + * the field is limited to a maximum length of 256 bytes. + * + * This field must be hex-encoded. You can use `convertStringToHex` to + * convert this field to the proper encoding. + * + * This field must not be an empty string. Omit it from the transaction or + * set to `undefined` value if you do not use it. + */ + URI?: string | null +} + +/** + * Verify the form and type of an NFTokenModify at runtime. + * + * @param tx - An NFTokenModify Transaction. + * @throws When the NFTokenModify is Malformed. + */ +export function validateNFTokenModify(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'NFTokenID', isString) + validateOptionalField(tx, 'Owner', isAccount) + validateOptionalField(tx, 'URI', isString) + + if (tx.URI !== undefined && typeof tx.URI === 'string') { + if (tx.URI === '') { + throw new ValidationError('NFTokenModify: URI must not be empty string') + } + if (!isHex(tx.URI)) { + throw new ValidationError('NFTokenModify: URI must be in hex format') + } + } +} diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index c7a8120758..6ca9cc94f4 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -52,6 +52,7 @@ export { NFTokenMintFlags, NFTokenMintFlagsInterface, } from './NFTokenMint' +export { NFTokenModify, validateNFTokenModify } from './NFTokenModify' export { OfferCancel } from './offerCancel' export { OfferCreateFlags, diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index 0ddc719539..cc8d09aab9 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -41,6 +41,7 @@ import { validateNFTokenCreateOffer, } from './NFTokenCreateOffer' import { NFTokenMint, validateNFTokenMint } from './NFTokenMint' +import { NFTokenModify, validateNFTokenModify } from './NFTokenModify' import { OfferCancel, validateOfferCancel } from './offerCancel' import { OfferCreate, validateOfferCreate } from './offerCreate' import { OracleDelete, validateOracleDelete } from './oracleDelete' @@ -120,6 +121,7 @@ export type SubmittableTransaction = | NFTokenCancelOffer | NFTokenCreateOffer | NFTokenMint + | NFTokenModify | OfferCancel | OfferCreate | OracleDelete @@ -326,6 +328,10 @@ export function validate(transaction: Record): void { validateNFTokenMint(tx) break + case 'NFTokenModify': + validateNFTokenModify(tx) + break + case 'OfferCancel': validateOfferCancel(tx) break diff --git a/packages/xrpl/test/integration/transactions/nftokenModify.test.ts b/packages/xrpl/test/integration/transactions/nftokenModify.test.ts new file mode 100644 index 0000000000..cd2b561fbc --- /dev/null +++ b/packages/xrpl/test/integration/transactions/nftokenModify.test.ts @@ -0,0 +1,104 @@ +import { assert } from 'chai' + +import { NFTokenModify } from '../../../dist/npm' +import { NFTokenMintFlags } from '../../../dist/npm/src' +import { + convertStringToHex, + getNFTokenID, + NFTokenMint, + TransactionMetadata, + TxRequest, +} from '../../../src' +import { hashSignedTx } from '../../../src/utils/hashes' +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('NFTokenModify', function () { + let testContext: XrplIntegrationTestContext + + beforeEach(async () => { + testContext = await setupClient(serverUrl) + }) + afterEach(async () => teardownClient(testContext)) + + // Mint an NFToken with tfMutable flag and modify URI later + it( + 'modify NFToken URI', + async function () { + const oldUri = convertStringToHex('https://www.google.com') + const newUri = convertStringToHex('https://www.youtube.com') + + const mutableMint: NFTokenMint = { + TransactionType: 'NFTokenMint', + Account: testContext.wallet.address, + Flags: NFTokenMintFlags.tfMutable, + URI: oldUri, + NFTokenTaxon: 0, + } + const response = await testTransaction( + testContext.client, + mutableMint, + testContext.wallet, + ) + assert.equal(response.type, 'response') + + const mutableTx: TxRequest = { + command: 'tx', + transaction: hashSignedTx(response.result.tx_blob), + } + const mutableTxResponse = await testContext.client.request(mutableTx) + + const mutableNFTokenID = + getNFTokenID( + mutableTxResponse.result.meta as TransactionMetadata, + ) ?? 'undefined' + + const accountNFTs = await testContext.client.request({ + command: 'account_nfts', + account: testContext.wallet.address, + }) + + assert.equal( + accountNFTs.result.account_nfts.find( + (nft) => nft.NFTokenID === mutableNFTokenID, + )?.URI, + oldUri, + ) + + const modifyTx: NFTokenModify = { + TransactionType: 'NFTokenModify', + Account: testContext.wallet.address, + NFTokenID: mutableNFTokenID, + URI: newUri, + } + + const modifyResponse = await testTransaction( + testContext.client, + modifyTx, + testContext.wallet, + ) + assert.equal(modifyResponse.type, 'response') + + const nfts = await testContext.client.request({ + command: 'account_nfts', + account: testContext.wallet.address, + }) + + assert.equal( + nfts.result.account_nfts.find( + (nft) => nft.NFTokenID === mutableNFTokenID, + )?.URI, + newUri, + ) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/models/NFTokenModify.test.ts b/packages/xrpl/test/models/NFTokenModify.test.ts new file mode 100644 index 0000000000..2f84793153 --- /dev/null +++ b/packages/xrpl/test/models/NFTokenModify.test.ts @@ -0,0 +1,41 @@ +import { assert } from 'chai' + +import { convertStringToHex, validate, ValidationError } from '../../src' + +const TOKEN_ID = + '00090032B5F762798A53D543A014CAF8B297CFF8F2F937E844B17C9E00000003' + +/** + * NFTokenModify Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('NFTokenModify', function () { + it(`verifies valid NFTokenModify`, function () { + const validNFTokenModify = { + TransactionType: 'NFTokenModify', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + NFTokenID: TOKEN_ID, + Fee: '5000000', + Sequence: 2470665, + URI: convertStringToHex('http://xrpl.org'), + } as any + + assert.doesNotThrow(() => validate(validNFTokenModify)) + }) + + it(`throws w/ missing NFTokenID`, function () { + const invalid = { + TransactionType: 'NFTokenModify', + Account: 'rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm', + Fee: '5000000', + Sequence: 2470665, + } as any + + assert.throws( + () => validate(invalid), + ValidationError, + 'NFTokenModify: missing field NFTokenID', + ) + }) +})