Skip to content

Repository files navigation

export_outputs / select_transfers / assembler

Turns a wallet's outputs plus a ring.json (from decoy_tool) into a signed Monero transaction, offline.

Status

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


What this does

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.

Review Notes

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. assembler used 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 with cryptonote::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 approach wallet2 itself uses.

  • Fixed: export_outputs no longer fails silently. If a transaction's tx public key can't be resolved from either the main key or the additional_tx_pub_keys list, 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_transfers uses a real random source for tie-breaking. It used to use std::mt19937_64 seeded from std::random_device to 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 own crypto::rand_idx, a proper CSPRNG built on the same Keccak-based generator decoy_tool already 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 with rct::isInMainSubgroup() before accepting it, a defense against a node handing back invalid or small-subgroup curve points. This is now checked here, in assembler, right before signing, covering every ring member regardless of which decoy_tool run 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: assembler wasn't passing a change address into the real signing call. construct_tx_and_get_tx_key() was being called with boost::none for 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 extra additional_tx_pub_keys field it wouldn't otherwise need. Both are visible in tx_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_extra went from 99 bytes (missing the dummy payment ID, carrying the extra field) to 44 bytes (matching what a real change-aware wallet2 call produces) once the change address was passed through correctly.

  • Open, low priority: export_outputs re-derives each output's tx public key at export time instead of using the cached index wallet2 itself 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 matching wallet2'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_transfers doesn't have unlock-status information available to it, because export_outputs doesn't currently include it in outputs.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_tool checks 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.

Building

Arch Linux

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

Ubuntu/Debian

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

Fedora

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

openSUSE

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

Note

export_outputs and select_transfers must be built separately.

export_outputs

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

select_transfers

select_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 -lpthread

Usage

First, run export_outputs

./export_outputs <wallet_file> <password> outputs.json

Then, run select_transfers

./select_transfers <outputs.json> <amount> > selected.json

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

Feedback wanted

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.

About

Offline Monero transaction signing toolchain - builds and signs a tx entirely offline using wallet2's own signing code, no view-only wallet needed

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages