Skip to content

Send Confidential MPT Payments

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...

Goals

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.

Prerequisites

To complete this tutorial, you should:

Source Code

You can find the complete source code for this tutorial's examples in the code samples section of this website's repository.

Steps

1. Install dependencies

From the js/ folder, use npm to install dependencies.

npm install

2. Set up the client and accounts

To 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.

Caution

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.

3. Make each holder's balance spendable

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`)
}

4. Prepare the ConfidentialMPTSend transactions

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.tfInnerBatchTxn

5. Submit confidential payments

Use 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.

6. Verify each payment individually

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.

7. Merge the received confidential amounts

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`)
}

8. Decrypt the balances and settled amounts

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.

See Also