Amount recovery¶
Once a wallet confirms that an output is addressed to one of its sub-addresses (see detection), it must recover the amount. The commitment \(C = v_{\text{amt}} \cdot G + \gamma \cdot H\) hides the amount from any observer without the blinding factor \(\gamma\), but the amount is not encrypted as a separate ciphertext on the wire. It is embedded inside the Bulletproofs+ range proof itself, and recovered by inverting two linear equations that the prover set up during proof generation.
The recovery routine lives in RangeProofLogic<T>::RecoverAmounts (src/blsct/range_proof/bulletproofs_plus/range_proof_logic.cpp:RecoverAmounts) and MsgAmtCipher<T>::Decrypt.
Nonce¶
The nonce used by recovery is the shared Diffie–Hellman point \(\eta \in G_1\):
The sender has \(r\) and \(V_{a,i}\); the receiver has \(v\) and \(R\). Both compute the same \(\eta\). In code, CalculateNonce(blindingKey, viewKey) returns blindingKey * viewKey as a MclG1Point (src/blsct/wallet/helpers.cpp). All subsequent "sub-nonces" are derived deterministically via \eta.\text{GetHashWithSalt}(\text{salt}):
| Salt | Role in the range proof |
|---|---|
| 1 | Proof blinding scalar \(\text{nonce}_1\) added into \(\alpha\). |
| 2 | Coefficient \(\tau_1\) of the linear term of the polynomial commitment. |
| 3 | Coefficient \(\tau_2\) of the quadratic term of the polynomial commitment. |
| 100 | Amount commitment blinding \(\gamma\) for the output's single value \(v_0\). |
What the prover embeds¶
During Prove the sender packs:
-
A 64-bit user message
msg1and the 64-bit amountvs[0]into a single scalar:\[ \text{msg1\_vs0} \;=\; (\text{msg1} \ll 64) \;\vert\; \text{vs}[0] \quad (\bmod\, r) \] -
Sets \(\alpha = \text{nonce}_1 + \text{msg1\_vs0}\), producing the proof's \(\alpha_{\text{hat}}\) after the outer-round weighted sum:
\[ \alpha_{\text{hat}} \;=\; \text{nonce}_1 \;+\; \text{msg1\_vs0} \;+\; \Bigl(z^{(2)}_{\text{asc}} \cdot \gamma\Bigr) \cdot y^{mn+1} \]where \(z^{(2)}_{\text{asc}}\) and \(y^{mn+1}\) come from
ComputePowers(y, z, m, n)— the deterministic Fiat–Shamir challenges of the proof. -
Packs a second 64-bit user message
msg2(the memo) into \(\tau_x\):\[ \tau_x \;=\; \tau_2 \cdot y^2 \;+\; (\tau_1 + \text{msg2}) \cdot y \;+\; z^2 \cdot \gamma. \]
Recovery by the receiver¶
Given the on-chain \(R, B\) (or just \(R\) and \(v\)), the stored \(\alpha_{\text{hat}}, \tau_x\) (from the range proof), and the commitment \(\text{Vs}[0] = C\), the receiver computes:
η = v · R (G1 point)
γ = η.GetHashWithSalt(100) (scalar)
nonce_1 = η.GetHashWithSalt(1)
τ_1 = η.GetHashWithSalt(2)
τ_2 = η.GetHashWithSalt(3)
# Re-derive Fiat–Shamir challenges y, z from the proof transcript.
(z_asc_pows, y_mn_plus_1) = ComputePowers(y, z, m=1, n=64)
α_hat_sum = (z_asc_pows · γ) · y_mn_plus_1
# Recover packed msg1 || vs[0]
msg1_vs0 = α_hat - nonce_1 - α_hat_sum # scalar subtraction in F_r
vs[0] = msg1_vs0 & (2^64 - 1) # low 64 bits = amount
msg1 = msg1_vs0 >> 64 # high 64 bits = user msg1
# Recover msg2 from τ_x
msg2 = (τ_x - τ_2 · y² - z² · γ) · y⁻¹ - τ_1
(see range_proof_logic.cpp:RecoverAmounts lines 577–635 and MsgAmtCipher<T>::Decrypt.)
The wallet then verifies the commitment re-opens correctly:
If the check fails, the output is not this wallet's (the view tag was a 1-in-65 536 false positive, or the sender constructed a malformed output). The wallet discards and moves on.
Memo recovery¶
As shown above, the memo is the 32-byte big-endian encoding of the recovered msg2 scalar (msg2_scalar.GetVch(true)). There is no separate memo ciphertext on the wire and no stream-cipher keystream — the memo rides in \(\tau_x\) additively alongside the amount in \(\alpha_{\text{hat}}\). Memos are therefore bounded by one scalar's worth of information (~256 bits ≈ 32 bytes).
RPC for third-party recovery¶
A trusted auditor, or a recipient who lost access to their wallet but has the transaction's output hash and a nonce, can use:
getblsctrecoverydata— derive the recovery data for outputs in a transaction given wallet access.getblsctrecoverydatawithnonce— given a specific shared nonce (e.g., recovered from a sender), decrypt just those outputs.deriveblsctnonce— compute the shared nonce from a destination address and the sender's blinding key.
These commands are how watch-only and audit workflows reconstruct amounts without the spend key.
SDK¶
// High-level: sync already recovers amounts into WalletOutput.amount
const utxos = await client.getUnspentOutputs();
for (const utxo of utxos) {
console.log(utxo.amount, 'sats'); // already recovered
}
// Low-level: recover manually from a given blinding key + output
const km = client.getKeyManager();
const nonce = km.calculateNonce(output.blindingKey);
// use navio-blsct primitives to decrypt the amount
See navio-blsct AmountRecoveryReq/Res for the library primitives.
Edge cases¶
- Change outputs recover identically — the sender addresses change to a sub-address in their own pool
-1. - Audit recovery — a wallet restored from audit key can recover every amount because \(v\) determines \(\sigma\).
- Corrupted outputs — if the commitment does not match the decrypted amount, wallet treats the output as not addressed to it.
- Zero-amount outputs — valid (e.g.
OP_RETURNoutputs and some coinstake markers havevs[0] = 0). The commitment is \(0 \cdot G + \gamma \cdot H = \gamma \cdot H\), i.e. a point with a hidden discrete log under the shared randomness base.