This tutorial shows you how to send a confidential Multi-Purpose Token (MPT) payment, where the amount sent is encrypted instead of being publicly visible.
In the example, a seller sends a tokenized fund and a buyer pays for it with a different token (for example, a stablecoin). This is coordinated by an orchestrator, which represents a third party, such as an exchange. The orchestrator uses a Batch transaction to settle both confidential payments atomically, without ever knowing the amounts involved.
Requires the ConfidentialTransfer amendment. Loading...
By the end of this tutorial, you will be able to:
- Prepare a confidential MPT payment, including the Zero-Knowledge Proof (ZKP) that the ledger validates it against.
- Settle two confidential payments atomically with a Batch transaction.
- Confirm each payment settled, and decrypt the resulting balances as a holder and as an auditor.
To complete this tutorial, you should:
- Have a basic understanding of the XRP Ledger and the Confidential Transfers concept.
- Have two accounts that each hold a confidential balance of a different MPT, with their encryption public keys registered on-ledger. See Issue an MPT for Confidential Transfers. The setup script for this tutorial creates them for you.
- 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 js/ 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 check for and load the tutorial setup data.xrpl: Used for XRPL client connection, transaction submission, and wallet handling../confidentialTransfersSetup.js: The tutorial setup script, imported and called directly.
import fs from 'fs'
import {
BatchFlags,
Client,
GlobalFlags,
Wallet,
combineBatchSigners,
deriveConfidentialKeypair,
fetchMPToken,
fetchMPTokenIssuance,
getConfidentialBalance,
hashes,
loadMptCrypto,
prepareConfidentialMergeInbox,
prepareConfidentialSend,
signMultiBatch,
validate
} from 'xrpl'
import { setup } from './confidentialTransfersSetup.js'
// Connect to the network ----------------------
const client = new Client('wss://s.devnet.rippletest.net:51233')
await client.connect()
const EXPLORER = 'https://devnet.xrpl.org'Load the accounts, MPT issuance IDs, and encryption keys. This example uses pre-configured data from the setup script, including public and private keys, but you can replace these with your own values.
// Load setup data ----------------------
// This step checks for the necessary setup data to run the tutorial.
// If missing, confidentialTransfersSetup.js will generate it.
if (!fs.existsSync('confidentialTransfersSetup.json')) {
console.log(`\n=== Setup data doesn't exist. Running setup script... ===\n`)
await setup()
}
const setupData = JSON.parse(
fs.readFileSync('confidentialTransfersSetup.json', 'utf8')
)
// Set up accounts
console.log(`\n=== Getting accounts... ===`)
const { wallet: orchestrator } = await client.fundWallet()
const seller = Wallet.fromSeed(setupData.seller.seed)
const buyer = Wallet.fromSeed(setupData.buyer.seed)
const auditor = Wallet.fromSeed(setupData.auditor.seed)
// deriveConfidentialKeypair rebuilds a confidential encryption keypair from an
// account seed. The same seed always gives the same keypair.
const sellerKeys = deriveConfidentialKeypair(seller.seed)
const buyerKeys = deriveConfidentialKeypair(buyer.seed)
const auditorKeys = deriveConfidentialKeypair(auditor.seed)
console.log(`Orchestrator address: ${orchestrator.address}`)
console.log(`Seller address: ${seller.address}`)
console.log(`Buyer address: ${buyer.address}`)
console.log(`Auditor address: ${auditor.address}`)In xrpl.js, the deriveConfidentialKeypair function rebuilds a confidential encryption keypair from an account seed, so the same seed always gives you the same keypair.
For testing purposes, the example private keys are stored in a JSON file by the setup script. In production, private keys should be stored in a secure, encrypted key store.
The starting balances that the setup script distributed are in each holder's ConfidentialBalanceInbox, and a confidential payment can only spend from ConfidentialBalanceSpending.
Submit a ConfidentialMPTMergeInbox transaction to move those funds into each holder's spending balance.
// Make each holder's balance spendable ----------------------
console.log(`\n=== Merging inbox balance for seller and buyer... ===`)
const tokens = [setupData.fund, setupData.stablecoin]
const holders = [
{ name: 'Seller', holder: seller, keys: sellerKeys },
{ name: 'Buyer', holder: buyer, keys: buyerKeys }
]
const holdings = [
{ ...setupData.fund, holder: seller },
{ ...setupData.stablecoin, holder: buyer }
]
for (const { ticker, mptIssuanceID, holder } of holdings) {
const mergeTx = await prepareConfidentialMergeInbox(client, {
account: holder.address,
mptIssuanceID
})
const mergeResponse = await client.submitAndWait(mergeTx, {
wallet: holder,
autofill: true
})
const mergeResult = mergeResponse.result.meta.TransactionResult
if (mergeResult !== 'tesSUCCESS') {
console.error(`Error: Unable to merge the ${ticker} inbox:`, mergeResult)
await client.disconnect()
process.exit(1)
}
console.log(`${holder.address} holds spendable confidential ${ticker}.`)
console.log(`${EXPLORER}/transactions/${mergeResponse.result.hash}\n`)
}To make a confidential payment, you must submit a ConfidentialMPTSend transaction.
Create each transaction with the confidential send helper function: prepareConfidentialSend in xrpl.js, or prepare_confidential_send in xrpl-py. The helper encrypts the amount under the four public keys, and generates the Zero-Knowledge Proof in the transaction's ZKProof field that the ledger validates the transfer against. Without revealing the amount, the proof shows that:
- Every encrypted amount on the transaction encrypts the same value.
- The balance being spent is the one the ledger holds for the sender.
- The sender's remaining balance doesn't go negative.
The proof is also bound to the sender's sequence number, so don't submit anything else from either account between preparing the payments and submitting the Batch.
// Build both confidential payments ----------------------
const fundAmount = BigInt(100)
const cashAmount = BigInt(500)
const [fundPayment, cashPayment] = await Promise.all([
prepareConfidentialSend(client, {
account: seller.address,
destination: buyer.address,
mptIssuanceID: setupData.fund.mptIssuanceID,
amount: fundAmount,
senderKeypair: sellerKeys
}),
prepareConfidentialSend(client, {
account: buyer.address,
destination: seller.address,
mptIssuanceID: setupData.stablecoin.mptIssuanceID,
amount: cashAmount,
senderKeypair: buyerKeys
})
])
console.log(`=== Prepared confidential payments ===`)
console.log(`Payment1 (Fund): ${JSON.stringify(fundPayment, null, 2)}`)
console.log(`\nPayment2 (Cash): ${JSON.stringify(cashPayment, null, 2)}`)
// Every inner batch transaction must have the tfInnerBatchTxn flag set.
fundPayment.Flags = GlobalFlags.tfInnerBatchTxn
cashPayment.Flags = GlobalFlags.tfInnerBatchTxnUse a Batch transaction to submit both confidential payments atomically with the tfAllOrNothing flag. This ensures that if there is a failure on either side, the whole transaction reverts.
Inner transactions must have a Fee of 0, so the outer Batch pays for everything inside it. That means 10 times the standard transaction cost for each confidential payment, plus twice the standard cost for the Batch itself, plus one standard cost for each signer.
autofill covers each payment's full cost, so you only need to tell it how many inner signers to expect.
// Settle both payments atomically ----------------------
console.log(`=== Submit confidential payments in batch... ===`)
console.log(`Seller sends ${setupData.fund.ticker} to Buyer.`)
console.log(`Buyer sends ${setupData.stablecoin.ticker} to Seller.\n`)
const batchTx = {
TransactionType: 'Batch',
Account: orchestrator.address,
Flags: BatchFlags.tfAllOrNothing,
RawTransactions: [
{ RawTransaction: fundPayment },
{ RawTransaction: cashPayment }
]
}
validate(batchTx)
const autofilledBatchTx = await client.autofill(batchTx, 2)
const sellerBatch = { ...autofilledBatchTx }
signMultiBatch(seller, sellerBatch)
const buyerBatch = { ...autofilledBatchTx }
signMultiBatch(buyer, buyerBatch)
const combinedBatchTx = combineBatchSigners([sellerBatch, buyerBatch])
const batchResponse = await client.submitAndWait(combinedBatchTx, {
wallet: orchestrator
})
if (batchResponse.result.meta.TransactionResult !== 'tesSUCCESS') {
const resultCode = batchResponse.result.meta.TransactionResult
console.error('Error: Unable to submit the Batch:', resultCode)
await client.disconnect()
process.exit(1)
}
console.log(`Batch transaction hash: ${batchResponse.result.hash}`)
console.log(`${EXPLORER}/transactions/${batchResponse.result.hash}`)The seller and buyer sign the Batch for their own inner payment, then the two sets of signers are combined and the orchestrator submits it.
A tesSUCCESS on the Batch only means the Batch itself was well-formed. Each inner payment has its own result, so you must verify the result of each inner transaction.
// Verify each payment individually ----------------------
// A tesSUCCESS on the Batch only means the Batch itself was well-formed.
// Hash each inner transaction and look it up to confirm both payments applied.
console.log(`\n=== Verifying both payments... ===`)
const rawTransactions = batchResponse.result.tx_json.RawTransactions
for (const [index, { RawTransaction }] of rawTransactions.entries()) {
const innerHash = hashes.hashSignedTx(RawTransaction)
const innerTx = await client.request({ command: 'tx', transaction: innerHash })
const innerResult = innerTx.result.meta.TransactionResult
console.log(`Payment ${index + 1}: ${innerResult}`)
console.log(`${EXPLORER}/transactions/${innerHash}`)
if (innerResult !== 'tesSUCCESS') {
console.error('Error: An inner payment failed:', innerResult)
await client.disconnect()
process.exit(1)
}
}
console.log(`\nPayments both successful!`)With tfAllOrNothing, both inner results are either tesSUCCESS or reverted together, so this check confirms the settlement applied as intended.
Now that each confidential payment has settled, each amount is in its recipient's ConfidentialBalanceInbox. A holder can decrypt an inbox amount, but can't spend it.
Merge the confidential balances so both holders can spend them later.
// Merge the received amounts into each spending balance ----------------------
console.log(`\n=== Merging settled amounts into spending balance... ===`)
const settlements = [
{ ...setupData.fund, recipient: buyer },
{ ...setupData.stablecoin, recipient: seller }
]
for (const { ticker, mptIssuanceID, recipient } of settlements) {
const mergeTx = await prepareConfidentialMergeInbox(client, {
account: recipient.address,
mptIssuanceID
})
const mergeResponse = await client.submitAndWait(mergeTx, {
wallet: recipient,
autofill: true
})
const mergeResult = mergeResponse.result.meta.TransactionResult
if (mergeResult !== 'tesSUCCESS') {
console.error(`Error: Unable to merge the ${ticker} inbox:`, mergeResult)
await client.disconnect()
process.exit(1)
}
console.log(`${recipient.address} can spend the ${ticker} it received.`)
console.log(`${EXPLORER}/transactions/${mergeResponse.result.hash}\n`)
}Reading a confidential amount requires an encryption private key, so only the two holders and the auditor can decrypt anything here.
First, decrypt each holder's balance on both issuances.
// Decrypt balances as each holder ----------------------
console.log(`=== Decrypting balances as each holder... ===`)
for (const { name, holder, keys } of holders) {
console.log(`${name} reads its own balance as:`)
for (const token of tokens) {
const balance = await getConfidentialBalance(
client,
holder.address,
token.mptIssuanceID,
keys.privateKey
)
console.log(` - ${balance} ${token.ticker}`)
}
}Each holder reads ConfidentialBalanceSpending, and only with its own encryption private key, so neither can read the other's balance.
Then, as the auditor, decrypt both balances along with the amount each payment moved.
// Decrypt the balances and amounts as the auditor ----------------------
console.log(`\n=== Decrypting balances and amounts as the auditor... ===`)
const crypto = await loadMptCrypto()
const confidentialSupplies = {}
for (const token of tokens) {
const issuance = await fetchMPTokenIssuance(client, token.mptIssuanceID)
confidentialSupplies[token.mptIssuanceID] = BigInt(
issuance.ConfidentialOutstandingAmount
)
}
for (const { name, holder } of holders) {
console.log(`Auditor reads the ${name.toLowerCase()}'s balance as:`)
for (const token of tokens) {
const mptoken = await fetchMPToken(
client,
holder.address,
token.mptIssuanceID
)
const auditorView = await crypto.decryptAmount(
mptoken.AuditorEncryptedBalance,
auditorKeys.privateKey,
confidentialSupplies[token.mptIssuanceID]
)
console.log(` - ${auditorView} ${token.ticker}`)
}
}
console.log(`\nAuditor reads the settled amounts as:`)
const settled = [
{ token: setupData.fund, payment: fundPayment },
{ token: setupData.stablecoin, payment: cashPayment }
]
for (const { token, payment } of settled) {
const settledAmount = await crypto.decryptAmount(
payment.AuditorEncryptedAmount,
auditorKeys.privateKey,
confidentialSupplies[token.mptIssuanceID]
)
console.log(` - ${settledAmount} ${token.ticker}`)
}
await client.disconnect()The auditor decrypts AuditorEncryptedBalance on the same entries with its own encryption private key, so one key reads both sides of the settlement. The same key decrypts AuditorEncryptedAmount on each payment, which is the amount that moved rather than the balance it landed in.
Everyone else (for example, the orchestrator) only sees the encrypted amounts, on the balances and on the payments that moved them.