Turns a wallet's outputs plus a ring.json (from decoy_tool) into a
signed Monero transaction, offline.
This has been tested on stagenet and produced correct, working
transactions accepted by the network, more than once. It has also gone
through several rounds of independent code review comparing it against
monero-project/monero, function by function. Every real issue those
reviews found has since been fixed and re-verified by actually building
the code against the real Monero source tree and running it, not just
reading it. The full list is below under "Review Notes."
This is a solo-built tool. I'm not claiming a formal security audit, and
I'd still rather have more eyes on it than fewer. If you know this
codebase and want to check my work against wallet2.cpp and
cryptonote_tx_utils.cpp yourself, that's welcome and genuinely useful.
If you find something and want to send a PR, I'll take a look.
This same note applies across the other tools in this toolchain
(decoy_tool, lora_bridge_app).
export_outputs opens your wallet file (using the real, unmodified
wallet2::load) and dumps its known outputs to outputs.json.
select_transfers reads that file and picks which outputs to spend,
using a direct port of wallet2::select_transfers and
pop_best_value_from, the same output-relatedness scoring the official
wallet uses to avoid linking unrelated coins together. assembler then
takes the selected outputs, a ring.json (built separately by
decoy_tool, which can serve rings for multiple sources at once), and
signs the actual transaction by calling Monero's own
construct_tx_and_get_tx_key() directly. No part of the signing math is
reimplemented. CLSAG signing, Bulletproofs, key image generation, all of
it runs through the real, unmodified library code.
| Tool | Can Run Without Wallet File? | Can Run Completely Offline? |
|---|---|---|
| export_outputs | ❌ No | ✅ Yes |
| select_transfers | ✅ Yes | ✅ Yes |
| assembler | ❌ No | ✅ Yes |
| decoy_tool | ✅ Yes | ❌ No (Requires Network) |
Usage assumption, stated plainly: this toolchain is built around a
wallet that has been fully synced and then not touched again before you
run export_outputs — no new incoming or outgoing transactions between
the sync and the run. If the wallet's state has moved on since the last
sync (new transfers landed, older outputs got spent elsewhere), the
snapshot this tool works from is stale, and results are undefined. This
isn't a corner case to code around later; it's the intended operating
model for an offline-signing setup like this one, the same way a
watch-only wallet needs a reasonably fresh view of the chain to be
useful. Keep the offline wallet's sync current relative to when you
intend to use it.
These are things independent review found. Most are fixed now; I'm keeping the record here rather than deleting it, since knowing what was wrong and got caught is more useful than a clean slate would be.
-
Fixed: fee estimation is now measured, not guessed.
assemblerused to estimate transaction weight with a fixed formula (BASE_WEIGHT + PER_EXTRA_INPUT_WEIGHT * extra_inputs) and computed the fee from that guess. It now builds the transaction once, measures its real weight withcryptonote::get_transaction_weight(), recomputes the fee from that real number, and rebuilds the transaction if the fee changed, the same build then measure then correct approachwallet2itself uses. -
Fixed:
export_outputsno longer fails silently. If a transaction's tx public key can't be resolved from either the main key or theadditional_tx_pub_keyslist, it used to fall through quietly and write a key that turns out to be wrong. It now re-checks the resolved key against the actual output before accepting it, and if it still doesn't match, it prints a clear error naming the transaction and output index and skips just that one output rather than either guessing or aborting the whole export. -
Fixed:
select_transfersuses a real random source for tie-breaking. It used to usestd::mt19937_64seeded fromstd::random_deviceto break ties among equally-good candidate outputs. That choice never affected ring anonymity (it only decides which of your own eligible outputs gets spent when more than one is an equally good pick), but it was a real, avoidable deviation from Monero's owncrypto::rand_idx, a proper CSPRNG built on the same Keccak-based generatordecoy_toolalready uses. It's been switched over. -
Fixed: ring members are checked against the main elliptic curve subgroup before signing. Upstream
wallet2::tx_add_fake_output()checks every decoy's public key and commitment withrct::isInMainSubgroup()before accepting it, a defense against a node handing back invalid or small-subgroup curve points. This is now checked here, inassembler, right before signing, covering every ring member regardless of whichdecoy_toolrun it came from. Tested directly: known small-order (torsion) points and the identity point are correctly rejected, a valid point (the curve's base point) is correctly accepted. -
Fixed:
assemblerwasn't passing a change address into the real signing call.construct_tx_and_get_tx_key()was being called withboost::nonefor the change address instead of your own address, even though the change output was right there in the destination list. This sounds cosmetic but isn't: without it,wallet2's own logic can't tell the change output apart from a second real destination, which throws off two things it does automatically. The dummy encrypted payment ID that a normal two-output transaction gets doesn't get added, and when the actual destination is a subaddress (the common case these days), the single-destination-subaddress optimization doesn't trigger, so the transaction ends up carrying an extraadditional_tx_pub_keysfield it wouldn't otherwise need. Both are visible intx_extra, meaning transactions built this way looked structurally different from normal wallet2 output in a way that could stand out. Confirmed with a direct before/after test: same transaction,tx_extrawent from 99 bytes (missing the dummy payment ID, carrying the extra field) to 44 bytes (matching what a real change-awarewallet2call produces) once the change address was passed through correctly. -
Open, low priority:
export_outputsre-derives each output's tx public key at export time instead of using the cached indexwallet2itself records at scan time (m_pk_index). In practice this means it tries the primary key, falls back to the additional-keys list, and re-verifies the result against the actual output before accepting it (see the fix above on failing loudly rather than silently). That verification step is exactly what keeps this from being a real risk: if the re-derivation ever landed on the wrong key, the check would catch it and skip that output rather than sign something wrong. Worth matchingwallet2's own cached-index approach eventually for correctness's sake, but it isn't an open safety gap as it stands. -
Open, low priority:
select_transfersdoesn't have unlock-status information available to it, becauseexport_outputsdoesn't currently include it inoutputs.json. So it can't proactively avoid picking an output that's technically still locked (recent coinbase, output not past its spendable age). Two things already bound the practical risk:decoy_toolchecks the real output's unlock status and warns if it isn't unlocked yet, and if a genuinely locked output did make it all the way to a signed transaction, the network's own consensus rules would reject it outright, not silently misbehave. Under the usage assumption above (a wallet synced up to the point you're using it, with no activity since), this basically doesn't come up in practice, since outputs old enough to appear in a stable, unmoving wallet snapshot are already well past typical unlock windows.
None of the above, fixed or still open, was ever a "your funds are at risk while running this" bug as far as I've verified, and every fix listed here was checked by actually building it and running it, not just by reading the diff. But "as far as I've verified" is exactly why outside review is worth asking for, and why this list stays here instead of getting quietly cleaned up.
sudo pacman -S --needed base-devel boost libsodium unbound openssl lmdb rapidjson
git clone --depth 1 https://github.com/monero-project/monero.git monero-src
sudo pacman -S nlohmann-json
env MONERO_SRC=./monero-src bash build.shsudo apt-get install build-essential libboost-all-dev libsodium-dev unbound-dev libssl-dev liblmdb-dev rapidjson-dev nlohmann-json3-dev
git clone --depth 1 https://github.com/monero-project/monero.git monero-src
env MONERO_SRC=./monero-src bash build.shsudo dnf install @development-tools boost-devel libsodium-devel unbound-devel openssl-devel lmdb-devel rapidjson-devel nlohmann-json-devel
git clone --depth 1 https://github.com/monero-project/monero.git monero-src
env MONERO_SRC=./monero-src bash build.shsudo zypper install -t pattern devel_basis boost-devel libsodium-devel unbound-devel openssl-devel lmdb-devel rapidjson-devel nlohmann-json-devel
git clone --depth 1 https://github.com/monero-project/monero.git monero-src
env MONERO_SRC=./monero-src bash build.shNote
export_outputs and select_transfers must be built separately.
mkdir -p obj_export
cp obj/*.o obj_export/
rm obj_export/assembler.o
g++ -std=c++17 -I./monero-src/src -I./monero-src/contrib/epee/include -I./monero-src/external/easylogging++ -I./monero-src/external/db_drivers/liblmdb -I./monero-src/external -c export_outputs.cpp -o obj_export/export_outputs.o
g++ obj_export/*.o -o export_outputs -lboost_filesystem -lboost_thread -lboost_regex -lboost_chrono -lboost_serialization -lboost_program_options -lboost_locale -lsodium -lunbound -lssl -lcrypto -lpthread -llmdbselect_transfers uses Monero's real crypto::rand_idx for tie-breaking
now, which pulls in the same Keccak-based RNG decoy_tool uses
(rng.h, random.c, random.h, keccak.c, keccak.h, hash.c,
hash-ops.h, int-util.h, initializer.h, warnings.h, already sitting
in this repo). Still no boost, no wallet2, no monero-src needed for this
one:
gcc -O2 -c random.c -o random.o
gcc -O2 -c keccak.c -o keccak.o
gcc -O2 -c hash.c -o hash.o
g++ -std=c++17 select_transfers.cpp random.o keccak.o hash.o -o select_transfers -lpthreadFirst, run export_outputs
./export_outputs <wallet_file> <password> outputs.jsonThen, run select_transfers
./select_transfers <outputs.json> <amount> > selected.jsonFinally, run assembler
./assembler <wallet_file> <password> <ring.json> <selected.json> <amount> <destination_address>Note
For export_outputs and select_transfers to work correctly, and for
the signed transaction to be accepted by the network, you need to have
transferred the wallet files over to the offline side as of the date of
your last transaction. Otherwise, once any transaction happens in your
wallet, the tool won't be able to correctly identify which outputs were
spent or received, which can cause double-spend errors and get the
transaction rejected by the network. See "Usage assumption" above.
Warning
The wallet password is currently passed as a plain command-line
argument to export_outputs and assembler. On Linux this means it's
briefly readable by other processes running as the same user (via
/proc/<pid>/cmdline) while the tool runs. This is a known weakness,
not yet fixed.
Everything fixed above was found by asking someone else to actually check this against the real Monero source instead of taking my word for it, and every fix was verified the same way: built against the real source tree, linked, and run, not just read and assumed correct. If you can do either of those, even for one function, that's more useful to this project than any amount of me testing it myself. PRs welcome for either of the two open items above.