This tutorial shows you how to issue a Multi-Purpose Token (MPT) that supports Confidential Transfers, so that account balances and transfer amounts stay encrypted on-ledger.
Requires the ConfidentialTransfer amendment. Loading...
By the end of this tutorial, you will be able to:
- Generate the EC-ElGamal keypairs that confidential transfers require.
- Issue an MPT configured for confidential transfers.
- Merge an inbox balance into a spending balance and decrypt it to verify the result.
To complete this tutorial, you should:
- Have a basic understanding of the XRP Ledger.
- Understand the Confidential Transfers concept, in particular the issuer second account model and the split-balance model.
- Have an XRP Ledger client library set up in your development environment. This page provides examples for the following:
- JavaScript with the xrpl.js library. See Get Started Using JavaScript for setup steps.
- Python with the xrpl-py library. See Get Started Using Python for setup steps.
You can find the complete source code for this tutorial's examples in the code samples section of this website's repository.
From the code sample folder, use npm to install dependencies.
npm installTo get started, import the necessary libraries and instantiate a client to connect to the XRP Ledger. This example imports:
fs: Used to write the generated accounts and keys to a local file.xrpl: Used for XRPL client connection, transaction submission, and wallet handling.
import fs from 'fs'
import {
Client,
MPTokenIssuanceCreateFlags,
deriveConfidentialKeypair,
encodeMPTokenMetadata,
fetchMPToken,
fetchMPTokenIssuance,
loadMptCrypto,
prepareConfidentialConvert,
prepareConfidentialMergeInbox
} from 'xrpl'
// Connect to the network ----------------------
const client = new Client('wss://s.devnet.rippletest.net:51233')
await client.connect()
const EXPLORER = 'https://devnet.xrpl.org'
const ticker = 'CTST'
const supplyAmount = 12000Next, fund the three accounts this example needs:
- Issuer: Creates the MPT issuance and registers the encryption keys.
- Second account: A regular holder account that the issuer controls. This is the account that holds the confidential balance.
- Auditor: An independent party that can decrypt every holder's balance for this issuance.
// Fund the accounts ----------------------
// An issuer cannot hold a confidential balance on the account that issues the
// token, because an issuer's own balance is not counted as tokens in
// circulation. Confidential tokens enter circulation through a second account
// the issuer controls, which the ledger treats as a regular holder.
console.log(`\n=== Funding accounts... ===`)
const [{ wallet: issuer }, { wallet: issuerSecondAccount }, { wallet: auditor }] =
await Promise.all([
client.fundWallet(),
client.fundWallet(),
client.fundWallet()
])
console.log(`Issuer address: ${issuer.address}`)
console.log(`Issuer second account address: ${issuerSecondAccount.address}`)
console.log(`Auditor address: ${auditor.address}`)An issuing account can't hold a confidential balance of its own token, because an issuer's balance doesn't count as tokens in circulation. Confidential tokens enter circulation through a second account that the issuer controls and that the ledger treats as a regular holder.
The auditor account is optional, so include it only if the token needs independent oversight. The auditor key must be registered in the same transaction as the issuer key, so this decision is permanent.
Confidential transfers use EC-ElGamal encryption over secp256k1. Each participant needs an encryption keypair, which is separate from the account's signing keys. The private key is the only way to decrypt a balance, and the public key is what gets registered on the ledger.
Generate the encryption keypairs with deriveConfidentialKeypair(). This helper function derives the keypair deterministically from an account's seed, so one backed-up secret recovers both the signing key and the encryption key.
// Generate confidential encryption keypairs ----------------------
// These ElGamal keypairs are separate from the accounts' signing keys.
console.log(`\n=== Generating confidential encryption keypairs... ===`)
const issuerKeys = deriveConfidentialKeypair(issuer.seed)
const issuerSecondAccountKeys = deriveConfidentialKeypair(issuerSecondAccount.seed)
const auditorKeys = deriveConfidentialKeypair(auditor.seed)
console.log(`Issuer public encryption key: ${issuerKeys.publicKey}`)
console.log(`Second account public encryption key: ${issuerSecondAccountKeys.publicKey}`)
console.log(`Auditor public encryption key: ${auditorKeys.publicKey}`)Store the encryption private keys securely. If a holder loses their encryption private key, their confidential balance is permanently unspendable. The holder can't decrypt it and can't generate the proofs needed to send or convert it back. Registered public keys are permanent and can't be changed or cleared.
Submit an MPTokenIssuanceCreate transaction with the tfMPTCanHoldConfidentialBalance flag. This flag is what makes the issuance eligible for confidential balances.
The example sets tfMPTCanTransfer so holders can send the token to each other, tfMPTCanClawback so the issuer can recover balances, and tfMPTCanLock so the issuer can freeze them.
// Create the MPT issuance ----------------------
console.log(`\n=== Creating the MPT issuance ===`)
const mptMetadata = {
ticker,
name: 'Confidential Token',
desc: 'A confidential demo token.',
icon: 'https://example.org/ctst-icon.png',
asset_class: 'rwa',
asset_subclass: 'treasury',
issuer_name: 'Example Financial Corp',
additional_info: {
interest_rate: '4.25%',
interest_type: 'fixed'
}
}
const mptIssuanceCreate = {
TransactionType: 'MPTokenIssuanceCreate',
Account: issuer.address,
AssetScale: 0,
MaximumAmount: '1000000000',
TransferFee: 0,
Flags:
MPTokenIssuanceCreateFlags.tfMPTCanHoldConfidentialBalance |
MPTokenIssuanceCreateFlags.tfMPTCanTransfer |
MPTokenIssuanceCreateFlags.tfMPTCanClawback |
MPTokenIssuanceCreateFlags.tfMPTCanLock,
MPTokenMetadata: encodeMPTokenMetadata(mptMetadata)
}
const createResponse = await client.submitAndWait(mptIssuanceCreate, {
wallet: issuer,
autofill: true
})
if (createResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = createResponse.result.meta.TransactionResult
console.error('Error: Unable to create the MPT issuance:', resultCode)
await client.disconnect()
process.exit(1)
}
const mptIssuanceID = createResponse.result.meta.mpt_issuance_id
console.log(`MPT issuance ID: ${mptIssuanceID}`)
console.log(`${EXPLORER}/transactions/${createResponse.result.hash}`)You can also add this capability to an existing issuance with the tfMPTSetCanHoldConfidentialBalance flag on an MPTokenIssuanceSet transaction, as long as the issuer didn't mark the capability immutable at creation. Enabling it is one-way, so there is no flag to turn it off.
The TransferFee must be 0 on an issuance that can hold confidential balances, because the ledger can't compute a percentage fee on an encrypted amount.
Holders can't convert their balances into confidential ones until the issuer registers an IssuerEncryptionKey with an MPTokenIssuanceSet transaction. Register an AuditorEncryptionKey in the same transaction if the token needs independent oversight.
// Register the encryption keys on the issuance ----------------------
console.log(`\n=== Registering the encryption keys... ===`)
const mptIssuanceSet = {
TransactionType: 'MPTokenIssuanceSet',
Account: issuer.address,
MPTokenIssuanceID: mptIssuanceID,
IssuerEncryptionKey: issuerKeys.publicKey,
AuditorEncryptionKey: auditorKeys.publicKey
}
const setResponse = await client.submitAndWait(mptIssuanceSet, {
wallet: issuer,
autofill: true
})
if (setResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = setResponse.result.meta.TransactionResult
console.error('Error: Unable to register the encryption keys:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log('Issuer and auditor encryption keys registered.')
console.log(`${EXPLORER}/transactions/${setResponse.result.hash}`)Key registration is a permanent operation:
- Neither key can be changed or removed after registration. Resubmitting a key that's already on the issuance returns
tecNO_PERMISSION. AuditorEncryptionKeyrequiresIssuerEncryptionKeyin the same transaction, or the transaction returnstemMALFORMED. Because the issuer key can't be resubmitted, this is the only chance to register an auditor.
Once an auditor is registered, every confidential transaction on the issuance must have an AuditorEncryptedAmount.
To introduce confidential tokens into circulation, the issuer must send the required amount to their second account, and convert it.
Authorize the second account to hold the MPT with an MPTokenAuthorize transaction, then send it the required amount with a Payment transaction.
// Authorize the second account ----------------------
console.log(`\n=== Authorizing the issuer second account... ===`)
const mptAuthorize = {
TransactionType: 'MPTokenAuthorize',
Account: issuerSecondAccount.address,
MPTokenIssuanceID: mptIssuanceID
}
const authorizeResponse = await client.submitAndWait(mptAuthorize, {
wallet: issuerSecondAccount,
autofill: true
})
if (authorizeResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = authorizeResponse.result.meta.TransactionResult
console.error('Error: Unable to authorize the second account:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log(`${issuerSecondAccount.address} is authorized to hold the MPT.`)
console.log(`${EXPLORER}/transactions/${authorizeResponse.result.hash}`)
// Send public tokens to the second account ----------------------
console.log(`\n=== Sending public tokens to the second account... ===`)
const payment = {
TransactionType: 'Payment',
Account: issuer.address,
Destination: issuerSecondAccount.address,
Amount: {
mpt_issuance_id: mptIssuanceID,
value: String(supplyAmount)
}
}
const paymentResponse = await client.submitAndWait(payment, {
wallet: issuer,
autofill: true
})
if (paymentResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = paymentResponse.result.meta.TransactionResult
console.error('Error: Unable to send the payment:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log(`Issuer sent ${supplyAmount} ${ticker} to ${issuerSecondAccount.address}.`)
console.log(`${EXPLORER}/transactions/${paymentResponse.result.hash}`)Note that this payment is public. The amount is visible to anyone reading the ledger, and it sets the ceiling on how much this account can hold confidentially.
Submit a ConfidentialMPTConvert transaction to convert the public balance into a confidential one.
Confidential transactions cost 10 times the standard transaction cost.
prepareConfidentialConvert reads the issuer's and auditor's registered public keys from the MPTokenIssuance entry, so you pass only the holder's own keypair.
// Convert the public balance to a confidential balance ----------------------
console.log(`\n=== Converting public balance to confidential... ===`)
const convertTx = await prepareConfidentialConvert(client, {
account: issuerSecondAccount.address,
mptIssuanceID,
amount: BigInt(supplyAmount),
holderKeypair: issuerSecondAccountKeys
})
console.log(JSON.stringify(convertTx, null, 2))
const convertResponse = await client.submitAndWait(convertTx, {
wallet: issuerSecondAccount,
autofill: true
})
if (convertResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = convertResponse.result.meta.TransactionResult
console.error('Error: Unable to convert the balance:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log(`Converted ${supplyAmount} ${ticker} into a confidential balance.`)
console.log(`${EXPLORER}/transactions/${convertResponse.result.hash}`)The helper function handles the following for you:
- Encrypts the amount under the holder's, the issuer's, and any auditor's public keys, using one shared blinding factor so every ciphertext commits to the same value.
- Generates a Schnorr proof that the account controls the encryption key it registers.
- Sets a
HolderEncryptionKey, so the holder's first conversion doubles as their opt-in. No separate registration step is needed.
The Schnorr proof commits to the account's Sequence number as the helper function read it from the ledger. Submit the prepared transaction before sending anything else from that account. If another transaction lands first, the prepared transaction is no longer valid and must be rebuilt.
Note that the MPTAmount field on a conversion is plaintext. Observers can see how much moved into the confidential pool, but not how it's distributed or spent afterwards.
A confidential balance has two buckets on the MPToken entry:
ConfidentialBalanceInboxreceives incoming funds, from both conversions and confidential payments.ConfidentialBalanceSpendingis the only bucket a ConfidentialMPTSend transaction can draw from.
Submit a ConfidentialMPTMergeInbox transaction to move the inbox balance into the spending balance. The two buckets exist so that an incoming payment can't invalidate a proof that the holder is already building against their spending balance.
// Merge the inbox into the spending balance ----------------------
// A conversion lands in the inbox balance. Merging folds it into the spending
// balance, which is the only balance a confidential send can draw from.
console.log(`\n=== Merging inbox into spending balance... ===`)
const mergeTx = await prepareConfidentialMergeInbox(client, {
account: issuerSecondAccount.address,
mptIssuanceID
})
console.log(JSON.stringify(mergeTx, null, 2))
const mergeResponse = await client.submitAndWait(mergeTx, {
wallet: issuerSecondAccount,
autofill: true
})
if (mergeResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = mergeResponse.result.meta.TransactionResult
console.error('Error: Unable to merge the inbox:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log('Inbox merged into the spending balance.')
console.log(`${EXPLORER}/transactions/${mergeResponse.result.hash}`)Each merge increments the holder's ConfidentialBalanceVersion. Proofs for confidential sends are bound to that version, so a merge invalidates any proof that was built against a previous one.
The second account is controlled by the issuer, so the issuer holds its encryption private key. Read the second account's MPToken entry, then decrypt ConfidentialBalanceSpending with that key to confirm the conversion was successful.
// Decrypt the confidential balance ----------------------
console.log(`\n=== Decrypting the confidential balance... ===`)
const [mptoken, mptIssuance] = await Promise.all([
fetchMPToken(client, issuerSecondAccount.address, mptIssuanceID),
fetchMPTokenIssuance(client, mptIssuanceID)
])
// The entry carries the same balance once per registered key, so each party
// reads it with their own private key.
console.log(`MPToken entry:`)
console.log(JSON.stringify(mptoken, null, 2))
const crypto = await loadMptCrypto()
const confidentialSupply = BigInt(mptIssuance.ConfidentialOutstandingAmount)
// Only a party holding the matching private key can decrypt the balance.
const secondAccountBalance = await crypto.decryptAmount(
mptoken.ConfidentialBalanceSpending,
issuerSecondAccountKeys.privateKey,
confidentialSupply
)
console.log(`\nSecond account reads its balance as: ${secondAccountBalance} ${ticker}`)The MPToken also has an AuditorEncryptedBalance field, which holds the same amount, but is encrypted under the auditor key. The auditor can decrypt it with its own private key and should see the same balance, without ever needing the issuer's key.
// The auditor reads the same amount from a separate ciphertext on the same
// entry, using its own private key.
const auditorView = await crypto.decryptAmount(
mptoken.AuditorEncryptedBalance,
auditorKeys.privateKey,
confidentialSupply
)
console.log(`Auditor reads the balance as: ${auditorView} ${ticker}`)Everyone else sees only the ciphertext.
The example writes the account seeds and the encryption keypairs to a keys.json file, so you can reuse these accounts.
Saving keys to a JSON file is not secure, and is only acceptable when working on a test network. In production, store them in a secure, encrypted key store.
// Save the accounts and keys ----------------------
// Losing an encryption private key makes a confidential balance permanently
// unspendable, so write the seeds and keypairs to keys.json.
console.log(`\n=== Saving accounts and keys to keys.json... ===`)
const keysData = {
description:
'This file is auto-generated by issueConfidentialMPT.js. It stores the account seeds and confidential encryption keypairs that script created.',
issuer: {
seed: issuer.seed,
privateKey: issuerKeys.privateKey,
publicKey: issuerKeys.publicKey
},
issuerSecondAccount: {
seed: issuerSecondAccount.seed,
privateKey: issuerSecondAccountKeys.privateKey,
publicKey: issuerSecondAccountKeys.publicKey
},
auditor: {
seed: auditor.seed,
privateKey: auditorKeys.privateKey,
publicKey: auditorKeys.publicKey
}
}
fs.writeFileSync('keys.json', JSON.stringify(keysData, null, 2))
console.log('Saved keys to file.')
await client.disconnect()