builders
Overview
The confidential/builder package is the high-level entry point for XLS-96 transaction construction. It is part of the optional confidential module, not the core module.
Each operation comes in two forms:
Build*: queries live ledger state through aLedgerQuerier.Prepare*: builds the same transaction from explicit inputs, which is useful for offline signing or test fixtures.
The LedgerQuerier interface is intentionally small, and both rpc.Client and websocket.Client satisfy it:
type LedgerQuerier interface {
GetAccountInfo(req *account.InfoRequest) (*account.InfoResponse, error)
GetLedgerEntry(req *ledger.EntryRequest) (*ledger.EntryResponse, error)
}
Builder families
BuildConvert and PrepareConvert
Use these for ConfidentialMPTConvert.
- Queries or accepts the account sequence.
- Resolves issuer and optional auditor encryption keys from the
MPTokenIssuance. - Detects whether the holder is opting in for the first time.
- Encrypts the converted amount for the holder, issuer, and optional auditor.
- On first use, adds
HolderEncryptionKeyand generates the Schnorr proof required to register it.
Amount == 0 is allowed here because zero-amount convert is the opt-in path for registering a holder key.
First-time detection reads the holder's MPToken, which ConfidentialMPTConvert debits, so the entry
must already exist. A holder that has not authorized the issuance gets ErrMPTokenNotFound instead of
being treated as a first-time opt-in, and a failed read reports ErrLedgerQuery rather than silently
taking the first-time path.
tx, err := builder.BuildConvert(client, builder.BuildConvertParams{
Account: holderAddress,
IssuanceID: issuanceID,
Amount: 100,
HolderPrivKey: holderPrivKeyHex,
HolderPubKey: holderPubKeyHex,
})
BuildSend and PrepareSend
Use these for ConfidentialMPTSend.
- Resolves issuer, auditor, sender, and destination encryption keys.
- Reads the sender
MPTokenstate, includingConfidentialBalanceSpendingandConfidentialBalanceVersion. - Decrypts the sender's current confidential balance with the supplied private key and inclusive
BalanceRange. - Encrypts the transfer amount for sender, destination, issuer, and optional auditor.
- Builds both Pedersen commitments and the composite send proof.
This path requires the destination holder to already be initialized to receive: a registered
HolderEncryptionKey, a ConfidentialBalanceInbox, and the mirror balances the issuance
implies. A destination missing any of them, or with no MPToken at all, reports
ErrReceiverNotOptedIn.
DestinationTag and CredentialIDs are optional and forwarded to the transaction unchanged. Set DestinationTag when the destination is a hosted account, and CredentialIDs when the destination sits behind a permissioned domain.
tx, err := builder.BuildSend(client, builder.BuildSendParams{
Account: senderAddress,
Destination: receiverAddress,
IssuanceID: issuanceID,
Amount: 25,
SenderPrivKey: senderPrivKeyHex,
SenderPubKey: senderPubKeyHex,
BalanceRange: elgamal.AmountRange{
Low: 0,
High: 1_000_000,
},
})
BuildConvertBack and PrepareConvertBack
Use these for ConfidentialMPTConvertBack.
- Resolves issuer and optional auditor keys.
- Reads and decrypts the holder's current confidential spending balance within the supplied inclusive
BalanceRange. - Uses
ConfidentialBalanceVersionfrom ledger state. - Builds the encrypted withdrawal amount, balance commitment, and convert-back proof.
tx, err := builder.BuildConvertBack(client, builder.BuildConvertBackParams{
Account: holderAddress,
IssuanceID: issuanceID,
Amount: 10,
HolderPrivKey: holderPrivKeyHex,
HolderPubKey: holderPubKeyHex,
BalanceRange: elgamal.AmountRange{
Low: 0,
High: 1_000_000,
},
})
Bounded balance decryption
BuildSend and BuildConvertBack decrypt the current on-ledger spending balance before constructing a transaction. Their BalanceRange is the expected range of that current balance, not the amount being sent or converted back.
The Low and High bounds are inclusive and must contain the plaintext balance. They must satisfy Low <= High < math.MaxUint64. Decryption searches the interval linearly, so use the narrowest practical range; unnecessarily large ranges can make transaction construction slow. Omitting BalanceRange produces [0, 0], which only succeeds for a zero balance.
PrepareSend and PrepareConvertBack do not decrypt ledger state because their CurrentBalance is supplied explicitly.
BuildClawback and GetSpendingBalance bound their searches the same way, additionally capping High at the issuance's ConfidentialOutstandingAmount, which no single holder balance can exceed.
BuildClawback and PrepareClawback
Use these for ConfidentialMPTClawback.
- Resolves the issuer sequence and issuer encryption key.
- Reads the holder's
IssuerEncryptedBalancefrom the ledger. - Decrypts that ciphertext with
IssuerPrivKeyto derive the amount. - Generates the equality proof that binds the clawback amount to the issuer-visible ciphertext.
A clawback always removes the holder's complete confidential balance, so BuildClawback derives
the amount rather than accepting one. The search is bounded by BalanceRange and additionally
capped at the issuance's ConfidentialOutstandingAmount, which no holder balance can exceed.
Supply the amount yourself only on the offline PrepareClawback path, via ClawbackParams.Amount.
tx, err := builder.BuildClawback(client, builder.BuildClawbackParams{
Account: issuerAddress,
Holder: holderAddress,
IssuanceID: issuanceID,
IssuerPrivKey: issuerPrivKeyHex,
BalanceRange: elgamal.AmountRange{Low: 0, High: 1_000},
})
BuildMergeInbox and PrepareMergeInbox
Use these for ConfidentialMPTMergeInbox.
- Resolves the account sequence.
- Reads the
MPTokenIssuanceto confirm it allows confidential balances and is not locked. - Reads the holder
MPTokento confirm it carries both confidential balances and the holder encryption key, and that the holder is neither locked nor unauthorized. - Does not require
IssuerEncryptionKey, whichConfidentialMPTMergeInboxnever reads. - Performs no cryptographic work.
- Lets a holder move confidential inbox balance into spending balance.
tx, err := builder.BuildMergeInbox(client, builder.BuildMergeInboxParams{
Account: holderAddress,
IssuanceID: issuanceID,
})
Reading a spending balance
GetSpendingBalance is the read-only counterpart to the builders: it resolves a holder's
ConfidentialBalanceSpending from the ledger and decrypts it with that holder's own ElGamal
private key. Nothing is built or submitted, and no account sequence is read, because a balance
read spends none.
balance, err := builder.GetSpendingBalance(client, builder.SpendingBalanceParams{
Holder: holderAddress,
IssuanceID: issuanceID,
HolderPrivKey: holderPrivKeyHex,
BalanceRange: elgamal.AmountRange{Low: 0, High: 1_000},
})
- Reads the holder
MPTokenand theMPTokenIssuancefrom one validated ledger, pinned by hash after the first read, so the supply that bounds the search can never predate the balance it must cover. - Returns
ErrMPTokenNotFoundwhen the holder holds noMPTokenfor the issuance. - Returns
0when theMPTokenexists but carries no spending ciphertext, which is a holder that has never converted: the firstConfidentialMPTConvertwrites an encrypted zero spending balance alongside the inbox credit. The issuance is not read in that case, and no cryptographic work is done. - Excludes
ConfidentialBalanceInbox. An inbox is not spendable until aConfidentialMPTMergeInboxmoves it, so counting it would report a balance the holder cannot send or convert back. - Bounds the search by
BalanceRange, capped at the issuanceConfidentialOutstandingAmount, exactly asBuildClawbackdoes. If omitted,BalanceRangeis[0, 0], so callers must set a range that contains any nonzero balance. The SDK does not default the range to the issuance's whole confidential supply, because that range can be as large as the issuance.
Like every other decryption in this package, it needs a CGo-enabled build. The zero-balance case above is the one answer it can give without one.
Ordered batches
BuildBatch assembles several confidential operations into one XLS-56 Batch that the
ledger applies in order. It exists because calling the standalone builders in a row cannot
produce one: each of them reads the ledger, and inside a Batch the ledger does not yet show
what an earlier inner leaves behind, so every proof after the first would bind a balance and
a version the transaction will no longer find when it applies.
batch, err := builder.BuildBatch(client, builder.BuildBatchParams{
Account: senderAddress,
Operations: []builder.BatchOperation{
builder.SendOp{BuildSendParams: builder.BuildSendParams{
Account: senderAddress,
Destination: receiverAddress,
IssuanceID: issuanceID,
Amount: 30,
SenderPrivKey: senderKey.PrivKeyHex,
SenderPubKey: senderKey.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: 0, High: 1_000},
}},
builder.MergeInboxOp{BuildMergeInboxParams: builder.BuildMergeInboxParams{
Account: receiverAddress,
IssuanceID: issuanceID,
}},
},
})
Each of the five confidential operations wraps the parameters of the standalone builder it
mirrors, so an inner reads the same as the call it replaces: ConvertOp, ConvertBackOp,
SendOp, MergeInboxOp, and ClawbackOp. TransactionOp carries a ready-made ordinary
transaction, which the assembler only shapes as an inner. Each operation can be passed as a
value or as a non-nil pointer.
The assembler owns five things:
- Up-front validation. Every operation's inputs are checked before the first ledger query,
by the same validator and with the same sentinels as the standalone builder it mirrors,
including the
TxOptionsrules. An invalid later operation costs no ledger read and no proof for the operations before it. - One validated ledger. Every
MPTokenandMPTokenIssuancetheBatchtouches is read from a single snapshot, pinned by hash after the first read, so no inner's proof mixes state from two ledgers. - Predicted state. A map keyed by the decoded holder
AccountIDand the issuance ID carries the spending and inbox ciphertexts, the issuer and auditor mirror balances, the holder keys, the balance versions, and the public amounts. After each inner it is advanced by exactly what the transactor does, including the re-randomization the network applies to a send's credited ciphertexts and the canonical encrypted zero it writes when it resets a balance: a merge resets the inbox, a holder's first convert starts its spending balance at zero, and a clawback resets every balance of its holder.elgamal.EncryptCanonicalZeroderives that ciphertext from the key, the holder account, and the issuance the same way the network does, so a later inner can spend from a reset balance within the sameBatch. As in the standalone builders, an open-ledger version change rejects the build withErrStaleBalanceVersiononly for a holder whose version a send or convert-back proof binds. - Final nonces. Each inner's
Sequence, or theTicketSequenceit spends instead, is resolved before any proof is generated, because a confidential context hash commits to the nonce and no later autofill can repair a proof. An account's inners take consecutive sequences; the outerBatchaccount's start one past the sequence theBatchitself spends, or at its current sequence when theBatchspends aTicket. ATicketCreateinner moves its account's later sequences past everyTicketit creates. - Inner shape. Every inner carries
tfInnerBatchTxn, a zeroFee, an emptySigningPubKey, and no signature of its own.
Fee and LastLedgerSequence are left unset, so the returned Batch goes through the
client's own autofill, which prices a Batch by summing its inners and charges each
confidential inner the multiplier the network applies. Autofill cannot disturb a proof: every
nonce the proofs bind is already set, and autofill assigns only nonces that are missing.
Signing stays with the caller — each participating account signs with
wallet.SignMultiBatch, several signatures are merged with wallet.CombineBatchSigners, and
the outer account signs the Batch itself:
flat := batch.Flatten()
if err := client.AutofillMultisigned(&flat, 1); err != nil {
return err
}
if err := wallet.SignMultiBatch(receiverWallet, &flat, nil); err != nil {
return err
}
_, err = client.SubmitTxAndWait(flat, &types.SubmitOptions{Wallet: &senderWallet})
Batch limits
The assembler refuses to emit a proof it can already tell the network will reject. Each refusal has its own sentinel:
ErrBatchOperationCount: aBatchholds between two and eight inners. The check runs before any ledger read, so an impossible size costs nothing.ErrBatchModeNotSupported: onlytfAllOrNothing, the default, is supported. Under any other mode an inner can be skipped or fail while later inners still apply, and every prediction after it would describe a ledger that never happened.ErrBatchMissingOperation: an operation is nil, as an interface or as a pointer, or aTransactionOpcarries no transaction.ErrBatchInnerNotSupported: aTransactionOpof a type the assembler does not accept.IsSupportedInnerTransactionTypereports the allowlist:AccountSet,SetRegularKey,SignerListSet,TicketCreate,TrustSet,DepositPreauth,DelegateSet,CredentialCreate,CredentialAccept, andCredentialDelete. Anything that could change a confidential balance, anMPToken's existence or authorization, or an issuance is kept out, because the assembler would have to predict its effect to keep the later proofs valid:MPTokenAuthorizecreates and deletes theMPTokenthe predictions are keyed by,MPTokenIssuanceSetcan lock an issuance or change its keys, andPaymentandClawbackcan move the public MPT a convert is funded from. Submit those before or after theBatch.ErrBatchInnerSequenceSet: a confidential operation set its ownSequence. The assembler derives every inner sequence from the operation's position, so a caller-set one describes an order it cannot honor. ATicketSequenceis accepted, and the proof binds it in place of the sequence.ErrBatchInnerSequenceMismatch: aTransactionOpcarries aSequencethat is not the one its position in theBatchrequires for its account, such as the outerBatch's own sequence, one past an allocated inner, or one aTicketCreateearlier in theBatchturned into aTicket. This holds for every account, so a caller-set sequence of an account other than the outer one is checked against that account's current sequence.ErrConflictingNonce: an inner, confidential or ready-made, sets bothSequenceandTicketSequence. The network requires exactly one.ErrBatchDuplicateNonce: two inners of one account spend the same sequence orTicket, or an inner spends theTicketthe outerBatchitself spends. The network rejects an all-or-nothingBatchthat repeats a nonce.
Everything the standalone builders reject, a Batch inner rejects too, with the same
sentinel: the issuance capability checks, the locked and authorized preflights, the key
mismatches, and the balance bounds. The bounds are checked against the running state rather
than the pre-batch ledger, so a convert earlier in the same Batch funds a later convert-back
and widens the decryption bound a later spend searches under.
Build* vs Prepare*
Choose Build* when you have access to a live ledger connection and want the SDK to resolve:
- account sequence numbers;
- issuer and auditor encryption keys;
- holder
MPTokenfields such asHolderEncryptionKey,ConfidentialBalanceSpending,IssuerEncryptedBalance, andConfidentialBalanceVersion.
Choose Prepare* when you already have those values and want deterministic, offline transaction assembly.
Each proof commits to the nonce the transaction spends, so a Prepare* helper that emits a proof
rejects options carrying neither Sequence nor TicketSequence with ErrMissingSequence rather
than produce a proof a later autofill would invalidate. The two proof-free forms are exempt:
PrepareMergeInbox, and PrepareConvert for a holder whose encryption key is already registered.
Both accept a zero nonce and can be autofilled.
Build* also preflights what the network enforces, so a transaction it would reject never costs
a fee and a sequence: the issuance capabilities, and the ledger state each transactor requires
of the accounts it touches.
Some conditions the network enforces are left to it. A destination that requires a destination
tag (tecDST_TAG_NEEDED), a destination behind deposit authorization (tecNO_PERMISSION), and
an issuance that authorizes through a permissioned domain all depend on account state or
credentials the builder does not read. Preflight covers the issuance capabilities and the
confidential state each transactor requires, not the destination's own access policy.
Transaction options
Every Build*Params embeds TxOptions, which carries the fields that are about the transaction
rather than the confidential operation: which nonce authorizes it, and who submits it.
type TxOptions struct {
Sequence uint32
TicketSequence uint32
Delegate string
}
Set either Sequence or TicketSequence, never both: XRPL requires Sequence to be 0 whenever a
transaction spends a Ticket, so setting both returns ErrConflictingNonce.
Every helper accepts a TicketSequence. xrpld hashes the sequence proxy into every confidential
context hash, and the sequence proxy is the ticket whenever the transaction spends one, so a proof
built here commits to the ticket sequence rather than to an account sequence. That covers a
first-time PrepareConvert, PrepareClawback, PrepareSend, and PrepareConvertBack.
There is one case a Ticket cannot rescue, and no builder can detect it, so it is documented rather
than refused. ConfidentialMPTSend and ConfidentialMPTConvertBack commit their proofs to the
submitter's own ConfidentialBalanceVersion, which a send, a convert-back, a merge-inbox, or a
clawback against that holder bumps. Of several such transactions built against a single reading of
one MPToken, the first to land bumps the version and the rest fail with tecBAD_PROOF, each
paying a fee and destroying its Ticket. Submit them one at a time on that issuance and wait for
validation.
The collision is per MPToken, because the version lives on the (issuance, holder) entry, so
plenty still runs in parallel on Tickets:
- Clawbacks against different holders.
PrepareClawbackbinds the target holder'sIssuerEncryptedBalancerather than any state of the submitting issuer, so they bind disjoint entries. Two against the same holder are redundant, because a clawback removes that holder's balance in full. - Converts. A convert credits the inbox rather than the spending balance, so it never bumps the version, and a repeat convert carries no proof at all.
- Merges and sends across different issuances, which never touch the same
MPToken.
A merge carries no proof, so nothing of its own can go stale, but it bumps the version regardless. A ticketed merge can therefore land out of order and invalidate a pending send or convert-back on the same issuance, so land a merge and wait for validation before preparing either.
A Build* helper reads the account sequence only when both are zero, so supplying either one keeps
the build off the account query entirely:
tx, err := builder.BuildMergeInbox(client, builder.BuildMergeInboxParams{
TxOptions: builder.TxOptions{
TicketSequence: ticketSequence,
Delegate: delegateAddress,
},
Account: holderAddress,
IssuanceID: issuanceID,
})
The Ticket is spent from the transaction Account's account root, so it must be one that account
created with TicketCreate. Only the Account's sequence is consumed, never the Delegate's, so
a Ticket the delegate owns is rejected on-ledger with tefNO_TICKET.
Delegate names the account submitting on the transaction account's behalf, per XLS-75. It is
rejected when it is not a valid address, decodes to ACCOUNT_ZERO, carries an X-address tag, or
names the transaction account itself, and the sentinels are the ones BaseTx.Validate reports.
ConfidentialMPTConvert is marked non-delegable by xrpld, so BuildConvert and PrepareConvert
reject any delegate with ErrDelegateNotAllowed.
Each Prepare*Params reads the options from its embedded Build*Params, so a composite literal
sets them there:
params := builder.MergeInboxParams{
BuildMergeInboxParams: builder.BuildMergeInboxParams{
TxOptions: builder.TxOptions{Sequence: sequence},
Account: holderAddress,
IssuanceID: issuanceID,
},
}
The promoted selectors, such as params.Sequence and params.Delegate, stay available after
construction and reach that same value.
Typical flow
- Enable confidential transfers on the issuance with
MPTokenIssuanceCreateorMPTokenIssuanceSet, then registerIssuerEncryptionKeyand optionallyAuditorEncryptionKeywith anMPTokenIssuanceSet. Only the set transaction carries the keys, so an issuance created with the capability still needs a second transaction to become usable. - Generate a holder keypair with
confidential/elgamal.GenerateKeypair(). - Opt the holder in with
BuildConvertorPrepareConvert, optionally withAmount: 0for key registration only. - Use
BuildSendfor confidential transfers between opted-in holders. - Use
BuildMergeInboxafter receiving confidential transfers, if the holder wants to spend the received balance. - Use
BuildConvertBackto move confidential balance back into public MPT balance.
Signing and submission
Builders return concrete transaction structs from xrpl/transaction, so the rest of the flow is the same as other XRPL transactions: autofill any remaining fields if needed, sign with a wallet, then submit through RPC or WebSocket.
tx, err := builder.BuildSend(client, params)
if err != nil {
return err
}
signed, err := wallet.Sign(tx)
if err != nil {
return err
}
_, err = client.SubmitTx(signed, nil)
return err
Address forms
Every address field accepts either a classic address or an X-address. The builder resolves
both to the same account, so Account given as rHb9… and as its X-address form name the
same account for the self-send and self-clawback checks. Addresses are normalized to their
classic spelling before they reach the ledger queries and keylet computation, and the proof
layer binds the decoded account ID, so the address form never changes a proof.
A tagged X-address is accepted only where the transaction has a companion tag field:
AccounthasSourceTag, so a tagged X-address is allowed.DestinationhasDestinationTag, so a tagged X-address is allowed unless you also setBuildSendParams.DestinationTag, which would name the tag twice.Holderhas no tag field, becauseConfidentialMPTClawbackdefines none, so a tagged X-address is rejected.
ACCOUNT_ZERO is rejected in every address field. It decodes cleanly in either form, but no
keypair can produce it, so it can never sign a transaction nor hold an MPToken.
Common failure cases
Most builder errors are explicit and map to missing ledger state or invalid inputs:
ErrEncryptionKeyNotSet: the issuance does not yet have the issuer encryption key configured.ErrReceiverNotOptedIn: the destination holder is not initialized to receive. It has noMPToken, no registeredHolderEncryptionKey, noConfidentialBalanceInbox, or is missing a mirror balance the issuance implies.ErrMPTokenNotFound: the account does not yet have the expectedMPTokenledger entry.ErrMissingSenderState: anMPTokenexists but lacks confidential state the transaction needs, such as a spending balance, a mirror balance, or the holder encryption key. A clawback reports a holder that never opted in this way too.ErrIssuanceNotFound: theMPTokenIssuanceledger entry does not exist.ErrInsufficientBalance: the requested confidential send or convert-back amount exceeds the decrypted balance, or a convert amount exceeds the holder's publicMPTAmount.ErrMissingSequence: a proof-bearingPrepare*helper was given neither aSequencenor aTicketSequence.ErrConflictingNonce: bothSequenceandTicketSequencewere set.ErrDelegateNotAllowed: aDelegatewas set on a typeNonDelegatableTransactionsMaplists, which among the confidential types isConfidentialMPTConvert.ErrKeyMismatch: the supplied public key differs from the one registered on the ledger.ErrInvalidCredentialIDs: a nonemptyBuildSendParams.CredentialIDslist must contain 1 to 8 distinct, nonzero, 256-bit hexadecimal IDs. Hex letter case does not affect uniqueness. It wrapstransaction.ErrInvalidCredentialIDs, soerrors.Ismatches either sentinel.ErrStaleBalanceVersion: a confidential transaction of the holder's own is still in flight and has already movedConfidentialBalanceVersion, so a proof built against the validated ledger would be rejected. Rebuild once it validates.ErrInvalidLedgerState: a ledger response was missing, malformed, or did not come from the validated ledger the build selected.ErrInvalidTransaction: the assembled transaction failed its ownValidate().elgamal.ErrInvalidAmountRange:BalanceRangeis inverted, its upper bound ismath.MaxUint64, or itsLowis above the issuanceConfidentialOutstandingAmountthatBuildClawbackandGetSpendingBalancecap the search at, which puts every possible balance outside the range.ErrCryptoFailed: a cryptographic primitive failed, or the current balance falls outsideBalanceRange.
Address fields report the field that failed and wrap the reason:
ErrInvalidAccount,ErrInvalidDestination,ErrInvalidHolder: the address is neither a classic address nor an X-address, or it decodes to ACCOUNT_ZERO. Matchtransaction.ErrZeroAccountIDwitherrors.Isto tell the two apart.ErrInvalidHolderwrappingtransaction.ErrAccountIDTagNotAllowed: a tagged X-address was used inHolder, which has no companion tag field.ErrInvalidDestinationwrappingtransaction.ErrDuplicateXAddressTag:Destinationis a tagged X-address andDestinationTagis also set.ErrInvalidAddress: an address failed to decode inside the MPToken keylet helper, which servesAccount,Destination, andHolderalike and so names no field. The builders validate their address fields first, so this reports against the field only in code that calls the helper directly.
The issuance capability checks mirror the conditions the network enforces:
ErrConfidentialDisabled: the issuance does not havelsfMPTCanHoldConfidentialBalanceset.ErrTransferDisabled: a confidential send needslsfMPTCanTransfer, which the issuance does not have.ErrTransferFeeSet: the issuance charges a transfer fee, which confidential sends forbid.ErrClawbackDisabled: a clawback needslsfMPTCanClawback, which the issuance does not have.ErrIssuanceLocked: the issuance haslsfMPTLocked, so every balance of it is locked.ErrHolderLocked: the holder'sMPTokenhaslsfMPTLocked. A clawback is exempt, because an issuer must be able to claw back from a holder it has locked. A send checks both participants and prefixes the error withsenderordestinationto name the side that blocked it.ErrHolderNotAuthorized: the issuance haslsfMPTRequireAuthand the holder'sMPTokenlackslsfMPTAuthorized. A send names the participant the same wayErrHolderLockeddoes. An issuance that authorizes through a permissioned domain is left to the network, because the credentials that path accepts are not read here.ErrAmountExceedsOutstanding: a convert-backAmountexceeds the issuanceConfidentialOutstandingAmount.