diff --git a/package-lock.json b/package-lock.json index 9ea2d4cdc..2743ac8f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,7 @@ "ts-jest": "^29.4.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "xrpl": "^4.5.0" + "xrpl": "^5.1.0" }, "engines": { "node": ">=22.0.0 <23", @@ -4561,29 +4561,28 @@ } }, "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "version": "2.4.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.4.0" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, + "version": "2.4.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5349,39 +5348,37 @@ "license": "MIT" }, "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", - "dev": true, + "version": "2.4.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "version": "2.4.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@scure/bip32/-/bip32-2.4.0.tgz", + "integrity": "sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few==", "dev": true, "license": "MIT", "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "version": "2.4.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" + "@noble/hashes": "2.4.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -6931,16 +6928,14 @@ "node": ">=18.0.0" } }, - "node_modules/@xrplf/isomorphic/node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "license": "MIT", + "node_modules/@xrplf/mpt-crypto": { + "version": "0.1.1", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@xrplf/mpt-crypto/-/mpt-crypto-0.1.1.tgz", + "integrity": "sha512-Wv9wbk60DRGWvI+B9DITwfWygCWDfEjv8yWxiLJyDf/G8UPkfhVlUKIflF9nMaBFMygPRkXrpO1jNqTj6/vJow==", + "dev": true, + "license": "ISC", "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">= 18" } }, "node_modules/@xrplf/prettier-config": { @@ -6951,14 +6946,14 @@ "license": "ISC" }, "node_modules/@xrplf/secret-numbers": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@xrplf/secret-numbers/-/secret-numbers-2.0.0.tgz", - "integrity": "sha512-z3AOibRTE9E8MbjgzxqMpG1RNaBhQ1jnfhNCa1cGf2reZUJzPMYs4TggQTc7j8+0WyV3cr7y/U8Oz99SXIkN5Q==", + "version": "3.0.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/@xrplf/secret-numbers/-/secret-numbers-3.0.0.tgz", + "integrity": "sha512-qpGhAZXv5noMDjCtfzq5NK0y5rrdwTVjKhhPcAYSE+a/gogBOgqdpCKyieprVVPCnmVmJnGeRoZKBAqpCGegsA==", "dev": true, "license": "ISC", "dependencies": { - "@xrplf/isomorphic": "^1.0.1", - "ripple-keypairs": "^2.0.0" + "@xrplf/isomorphic": "^1.0.2", + "ripple-keypairs": "^3.0.0" } }, "node_modules/accepts": { @@ -7752,14 +7747,10 @@ } }, "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } + "version": "10.0.2", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -19676,19 +19667,10 @@ "node": ">= 18" } }, - "node_modules/ripple-address-codec/node_modules/@scure/base": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", - "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/ripple-binary-codec": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.9.0.tgz", - "integrity": "sha512-DOv4CPwm2B5mjAJNHE4tfes7tIqefi85iun8BskS7/i1PvLIwVydHIbTV5hbQ7eT69Zg4K5dtO1CzfuvQVAYvQ==", + "version": "2.10.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/ripple-binary-codec/-/ripple-binary-codec-2.10.0.tgz", + "integrity": "sha512-H4pBm2WdOMmVnA0HGUdWAShmUFdmqavWAsi2WlMCzOknc1XjrZssa/RxEyziaPww8MXe1OpgGU7uCs8G2nYe/A==", "license": "ISC", "dependencies": { "@xrplf/isomorphic": "^1.0.2", @@ -19699,25 +19681,19 @@ "node": ">= 18" } }, - "node_modules/ripple-binary-codec/node_modules/bignumber.js": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", - "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", - "license": "MIT" - }, "node_modules/ripple-keypairs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-2.0.0.tgz", - "integrity": "sha512-b5rfL2EZiffmklqZk1W+dvSy97v3V/C7936WxCCgDynaGPp7GE6R2XO7EU9O2LlM/z95rj870IylYnOQs+1Rag==", + "version": "3.0.0", + "resolved": "https://artifactory.ops.ripple.com/artifactory/api/npm/ripple-npm/ripple-keypairs/-/ripple-keypairs-3.0.0.tgz", + "integrity": "sha512-lE69pD0E8hFNCqZoVXRyY45Yi8Ku+Qw7Rf1qRwPj4nOi34vp9NAuwzfiJH1IwXGWNCfEkwVfctG99CPTEoUf+g==", "dev": true, "license": "ISC", "dependencies": { - "@noble/curves": "^1.0.0", - "@xrplf/isomorphic": "^1.0.0", - "ripple-address-codec": "^5.0.0" + "@noble/curves": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "ripple-address-codec": "^5.0.1" }, "engines": { - "node": ">= 16" + "node": ">= 18" } }, "node_modules/robust-predicates": { @@ -23639,25 +23615,26 @@ "license": "MIT" }, "node_modules/xrpl": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-4.6.0.tgz", - "integrity": "sha512-0nXZfqDHRJ6bsDv1WtA9MdCYalMtXuxVa9mtLdqT3xypRKf2LwT5DbuGL/kHcVfuqk3B+ly+SFARlrnX+LHtRQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-5.1.0.tgz", + "integrity": "sha512-wYTiyrFqV22kwSX2lrUCD10Ns8Zyv2zz8rZ2Pi0KOVm5Sc7/8m7rrca2dAZy1vGhOt36tkvWyX4aC2SQveyT9w==", "dev": true, "license": "ISC", "dependencies": { - "@scure/bip32": "^1.3.1", - "@scure/bip39": "^1.2.1", - "@xrplf/isomorphic": "^1.0.1", - "@xrplf/secret-numbers": "^2.0.0", - "bignumber.js": "^9.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "@xrplf/mpt-crypto": "^0.1.1", + "@xrplf/secret-numbers": "^3.0.0", + "bignumber.js": "^10.0.2", "eventemitter3": "^5.0.1", "fast-json-stable-stringify": "^2.1.0", - "ripple-address-codec": "^5.0.0", - "ripple-binary-codec": "^2.7.0", - "ripple-keypairs": "^2.0.0" + "ripple-address-codec": "^5.0.1", + "ripple-binary-codec": "^2.10.0", + "ripple-keypairs": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" } }, "node_modules/xrpl-client": { diff --git a/package.json b/package.json index cc8d56238..3c11e9c5e 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "ts-jest": "^29.4.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "xrpl": "^4.5.0" + "xrpl": "^5.1.0" }, "resolutions": { "jest-environment-jsdom": "29.3.1", diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index bc4553c11..81dc72bba 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -945,5 +945,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 8c55a6a7f..b0f84b37a 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -946,5 +946,11 @@ "registered": "Registered", "included": "Included", "maintenance_banner.notice": "Scheduled maintenance: {{window}} (~{{duration}} min downtime).", - "maintenance_banner.countdown_prefix": "Starts in" + "maintenance_banner.countdown_prefix": "Starts in", + "can_enable": "Can Enable", + "can_enable_flag_tooltip": "The issuer can still enable this capability — it has not been permanently locked", + "immutable": "Immutable", + "immutable_flag_tooltip": "This capability has been permanently locked by the issuer and cannot be enabled", + "immutable_field_tooltip": "This field has been permanently locked by the issuer and cannot be changed", + "immutable_flags": "Immutable Flags" } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 12e1a48f4..e30c9f535 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -946,5 +946,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 99bb71f9e..2a5978c1d 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -946,5 +946,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 8229d9e98..b392093d2 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -946,5 +946,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index d02b1dd5c..5b974d392 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -946,5 +946,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index c1ef818ab..567be79be 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -946,5 +946,11 @@ "registered": null, "included": null, "maintenance_banner.notice": null, - "maintenance_banner.countdown_prefix": null + "maintenance_banner.countdown_prefix": null, + "immutable_flags": null, + "can_enable": null, + "can_enable_flag_tooltip": null, + "immutable": null, + "immutable_flag_tooltip": null, + "immutable_field_tooltip": null } diff --git a/scripts/showcase-dynamic-mpt.mjs b/scripts/showcase-dynamic-mpt.mjs new file mode 100644 index 000000000..1550bb645 --- /dev/null +++ b/scripts/showcase-dynamic-mpt.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +/** + * showcase-dynamic-mpt.mjs + * + * Submits four DynamicMPT transactions to a local rippled standalone and prints + * Explorer URLs to review each UI surface: + * 1. MPTokenIssuanceCreate — all mutable (no ImmutableFlags) + * 2. MPTokenIssuanceCreate — with ImmutableFlags set + * 3. MPTokenIssuanceSet — enable capabilities via tfMPTSetCan* Flags + * 4. MPTokenIssuanceSet — update TransferFee + lock flags via ImmutableFlags + * + * Usage: + * node scripts/showcase-dynamic-mpt.mjs + * EXPLORER=http://localhost:3001/localhost node scripts/showcase-dynamic-mpt.mjs + */ + +const ENDPOINT = process.env.RIPPLED ?? 'http://localhost:5005' +const GENESIS_ACCOUNT = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh' +const GENESIS_SECRET = 'snoPBrXtMeMyMHUVTgbuqAfg1SUTb' +const EXPLORER = process.env.EXPLORER ?? 'http://localhost:3001/localhost' + +// ── RPC helpers ─────────────────────────────────────────────────────────────── + +async function rpc(method, params = {}) { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, params: [params] }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const { result } = await res.json() + if (result.error) + throw new Error(`${result.error}: ${result.error_message ?? ''}`) + return result +} + +async function submit(txJson) { + const result = await rpc('submit', { + tx_json: txJson, + secret: GENESIS_SECRET, + }) + const eng = result.engine_result + if (eng !== 'tesSUCCESS' && eng !== 'terQUEUED') { + throw new Error(`${eng}: ${result.engine_result_message}`) + } + return result.tx_json.hash +} + +async function advanceLedger() { + await rpc('ledger_accept') +} + +async function getValidatedTx(hash) { + // retry a few times in case the ledger hasn't closed yet + for (let i = 0; i < 5; i++) { + try { + const result = await rpc('tx', { transaction: hash }) + if (result.validated) return result + } catch (_) {} + await new Promise((r) => setTimeout(r, 200)) + } + throw new Error(`tx ${hash} not validated after retries`) +} + +// ── XLS-0089 MPT metadata schemas ──────────────────────────────────────────── +// Short key names per spec: t=ticker, n=name, d=desc, i=icon, ac=asset_class, +// as=asset_subclass (required for ac=rwa), in=issuer_name, us=uris, ai=additional_info + +// Issuance A: DeFi token, all capabilities still mutable at creation. +const METADATA_A = { + t: 'DYNX', + n: 'DynToken Flex', + d: 'A DeFi token demonstrating DynamicMPT — all capabilities mutable at creation. The issuer can still enable escrow, trade, clawback, and more via MPTokenIssuanceSet.', + i: 'https://example.org/dynx/icon.png', + ac: 'defi', + in: 'XRPL Foundation', +} + +// Issuance B: RWA (treasury) token with CanLock, RequireAuth, and Metadata +// permanently locked at issuance via ImmutableFlags. +const METADATA_B = { + t: 'RTBIL', + n: 'T-Bill Yield Token', + d: 'Yield-bearing token backed by U.S. Treasuries. CanLock, RequireAuth, and Metadata are permanently immutable — set once at issuance and can never be changed.', + i: 'https://example.org/rtbil/icon.png', + ac: 'rwa', + as: 'treasury', + in: 'XRPL Foundation', + us: [{ u: 'https://example.org/rtbil', c: 'website', t: 'Product Page' }], + ai: { interest_rate: '5.00%', maturity_date: '2027-06-30' }, +} + +// TX4 update to Issuance A: new metadata after locking CanTrade, CanTransfer, +// and TransferFee — reflects the permanent change in the description. +const METADATA_A_UPDATED = { + t: 'DYNX', + n: 'DynToken Flex', + d: 'Updated via MPTokenIssuanceSet: TransferFee raised to 0.075%. CanTrade, CanTransfer, and TransferFee are now permanently immutable.', + i: 'https://example.org/dynx/icon.png', + ac: 'defi', + in: 'XRPL Foundation', + us: [{ u: 'https://example.org/dynx', c: 'website', t: 'Product Page' }], +} + +function toHex(obj) { + const json = typeof obj === 'string' ? obj : JSON.stringify(obj) + return Buffer.from(json, 'utf8').toString('hex').toUpperCase() +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + console.log(`Connecting to ${ENDPOINT} …`) + const { info } = await rpc('server_info') + console.log( + ` rippled ${info.build_version} network_id=${info.network_id}\n`, + ) + + const { account_data } = await rpc('account_info', { + account: GENESIS_ACCOUNT, + ledger_index: 'current', + }) + let seq = account_data.Sequence + console.log(` Genesis account: ${GENESIS_ACCOUNT} seq=${seq}\n`) + + const results = {} + + // ── TX 1: Create — all mutable (no ImmutableFlags) ────────────────────────── + // Flags: tfMPTCanTransfer (0x20) + // Metadata: XLS-0089 JSON (ac=defi), all capabilities mutable. + // Shows: Create Simple with NO "Immutable Flags" row. + // Settings: 7 "Can Enable" badges on unlocked disabled caps; + // Metadata + TransferFee field rows visible with no badge. + console.log('TX1 MPTokenIssuanceCreate — all mutable (DYNX / DeFi)') + const hash1 = await submit({ + TransactionType: 'MPTokenIssuanceCreate', + Account: GENESIS_ACCOUNT, + Sequence: seq++, + Fee: '200', + Flags: 0x00000020, // tfMPTCanTransfer + AssetScale: 6, + MaximumAmount: '1000000000000', + TransferFee: 100, // 0.010% — still mutable + MPTokenMetadata: toHex(METADATA_A), + }) + await advanceLedger() + const tx1 = await getValidatedTx(hash1) + const issuanceIdA = tx1.meta?.mpt_issuance_id ?? '?' + results.tx1 = { hash: hash1, issuanceId: issuanceIdA } + console.log(` hash : ${hash1}`) + console.log(` issuance ID: ${issuanceIdA}\n`) + + // ── TX 2: Create — with ImmutableFlags ────────────────────────────────────── + // Flags: tfMPTCanTransfer (0x20) | tfMPTRequireAuth (0x04) + // ImmutableFlags: tifMPTCanLock(0x2) + tifMPTRequireAuth(0x4) + tifMPTMetadata(0x10000) + // = 0x10006 = 65542 + // Metadata: XLS-0089 JSON (ac=rwa, as=treasury), with URIs and ai fields. + // Shows: Create Simple "Immutable Flags" row listing tifMPTCanLock, + // tifMPTRequireAuth, tifMPTMetadata; metadata renders as JSON in Detail. + // Settings: CanLock + RequireAuth → "Immutable" badge; + // Metadata field row → "Immutable" badge; TransferFee row mutable. + console.log( + 'TX2 MPTokenIssuanceCreate — with ImmutableFlags (RTBIL / RWA treasury)', + ) + const hash2 = await submit({ + TransactionType: 'MPTokenIssuanceCreate', + Account: GENESIS_ACCOUNT, + Sequence: seq++, + Fee: '200', + Flags: 0x00000024, // tfMPTCanTransfer | tfMPTRequireAuth + AssetScale: 2, + MaximumAmount: '9223372036854775807', + TransferFee: 500, // 0.500% + MPTokenMetadata: toHex(METADATA_B), + ImmutableFlags: 0x00010006, // tifMPTCanLock + tifMPTRequireAuth + tifMPTMetadata + }) + await advanceLedger() + const tx2 = await getValidatedTx(hash2) + const issuanceIdB = tx2.meta?.mpt_issuance_id ?? '?' + results.tx2 = { hash: hash2, issuanceId: issuanceIdB } + console.log(` hash : ${hash2}`) + console.log(` issuance ID: ${issuanceIdB}\n`) + + // ── TX 3: Set — enable capabilities via tfMPTSetCan* Flags ────────────────── + // Flags: tfMPTSetCanEscrow(0x10) | tfMPTSetCanTrade(0x20) = 0x30 + // Shows: Set Simple with NO ImmutableFlags row; the tfMPTSetCan* bits appear + // automatically in the Detail tab via TX_FLAGS.MPTokenIssuanceSet. + console.log( + 'TX3 MPTokenIssuanceSet — enable CanEscrow + CanTrade on Issuance A', + ) + const hash3 = await submit({ + TransactionType: 'MPTokenIssuanceSet', + Account: GENESIS_ACCOUNT, + Sequence: seq++, + Fee: '200', + MPTokenIssuanceID: issuanceIdA, + Flags: 0x00000030, // tfMPTSetCanEscrow | tfMPTSetCanTrade + }) + await advanceLedger() + results.tx3 = { hash: hash3 } + console.log(` hash: ${hash3}\n`) + + // ── TX 4: Set — update TransferFee + metadata + lock via ImmutableFlags ────── + // ImmutableFlags: tifMPTCanTrade(0x10) + tifMPTCanTransfer(0x20) + tifMPTTransferFee(0x20000) + // = 0x20030 = 131120 + // Metadata: updated XLS-0089 JSON reflecting the new locked state. + // Shows: Set Simple with TransferFee row (0.075%), Metadata row (JSON rendered), + // and "Immutable Flags" row listing tifMPTCanTrade, tifMPTCanTransfer, + // tifMPTTransferFee. + console.log( + 'TX4 MPTokenIssuanceSet — update metadata + TransferFee + lock via ImmutableFlags on Issuance A', + ) + const hash4 = await submit({ + TransactionType: 'MPTokenIssuanceSet', + Account: GENESIS_ACCOUNT, + Sequence: seq++, + Fee: '200', + MPTokenIssuanceID: issuanceIdA, + TransferFee: 750, // update: 0.010% → 0.075% + MPTokenMetadata: toHex(METADATA_A_UPDATED), + ImmutableFlags: 0x00020030, // tifMPTCanTrade + tifMPTCanTransfer + tifMPTTransferFee + }) + await advanceLedger() + results.tx4 = { hash: hash4 } + console.log(` hash: ${hash4}\n`) + + // ── Summary ────────────────────────────────────────────────────────────────── + console.log( + '═══════════════════════════════════════════════════════════════════', + ) + console.log(' EXPLORER URLS') + console.log( + '═══════════════════════════════════════════════════════════════════\n', + ) + + console.log( + '── MPTokenIssuanceCreate / Simple view ─────────────────────────────', + ) + console.log(' TX1 DYNX — no ImmutableFlags ("Immutable Flags" row absent)') + console.log(` ${EXPLORER}/transactions/${results.tx1.hash}/simple`) + console.log() + console.log( + ' TX2 RTBIL — ImmutableFlags=0x10006 (tifMPTCanLock, tifMPTRequireAuth, tifMPTMetadata)', + ) + console.log(` ${EXPLORER}/transactions/${results.tx2.hash}/simple`) + console.log() + + console.log( + '── MPTokenIssuanceSet / Simple view ────────────────────────────────', + ) + console.log( + ' TX3 tfMPTSetCanEscrow + tfMPTSetCanTrade (see Detail tab for flag decode)', + ) + console.log(` ${EXPLORER}/transactions/${results.tx3.hash}/simple`) + console.log() + console.log(' TX4 TransferFee + Metadata (JSON) + ImmutableFlags rows') + console.log(` ${EXPLORER}/transactions/${results.tx4.hash}/simple`) + console.log() + + console.log( + '── MPT token Settings page ─────────────────────────────────────────', + ) + console.log( + ' Issuance A DYNX — "Can Enable" on unlocked caps; CanTrade+CanTransfer+TransferFee → "Immutable"', + ) + console.log(` ${EXPLORER}/token/mpt/${results.tx1.issuanceId}`) + console.log() + console.log( + ' Issuance B RTBIL — CanLock+RequireAuth+Metadata → "Immutable"; TransferFee still mutable', + ) + console.log(` ${EXPLORER}/token/mpt/${results.tx2.issuanceId}`) + console.log() + console.log( + '═══════════════════════════════════════════════════════════════════', + ) +} + +main().catch((err) => { + console.error('\nFatal:', err.message) + process.exit(1) +}) diff --git a/src/containers/Token/MPT/Header/Settings.tsx b/src/containers/Token/MPT/Header/Settings.tsx index fac9ee031..fa9fcca91 100644 --- a/src/containers/Token/MPT/Header/Settings.tsx +++ b/src/containers/Token/MPT/Header/Settings.tsx @@ -2,17 +2,39 @@ import { useTranslation } from 'react-i18next' interface Props { flags?: string[] + immutableFlags?: string[] } interface FlagItem { key: string label: string enabled: boolean + // The lsifMPT* flag name that permanently locks this capability (XLS-94). + immutableFlag?: string } -export const Settings = ({ flags = [] }: Props): JSX.Element => { +interface FieldItem { + key: string + label: string + // The lsifMPT* flag name that permanently locks this field (XLS-94). + immutableFlag: string +} + +export const Settings = ({ + flags = [], + immutableFlags = [], +}: Props): JSX.Element => { const { t } = useTranslation() + // Whether the lsif bit is present — the capability/field is permanently frozen. + const isLocked = (immutableFlag?: string): boolean => + !!immutableFlag && immutableFlags.includes(immutableFlag) + + // Whether the capability can still be enabled: not yet locked and not yet on. + // Absent immutableFlag means it is not a lockable capability (e.g. lsfMPTLocked). + const isCanEnable = (immutableFlag?: string, enabled = false): boolean => + !!immutableFlag && !immutableFlags.includes(immutableFlag) && !enabled + const flagItems: FlagItem[] = [ { key: 'locked', @@ -23,36 +45,58 @@ export const Settings = ({ flags = [] }: Props): JSX.Element => { key: 'canLock', label: t('can_lock'), enabled: flags.includes('lsfMPTCanLock'), + immutableFlag: 'lsifMPTCanLock', }, { key: 'requireAuth', label: t('require_auth'), enabled: flags.includes('lsfMPTRequireAuth'), + immutableFlag: 'lsifMPTRequireAuth', }, { key: 'canEscrow', label: t('can_escrow'), enabled: flags.includes('lsfMPTCanEscrow'), + immutableFlag: 'lsifMPTCanEscrow', }, { key: 'canTrade', label: t('can_trade'), enabled: flags.includes('lsfMPTCanTrade'), + immutableFlag: 'lsifMPTCanTrade', }, { key: 'canTransfer', label: t('can_transfer'), enabled: flags.includes('lsfMPTCanTransfer'), + immutableFlag: 'lsifMPTCanTransfer', }, { key: 'canClawback', label: t('can_clawback'), enabled: flags.includes('lsfMPTCanClawback'), + immutableFlag: 'lsifMPTCanClawback', }, { key: 'canConfidentialAmount', label: t('can_confidential_amount'), enabled: flags.includes('lsfMPTCanConfidentialAmount'), + immutableFlag: 'lsifMPTCanHoldConfidentialBalance', + }, + ] + + // Mutable fields (not capability flags): always shown. An Immutable badge + // appears once the issuer has permanently locked the field via ImmutableFlags. + const fieldItems: FieldItem[] = [ + { + key: 'metadata', + label: t('metadata'), + immutableFlag: 'lsifMPTMetadata', + }, + { + key: 'transferFee', + label: t('transfer_fee'), + immutableFlag: 'lsifMPTTransferFee', }, ] @@ -63,10 +107,56 @@ export const Settings = ({ flags = [] }: Props): JSX.Element => { {flagItems.map((flag) => (
{flag.label}
-
- {flag.enabled ? t('enabled') : t('disabled')} +
+ {/* Capabilities are one-way (enable-only). Show "Can Enable" while + the flag is not yet locked and not yet on, so holders know the + issuer can still activate this capability. */} + {isCanEnable(flag.immutableFlag, flag.enabled) && ( +
+ {t('can_enable')} +
+ )} + {/* Show "Immutable" when the issuer has permanently locked this + capability via ImmutableFlags — it can never be enabled. */} + {isLocked(flag.immutableFlag) && !flag.enabled && ( +
+ {t('immutable')} +
+ )} +
+ {flag.enabled ? t('enabled') : t('disabled')} +
+
+
+ ))} + {fieldItems.map((field) => ( +
+
{field.label}
+
+ {/* Show "Immutable" once the issuer has permanently locked this + field. No badge when still changeable — the row being visible + is enough to indicate the field exists and can be updated. */} + {isLocked(field.immutableFlag) && ( +
+ {t('immutable')} +
+ )}
))} diff --git a/src/containers/Token/MPT/Header/index.tsx b/src/containers/Token/MPT/Header/index.tsx index 74bed94d9..7c3642b35 100644 --- a/src/containers/Token/MPT/Header/index.tsx +++ b/src/containers/Token/MPT/Header/index.tsx @@ -92,6 +92,7 @@ export const Header = (props: Props) => { confidentialOutstandingAmt, transferFee, flags, + immutableFlags, rawMPTMetadata, parsedMPTMetadata, isMPTMetadataCompliant, @@ -235,7 +236,7 @@ export const Header = (props: Props) => { confidentialOutstandingAmt={confidentialOutstandingAmt} assetScale={assetScale} /> - + {(parsedMPTMetadata || rawMPTMetadata) && ( { const renderComponent = (props: any = {}) => render( - + , ) @@ -21,11 +21,15 @@ describe('Settings component', () => { ) }) - it('renders all 7 flag items', () => { + it('renders 8 capability rows plus 2 field rows always', () => { + // Field rows (Metadata, TransferFee) are always visible regardless of + // immutableFlags — they show an Immutable badge when locked. const { container } = renderComponent() - expect(container.querySelectorAll('.header-box-item')).toHaveLength(8) + expect(container.querySelectorAll('.header-box-item')).toHaveLength(10) }) + // ── Enabled / disabled status ─────────────────────────────────────────────── + it('shows locked flag as disabled by default', () => { const { container } = renderComponent({ flags: [] }) expect(container).toHaveTextContent('locked') @@ -39,43 +43,43 @@ describe('Settings component', () => { expect(lockedItem.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_lock flag status', () => { + it('shows can_lock flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanLock'] }) expect(container).toHaveTextContent('can_lock') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows require_auth flag status', () => { + it('shows require_auth flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTRequireAuth'] }) expect(container).toHaveTextContent('require_auth') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_escrow flag status', () => { + it('shows can_escrow flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanEscrow'] }) expect(container).toHaveTextContent('can_escrow') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_trade flag status', () => { + it('shows can_trade flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanTrade'] }) expect(container).toHaveTextContent('can_trade') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_transfer flag status', () => { + it('shows can_transfer flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanTransfer'] }) expect(container).toHaveTextContent('can_transfer') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_clawback flag status', () => { + it('shows can_clawback flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanClawback'] }) expect(container).toHaveTextContent('can_clawback') expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(1) }) - it('shows can_confidential_amount flag status', () => { + it('shows can_confidential_amount flag as enabled when present', () => { const { container } = renderComponent({ flags: ['lsfMPTCanConfidentialAmount'], }) @@ -91,14 +95,116 @@ describe('Settings component', () => { expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(5) }) - it('handles empty flags array', () => { + it('handles empty flags array — all caps disabled', () => { const { container } = renderComponent({ flags: [] }) expect(container.querySelectorAll('.flag-status.enabled')).toHaveLength(0) expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(8) }) - it('handles undefined flags', () => { + it('handles undefined flags — all caps disabled', () => { const { container } = renderComponent({ flags: undefined }) expect(container.querySelectorAll('.flag-status.disabled')).toHaveLength(8) }) + + // ── Can Enable badge ──────────────────────────────────────────────────────── + // Appears on disabled capabilities that have not been locked via ImmutableFlags. + // Tells holders: "the issuer can still activate this capability." + + it('shows can-enable badges for all unlocked disabled caps when no immutableFlags set', () => { + // 7 lockable caps (canLock through canConfidentialAmount) × disabled + unlocked + // = 7 can-enable badges. lsfMPTLocked has no immutableFlag → no badge. + // Field rows show no badge when not locked. + const { queryAllByTestId, container } = renderComponent({ flags: [] }) + expect(queryAllByTestId('can-enable-badge')).toHaveLength(7) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + expect(container.querySelectorAll('.header-box-item')).toHaveLength(10) + }) + + it('hides can-enable badge once a capability is enabled', () => { + // Capabilities are one-way (enable-only), so an enabled flag never needs + // the "Can Enable" indicator. + const { queryAllByTestId } = renderComponent({ + flags: ['lsfMPTCanLock'], + immutableFlags: [], + }) + // canLock is enabled → no can-enable badge for it; 6 other disabled unlocked + expect(queryAllByTestId('can-enable-badge')).toHaveLength(6) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + }) + + it('hides can-enable badge once a capability is locked in immutableFlags', () => { + // Locked = permanently disabled; "Can Enable" would be misleading. + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanLock'], + }) + // canLock is locked → no can-enable badge; 6 other disabled unlocked caps + expect(queryAllByTestId('can-enable-badge')).toHaveLength(6) + }) + + // ── Immutable badge ───────────────────────────────────────────────────────── + // Appears on capabilities/fields permanently locked via ImmutableFlags. + // Tells everyone: "the issuer cannot change this — ever." + + it('shows immutable badge for a locked capability', () => { + const { container, getAllByTestId, queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanLock'], + }) + // canLock locked → 1 immutable badge; 6 other disabled unlocked → 6 can-enable + expect(getAllByTestId('immutable-badge')).toHaveLength(1) + expect(queryAllByTestId('can-enable-badge')).toHaveLength(6) + expect(container.querySelectorAll('.flag-status.immutable')).toHaveLength(1) + }) + + it('shows immutable badge for locked confidential capability', () => { + const { getAllByTestId, queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTCanHoldConfidentialBalance'], + }) + expect(getAllByTestId('immutable-badge')).toHaveLength(1) + expect(queryAllByTestId('can-enable-badge')).toHaveLength(6) + }) + + it('does not show immutable badge for an already-enabled capability', () => { + // Once enabled, the "Immutable" badge would be redundant — the enabled status + // is already permanent (capabilities are one-way). + const { queryAllByTestId } = renderComponent({ + flags: ['lsfMPTCanLock'], + immutableFlags: ['lsifMPTCanLock'], + }) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + }) + + // ── Field rows (Metadata / TransferFee) ───────────────────────────────────── + // Always shown. An Immutable badge appears when locked; no badge when mutable. + + it('always shows metadata and transferFee rows regardless of immutableFlags', () => { + const { container: withLock } = renderComponent({ + immutableFlags: ['lsifMPTMetadata', 'lsifMPTTransferFee'], + }) + const { container: withoutLock } = renderComponent({ immutableFlags: [] }) + // Both states → 8 cap rows + 2 field rows = 10 + expect(withLock.querySelectorAll('.header-box-item')).toHaveLength(10) + expect(withoutLock.querySelectorAll('.header-box-item')).toHaveLength(10) + }) + + it('shows immutable badges on field rows when locked', () => { + const { getAllByTestId, queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: ['lsifMPTMetadata', 'lsifMPTTransferFee'], + }) + // 2 immutable badges on field rows; 7 can-enable on unlocked disabled caps + expect(getAllByTestId('immutable-badge')).toHaveLength(2) + expect(queryAllByTestId('can-enable-badge')).toHaveLength(7) + }) + + it('shows no badges on field rows when not locked', () => { + const { queryAllByTestId } = renderComponent({ + flags: [], + immutableFlags: [], + }) + expect(queryAllByTestId('immutable-badge')).toHaveLength(0) + expect(queryAllByTestId('can-enable-badge')).toHaveLength(7) + }) }) diff --git a/src/containers/shared/Interfaces.tsx b/src/containers/shared/Interfaces.tsx index f9d11d00b..02d0bf7b9 100644 --- a/src/containers/shared/Interfaces.tsx +++ b/src/containers/shared/Interfaces.tsx @@ -48,6 +48,7 @@ export interface FormattedMPTIssuance { outstandingAmt?: string confidentialOutstandingAmt?: string flags?: string[] + immutableFlags?: string[] transferFee?: number rawMPTMetadata?: string parsedMPTMetadata?: Record diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx index 28a9c62a6..ec9062c01 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/Simple.tsx @@ -11,8 +11,14 @@ import './styles.scss' export const Simple: TransactionSimpleComponent = ({ data, }: TransactionSimpleProps) => { - const { issuanceID, metadata, assetScale, transferFee, maxAmount } = - data.instructions + const { + issuanceID, + metadata, + assetScale, + transferFee, + maxAmount, + immutableFlags, + } = data.instructions const { t } = useTranslation() const language = useLanguage() const formattedFee = @@ -64,6 +70,17 @@ export const Simple: TransactionSimpleComponent = ({ )} )} + {immutableFlags && immutableFlags.length > 0 && ( + + {immutableFlags.map((flag) => ( +
{flag}
+ ))} +
+ )} ) } diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts index fd08c599f..8aa49165d 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/parser.ts @@ -1,19 +1,27 @@ import type { MPTokenIssuanceCreate } from 'xrpl' import { MPTokenIssuanceCreateInstructions } from './types' import { TransactionParser } from '../types' -import { convertHexToString } from '../../../../../rippled/lib/utils' +import { + buildFlags, + convertHexToString, +} from '../../../../../rippled/lib/utils' +import { MPT_IMMUTABLE_FLAGS } from '../../../transactionUtils' export const parser: TransactionParser< MPTokenIssuanceCreate, MPTokenIssuanceCreateInstructions -> = (tx, meta) => ({ - issuanceID: meta.mpt_issuance_id, - metadata: tx.MPTokenMetadata - ? convertHexToString(tx.MPTokenMetadata) - : undefined, - transferFee: tx.TransferFee, - assetScale: tx.AssetScale, - maxAmount: tx.MaximumAmount - ? BigInt(tx.MaximumAmount).toString(10) - : undefined, -}) +> = (tx, meta) => { + const immutableFlags = buildFlags(tx.ImmutableFlags, MPT_IMMUTABLE_FLAGS) + return { + issuanceID: meta.mpt_issuance_id, + metadata: tx.MPTokenMetadata + ? convertHexToString(tx.MPTokenMetadata) + : undefined, + transferFee: tx.TransferFee, + assetScale: tx.AssetScale, + maxAmount: tx.MaximumAmount + ? BigInt(tx.MaximumAmount).toString(10) + : undefined, + immutableFlags: immutableFlags.length ? immutableFlags : undefined, + } +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx index be888d0d6..bb8cbfaa2 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/MPTokenIssuanceCreateSimple.test.jsx @@ -1,7 +1,8 @@ import { createSimpleRenderFactory } from '../../test/createWrapperFactory' import { Simple } from '../Simple' -import { expectSimpleRowText } from '../../test' +import { expectSimpleRowText, expectSimpleRowNotToExist } from '../../test' import transactionSuccess from './mock_data/MPTokenIssuanceCreate.json' +import transactionDynamic from './mock_data/MPTokenIssuanceCreate_Dynamic.json' const renderComponent = createSimpleRenderFactory(Simple) @@ -22,6 +23,18 @@ describe('MPTokenIssuanceCreate', () => { 'https://ipfs.io/ipfs/QmZnjmB9Tk4xaA9E679ytrPXda3beWMLUnMB5RFj1eStLp', ) expectSimpleRowText(container, 'mpt-fee', '0.010%') + expectSimpleRowNotToExist(container, 'mpt-mutable-flags') + unmount() + }) + + it('handles Dynamic MPT MPTokenIssuanceCreate simple view', () => { + const { container, unmount } = renderComponent(transactionDynamic) + + // ImmutableFlags 196610 = tifMPTCanLock (0x2) + + // tifMPTMetadata (0x10000) + tifMPTTransferFee (0x20000) + expectSimpleRowText(container, 'mpt-mutable-flags', 'tifMPTCanLock') + expectSimpleRowText(container, 'mpt-mutable-flags', 'tifMPTMetadata') + expectSimpleRowText(container, 'mpt-mutable-flags', 'tifMPTTransferFee') unmount() }) }) diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json new file mode 100644 index 000000000..be5b44f6e --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/test/mock_data/MPTokenIssuanceCreate_Dynamic.json @@ -0,0 +1,51 @@ +{ + "tx": { + "Account": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "AssetScale": 2, + "Fee": "10", + "Flags": 34, + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "ImmutableFlags": 196610, + "Sequence": 5496, + "SigningPubKey": "EDAD408FAEE57EB4A347E6FE395B834DD47C6531C3C37B09ACC35528161CAD4B0E", + "TransactionType": "MPTokenIssuanceCreate", + "TransferFee": 10, + "TxnSignature": "F7AA8083EE7D7EFD10E11FF5A12B73D2D45A80094AEED4B41FBF2A90C9E03E5E4D162E91FA1EC156BF94E770E70E7633DF09665A5C2D5408178FC376BFC9B100", + "ctid": "C000157A00000000", + "date": 1710949602000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "265CEA78D8246B8B51D5CCC20AF4DB95569DE09E53115C888B176A3D1D05048A", + "NewFields": { + "AssetScale": 2, + "Flags": 34, + "Issuer": "rfGb6p2kWy3zQweWnYNxSFYoHeymcx7mhg", + "MPTokenMetadata": "68747470733A2F2F697066732E696F2F697066732F516D5A6E6A6D4239546B34786141394536373979747250586461336265574D4C556E4D423552466A316553744C70", + "MaximumAmount": "9223372036854775807", + "ImmutableFlags": 196610, + "Sequence": 5496, + "TransferFee": 10 + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "mpt_issuance_id": "0000157844C3F3B57A8B579FEE1033CC8E8498729D063617" + }, + "hash": "9686DA1322D2D8F9CD97C5848A8E3CADB9D3F73154DA59BB3A3CACC4CA43671C", + "ledger_index": 5498, + "date": 1710949602000 +} diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts index 065d02976..caa4d9912 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceCreate/types.ts @@ -4,4 +4,6 @@ export interface MPTokenIssuanceCreateInstructions { transferFee?: number assetScale?: number maxAmount?: string + // i18n keys of the fields/flags permanently locked via Dynamic MPT (XLS-94). + immutableFlags?: string[] } diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx index 9b0777e17..787cea2c3 100644 --- a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/Simple.tsx @@ -4,23 +4,44 @@ import { SimpleRow } from '../SimpleRow' import { TransactionSimpleComponent, TransactionSimpleProps } from '../types' import { Account } from '../../Account' import { MPTokenLink } from '../../MPTokenLink' -import { shortenEncryptionKey } from '../../../utils' - -interface MPTokenIssuanceSetExtended extends MPTokenIssuanceSet { - IssuerEncryptionKey?: string - AuditorEncryptionKey?: string -} +import { + isValidJsonString, + localizeNumber, + shortenEncryptionKey, +} from '../../../utils' +import { useLanguage } from '../../../hooks' +import { JsonView } from '../../JsonView' +import { + buildFlags, + convertHexToString, +} from '../../../../../rippled/lib/utils' +import { MPT_IMMUTABLE_FLAGS } from '../../../transactionUtils' export const Simple: TransactionSimpleComponent = ({ data, -}: TransactionSimpleProps) => { +}: TransactionSimpleProps) => { const { MPTokenIssuanceID, Holder, + MPTokenMetadata, + TransferFee, + ImmutableFlags, IssuerEncryptionKey, AuditorEncryptionKey, } = data.instructions const { t } = useTranslation() + const language = useLanguage() + + const metadata = MPTokenMetadata + ? convertHexToString(MPTokenMetadata) + : undefined + const formattedFee = + TransferFee != null + ? `${localizeNumber((TransferFee / 1000).toPrecision(5), language, { + minimumFractionDigits: 3, + })}%` + : undefined + const flagChanges = buildFlags(ImmutableFlags, MPT_IMMUTABLE_FLAGS) return ( <> @@ -32,6 +53,35 @@ export const Simple: TransactionSimpleComponent = ({ )} + {TransferFee != null && ( + + {formattedFee} + + )} + {metadata && ( + + {isValidJsonString(metadata) ? ( + + ) : ( + metadata + )} + + )} + {flagChanges.length > 0 && ( + + {flagChanges.map((flag) => ( +
{flag}
+ ))} +
+ )} {IssuerEncryptionKey && ( { unmount() }) + it('handles Dynamic MPT MPTokenIssuanceSet simple view', () => { + const { container, unmount } = renderComponent(transactionDynamic) + + expectSimpleRowText( + container, + 'mpt-issuance-id', + '000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18', + ) + expectSimpleRowText(container, 'mpt-fee', '0.500%') + expectSimpleRowText(container, 'mpt-metadata', 'FOO') + // ImmutableFlags 16 (0x10) = tifMPTCanTrade + expectSimpleRowText(container, 'mpt-mutable-flags', 'tifMPTCanTrade') + expectSimpleRowNotToExist(container, 'mpt-holder') + unmount() + }) + it('handles failed MPTokenIssuanceSet simple view ', () => { const { container, unmount } = renderComponent(transactionFail) expectSimpleRowText( diff --git a/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json new file mode 100644 index 000000000..b84dca60e --- /dev/null +++ b/src/containers/shared/components/Transaction/MPTokenIssuanceSet/test/mock_data/MPTokenIssuanceSet_Dynamic.json @@ -0,0 +1,73 @@ +{ + "tx": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Fee": "10", + "Flags": 0, + "MPTokenIssuanceID": "000002609BB39CEC721B5AB337B6BD862ACD2811CBBB5F18", + "ImmutableFlags": 16, + "TransferFee": 500, + "MPTokenMetadata": "464F4F", + "Sequence": 609, + "SigningPubKey": "ED92DDE49AA689EC63589623067968B85E4885A9874B3CAB89E07D192EBFA42FF9", + "TransactionType": "MPTokenIssuanceSet", + "TxnSignature": "6678BF017A62360DAA39156112960D934440467D2E4098958F053B6758A2D237DA82CD27FDA3B35BAB610D74669BF6C4DEB12D77984E48158B2D79C8BAEC3303", + "ctid": "C000026400000000", + "date": 1711133159000, + "hash": "undefined", + "inLedger": "undefined", + "ledger_index": "undefined", + "meta": "undefined", + "validated": "undefined", + "metaData": "undefined", + "status": "undefined" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "Balance": "99999980", + "Flags": 0, + "OwnerCount": 1, + "Sequence": 610 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "1D54D263727856612FC7C7A27D93532ED0C41B74FA651992C125AD19C900669D", + "PreviousFields": { + "Balance": "99999990", + "Sequence": 609 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 32, + "Issuer": "rEUGuTqrySk9o1rZSVx8seuvcsEZymeEYM", + "ImmutableFlags": 131106, + "OutstandingAmount": "0", + "OwnerNode": "0", + "Sequence": 608, + "TransferFee": 500 + }, + "LedgerEntryType": "MPTokenIssuance", + "LedgerIndex": "E8DF796110B1FBD9086A4637491E49843D33D897FBC32F03A33140F76378EE86", + "PreviousFields": { + "Flags": 34, + "TransferFee": 0 + }, + "PreviousTxnID": "CF68A8D929F089F6F53B071250935DD7C3F52F6E175D82E53118265D776D4BF7", + "PreviousTxnLgrSeq": 610 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "73C21D8B5DFBF5DE03FCAF0D69C00E9E2918280561E9898C556A1C743A566D47", + "ledger_index": 612, + "date": 1711133159000 +} diff --git a/src/containers/shared/transactionUtils.ts b/src/containers/shared/transactionUtils.ts index 6b3c4900c..5d0288bda 100644 --- a/src/containers/shared/transactionUtils.ts +++ b/src/containers/shared/transactionUtils.ts @@ -74,8 +74,14 @@ export const TX_FLAGS: Record> = { MPTokenIssuanceSet: { 0x00000001: 'tfMPTLock', 0x00000002: 'tfMPTUnlock', - 0x00001000: 'tfMPTSetCanConfidentialAmount', - 0x00002000: 'tfMPTClearCanConfidentialAmount', + // Capability-setting flags (XLS-94 DynamicMPT): one-way, moved into Flags + 0x00000004: 'tfMPTSetCanLock', + 0x00000008: 'tfMPTSetRequireAuth', + 0x00000010: 'tfMPTSetCanEscrow', + 0x00000020: 'tfMPTSetCanTrade', + 0x00000040: 'tfMPTSetCanTransfer', + 0x00000080: 'tfMPTSetCanClawback', + 0x00000100: 'tfMPTSetCanHoldConfidentialBalance', }, NFTokenMint: { 0x00000001: 'tfBurnable', @@ -119,6 +125,27 @@ export const TX_FLAGS: Record> = { }, } +// Dynamic MPT (XLS-94) ImmutableFlags fields. These are separate UInt32 fields +// from the standard `Flags` field, so they are decoded with their own maps. +// Semantics: by default all fields/capabilities are mutable; setting a bit in +// ImmutableFlags permanently locks that field/capability so it can never be +// changed again via MPTokenIssuanceSet. + +// MPTokenIssuanceCreate.ImmutableFlags / MPTokenIssuanceSet.ImmutableFlags: +// `tif` prefix (Transaction ImmutableFlag). Setting a bit permanently locks +// the corresponding capability/field. Same bit values as the ledger `lsif*` map. +export const MPT_IMMUTABLE_FLAGS: Record = { + 0x00000002: 'tifMPTCanLock', + 0x00000004: 'tifMPTRequireAuth', + 0x00000008: 'tifMPTCanEscrow', + 0x00000010: 'tifMPTCanTrade', + 0x00000020: 'tifMPTCanTransfer', + 0x00000040: 'tifMPTCanClawback', + 0x00000080: 'tifMPTCanHoldConfidentialBalance', + 0x00010000: 'tifMPTMetadata', + 0x00020000: 'tifMPTTransferFee', +} + export const ACCOUNT_FLAGS: Record = { 17: 'asfAllowTrustLineLocking', 16: 'asfAllowTrustLineClawback', diff --git a/src/rippled/lib/utils.ts b/src/rippled/lib/utils.ts index 823870688..2e1781d5c 100644 --- a/src/rippled/lib/utils.ts +++ b/src/rippled/lib/utils.ts @@ -47,6 +47,21 @@ const MPT_ISSUANCE_FLAGS: FlagMap = { 0x00000040: 'lsfMPTCanClawback', 0x00000080: 'lsfMPTCanConfidentialAmount', } +// ImmutableFlags on the MPTokenIssuance ledger object (Dynamic MPT, XLS-94). +// A set bit permanently locks the corresponding field/capability — the issuer +// can no longer change it via MPTokenIssuanceSet. Absent = everything mutable. +// `lsif` prefix mirrors the `tif` transaction-level names; bit values are shared. +const MPT_ISSUANCE_IMMUTABLE_FLAGS: FlagMap = { + 0x00000002: 'lsifMPTCanLock', + 0x00000004: 'lsifMPTRequireAuth', + 0x00000008: 'lsifMPTCanEscrow', + 0x00000010: 'lsifMPTCanTrade', + 0x00000020: 'lsifMPTCanTransfer', + 0x00000040: 'lsifMPTCanClawback', + 0x00000080: 'lsifMPTCanHoldConfidentialBalance', + 0x00010000: 'lsifMPTMetadata', + 0x00020000: 'lsifMPTTransferFee', +} const MPTOKEN_FLAGS: FlagMap = { 0x00000001: 'lsfMPTLocked', 0x00000002: 'lsfMPTAuthorized', @@ -267,6 +282,7 @@ interface MPTIssuanceInfo { Sequence: number MPTokenMetadata?: string Flags: number + ImmutableFlags?: number IssuerEncryptionKey?: string AuditorEncryptionKey?: string } @@ -281,6 +297,7 @@ interface FormattedMPTIssuance { sequence: number metadata?: any flags: string[] + immutableFlags: string[] rawMPTMetadata?: string parsedMPTMetadata?: Record isMPTMetadataCompliant: boolean @@ -312,6 +329,10 @@ const formatMPTIssuance = (info: MPTIssuanceInfo): FormattedMPTIssuance => { parsedMPTMetadata: parseMPTMetadata(rawMPTMetadataHex), isMPTMetadataCompliant: isMPTMetadataCompliant(rawMPTMetadataHex), flags: buildFlags(info.Flags, MPT_ISSUANCE_FLAGS), + immutableFlags: buildFlags( + info.ImmutableFlags, + MPT_ISSUANCE_IMMUTABLE_FLAGS, + ), issuerEncryptionKey: info.IssuerEncryptionKey, auditorEncryptionKey: info.AuditorEncryptionKey, }