While there are tools to make offline transactions on Ethereum, I’ve failed to find any comprehensive solution for Cardano. This post contains the instructions on creating an offline Cardano wallet and signing Cardano transactions offline with the help of command-line tools.

⬅ Note the interactive table of contents on the left.

📟 Hardware vs offline wallets

First, a few notes on hardware wallets.

An obvious alternative to storing private keys to your wallet on an online PC is to use a hardware wallet like Trezor or Ledger. Even though they technically are connected to the online PC when making transactions, they still serve as a kind-of-offline storage for cryptocurrencies.

However, despite being marketed as near-impenetrable and thus being surprisingly popular, they do have issues. Both generic and Cardano-specific.

🌩 Glitching

As has been shown many times over the past years, hardware wallets can be glitched, and all private keys can be extracted. Dmitry Nedospasov even has a training on glitching the Trezor One wallet.

Attacking a hardware wallet via glitching does require physical access to the wallet. So if you don’t worry about misplacing yours, this issue doesn’t affect you. However, should it happen your wallet gets lost or stolen, a knowledgeable attacker will get access to your funds.

Arguably, you could use a hardware wallet for making a single transaction when required, and then wipe the device. However, this requires putting trust into the wiping procedure. As an amusing alternative, you could dispose of the wallet after use. Altough then you’ll need a get a new one every time.

Being vulnerabile to glitching is a generic issue hardware wallets have regardless of the used cryptocurrency.

However, when it comes to Cardano, there’s another problem that makes hardware wallets less desirable.

🗝 Cardano key derivation

Unfortunately, both Trezor (for 24-word seeds) and Ledger have botched their key derivation schemes for Cardano. Thus, wallet addresses generated by these hardware wallets are different from ones generated by software wallets like Daedalus, Yoroi, or AdaLite, which follow the Icarus scheme.

You can still generate a Cardano wallet address on a hardware wallet, but then you’ll be stuck with using that particular wallet brand. That is unless you implement a matching derivation scheme yourself, either in another hardware wallet or in userspace software.

🔌 Offline wallet

Both issues aren’t terrible, but their existence makes me like the idea of using an offline wallet. That is to store the private keys on a PC that’s not connected to any network, and sign transactions there.

In the case of using an offline PC instead of a hardware wallet, an attacker with physical access will need to break the disk encryption to recover the keys. This shifts the trust requirement from the faulty “secure” hardware chips to the encryption software that you use.

As for the issue with key derivation, as long as you use the software that follows the Icarus scheme like the official Cardano command-line tools, you’ll be fine.

🏗 Generating a wallet address

Let’s start with generating a Cardano wallet address on an offline PC. Here, I assume that you already have a BIP-39 seed phrase and would like to use it to generate the address.

The instructions are based on the cardano-address documentation and a related tutorial.

Note. A single typo while following these instructions can render your funds unrecoverable. Also, the instructions can be just wrong, in case I have messed something up. Verify and test everything yourself.

🍱 Requirements

Offline PC. Used to generate a Cardano wallet address from the seed phrase. Needs:

  • Seed phrase in seed.txt,
  • cardano-address installed: download online and transfer to the offline PC.

To make the snippets more compact, I assume:

export CA=./cardano-address

📋 Process

All of the instructions are executed on the offline PC.

⚫ On offline PC

Have your seed phrase ready:

$ cat seed.txt
tiger pelican pencil label adult figure grid goddess proof visual order worry fox ordinary define flash dust impulse broken crew morning ketchup grab valve
1. Generate the root private key root.xsk:
$ cat seed.txt | $CA key from-recovery-phrase Shelley > root.xsk
$ cat root.xsk
root_xsk1aqhnhnxh00vqzqdhlqnf6dvrwry3zckcfr5ff49ec3xj7n8fg4xxz9vv4qnva94klx0f6lpxk6s59qlystls3l4td2axfxdrhee5cpnt6r02wp3sl6zewmw3gagfsldhqfwqyvff0n97kytrlwqtnal6psh48k34

Check out CIP 5 if you’re wondering about the xsk file extension.

2. Construct the extended private key addr.xsk:
$ cat root.xsk | $CA key child 1852H/1815H/0H/0/0 > addr.xsk
$ cat addr.xsk
addr_xsk1zr9df87ljsw3gnycnxwkddk7h2yt25s7fw4r4t64zn527h4vgew66rvaurcrlfv8hhr2gkmehqen0he6m7qtn28yynx3jmjgctns7a9flpl5pm6qfalgfwhhzduvxjx3ktemdpy8z2ll5uqre843rvn8dcjcyk3l

This is the private key that controlls the wallet that corresponds to the full 1852H/1815H/0H/0/0 BIP-44 derivation path.

3. Generate the payment address payment.addr:
$ cat addr.xsk | $CA key public --with-chain-code | $CA address payment --network-tag mainnet > payment.addr
$ cat payment.addr
addr1vxptzvmwgpt4lwghez5qnp6xv2wfxrsy72mzj0vzy74fnagx8lkc9

That’s basically it, the payment address is the wallet address.

However, if you put the same seed phase into AdaLite or Yoroi, you’ll notice that the wallet address generated by them is different. The reason is that besides the payment part, these wallets also include the delegation part into the address. This second part allows staking your ADA.

Let’s extend the wallet address with the delegation part to make sure it matches the address generated by the mentioned wallets.

4. Generate a stake verification key stake.xvk:
$ cat root.xsk | $CA key child 1852H/1815H/0H/2/0 | $CA key public --with-chain-code > stake.xvk
$ cat stake.xvk
stake_xvk1u6t762v0fmmct8en9sl9lg4mc46nk0wd0zqvrqmhltcyp9ygpwp0ekd4uj28pletqag56lna5z9j9756mclv0hrf0ya085pr78fexeq2cm5ff
5. Generate a delegated payment address payment-delegated.addr:
$ cat payment.addr | $CA address delegation $(cat stake.xvk) > payment-delegated.addr
$ cat payment-delegated.addr
addr1qxptzvmwgpt4lwghez5qnp6xv2wfxrsy72mzj0vzy74fnavjwf5glr8e3qzldsgy330rpsd6gu79ufuxrdnwwwxx5wnqq7s6yp

This is the wallet address extended with the stake verification key. Note how its first part is similar to the wallet address without the delegation part. Note how the prefix is different.

This address matches the one generated by AdaLite and Yoroi. Note that it doesn’t match the address generated by Trezor nor by Ledger, as explained above.

I’ll be referring to the generated wallet address as to wallet.addr for the rest of the instructions. Use payment-delegated.addr as wallet.addr if you’re planning to stake your ADA.

Now, you can copy the wallet address to an online PC, transfer funds to this address, and you’ll have an offline cold storage.

✉ Making a transaction

Let’s assume you have transferred some funds to the wallet. Now, how do you access them without exposing the seed phrase or the private keys to an online PC?

Let’s see with an example. I deposited 10 ADA to the wallet address wallet.addr generated above. Now, let’s transfer 3 ADA to another address dest.addr.

🥘 Requirements

Online PC. Used to generate and submit the transaction. Needs:

  • Offline-generated wallet address wallet.addr,
  • Destination wallet address dest.addr,
  • Synced and running cardano-node: follow the instructions; syncing takes hours.

Once the syncing is completed, get a shell in the container running cardano-node:

docker run -it --entrypoint bash -e NETWORK=mainnet \
           -e CARDANO_NODE_SOCKET_PATH=/ipc/node.socket \
           -v cardano-node-ipc:/ipc -v cardano-node-data:/data \
           inputoutput/cardano-node

Then:

export CLI=cardano-cli

Offline PC. Used to sign the transaction. Needs:

  • Extended private key addr.xsk that corresponds to wallet.addr,
  • cardano-cli installed: download online and transfer to the offline PC.

And:

export CLI=./cardano-cli

📋 Process

The instructions roughly follow the Create Simple Transaction tutorial but split into the online and the offline parts.

🟢 On online PC

1. Fetch the protocol parameters protocol.json:
$ $CLI query protocol-parameters --mainnet --out-file protocol.json
$ cat protocol.json
{
    "maxValueSize": 5000,
    "minUTxOValue": null,
    ...
}
2. Find out the hash and the index of the UTxO to spend:
$ $CLI query utxo --mainnet --address addr1qxptzvmwgpt4lwghez5qnp6xv2wfxrsy72mzj0vzy74fnavjwf5glr8e3qzldsgy330rpsd6gu79ufuxrdnwwwxx5wnqq7s6yp
                           TxHash                                 TxIx        Amount
--------------------------------------------------------------------------------------
fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696     1        10000000 lovelace + TxOutDatumNone

This UTxO corresponds to the 10 ADA I deposited into the wallet.

3. Generate a draft transaction tx.draft:
$ $CLI transaction build-raw \
        --tx-in fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696#1 \
        --tx-out $(cat dest.addr)+0 \
        --tx-out $(cat wallet.addr)+0 \
        --fee 0 --invalid-hereafter 0 --out-file tx.draft
$ cat tx.draft
{
    "type": "TxBodyAlonzo",
    "description": "",
    "cborHex": "86a60081825820fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696010d800182825839019b83cb4a967fa114c59e1630930574134c16361c6249567ef6b649079272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a6008258390182b1336e40575fb917c8a8098746629c930e04f2b6293d8227aa99f59272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a600020003000e809fff8080f5f6"
}

The transaction is intended to spend the single UTxO corresponding to wallet.addr, transfer a part of it to dest.addr, and return the rest back to wallet.addr.

« For --tx-in we use the following syntax: TxHash#TxIx where TxHash is the transaction hash and TxIx is the index; for --tx-out we use: TxOut+Lovelace where TxOut is the hex encoded address followed by the amount in Lovelace. For the transaction draft --tx-out, --invalid-hereafter and --fee can be set to zero.
Create Simple Transaction
4. Calculate the transaction fee:
$ $CLI transaction calculate-min-fee \
                 --tx-body-file tx.draft \
                 --tx-in-count 1 --tx-out-count 2 \
                 --witness-count 1 --byron-witness-count 0 \
                 --mainnet --protocol-params-file protocol.json
176589 Lovelace
5. Calculate the change to send back to wallet.addr:
$ expr 10000000 - 3000000 - 176589
6823411

10000000 is the 10 ADA UTxO, 3000000 is the 3 ADA to be sent to dest.addr, and 176589 is the previously calculated fee. The leftover 6823411 is to be sent back to wallet.addr.

6. Determine the TTL.

TTL stands for Time to Live — the slot number deadline for the transaction to be picked up by one of the validators.

« To build the transaction we need to specify the TTL (Time to live), this is the slot height limit for our transaction to be included in a block, if it is not in a block by that slot the transaction will be cancelled. So TTL = slot + N slots. Where N is the amount of slots you want to add to give the transaction a window to be included in a block.
Create Simple Transaction
$ $CLI query tip --mainnet
{
    "era": "Alonzo",
    "syncProgress": "100.00",
    "hash": "4bfb85613286ec08015230c5c0a76e640ec37d45392269c35ca63ce8c0d4f548",
    "epoch": 326,
    "slot": 55735121,
    "block": 6997593
}

Here you see the number for the last processed slot, which is 55735121. You can try to monitor the rate with which this number increases and estimate the desirable TTL. I added 2000 to the current slot number.

7. Build the transaction tx.raw:
$ $CLI transaction build-raw \
        --tx-in fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696#1 \
        --tx-out $(cat dest.addr)+3000000 \
        --tx-out $(cat wallet.addr)+6823411 \
        --fee 176589 --invalid-hereafter 55737121 --out-file tx.raw
$ cat tx.raw
{
    "type": "TxBodyAlonzo",
    "description": "",
    "cborHex": "86a60081825820fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696010d800182825839019b83cb4a967fa114c59e1630930574134c16361c6249567ef6b649079272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a61a002dc6c08258390182b1336e40575fb917c8a8098746629c930e04f2b6293d8227aa99f59272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a61a00681df3021a0002b1cd031a03527b210e809fff8080f5f6"
}
8. Transfer tx.raw to the offline PC.

⚫ On offline PC

9. Generate the extended signing key addr.skey:
$ $CLI key convert-cardano-address-key --shelley-payment-key \
           --signing-key-file addr.xsk --out-file addr.skey
$ cat addr.skey
cat addr.skey
{
    "type": "PaymentExtendedSigningKeyShelley_ed25519_bip32",
    "description": "",
    "cborHex": "5880e0a9c15070be2bf51396a3cf6c54245061c60f0dd3c8b124e603cbbd56e9454c447e0d41ea7f8e3075e38b34ad10748302d652b03a5041411716401b20f3285187be7788076481f7e300b5d241f8c3c3cb05a6cf2e4cce9d646097c4e5f585798e81ab68651851a4e4de1cc65bbc72b17d288c948d042e8c1b3de7ef617f9081"
}

For whatever reason, cardano-cli doesn’t accept the extended private key addr.xsk for signing transactions and requires the extended signing key addr.skey.

10. Sign the transaction to obtain tx.signed:
$ $CLI transaction sign --mainnet --signing-key-file addr.skey \
                 --tx-body-file tx.raw --out-file tx.signed
$ cat tx.signed
{
    "type": "Tx AlonzoEra",
    "description": "",
    "cborHex": "84a60081825820fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696010d800182825839019b83cb4a967fa114c59e1630930574134c16361c6249567ef6b649079272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a61a002dc6c08258390182b1336e40575fb917c8a8098746629c930e04f2b6293d8227aa99f59272688f8cf98805f6c1048c5e30c1ba473c5e27861b66e738c6a3a61a00681df3021a0002b1cd031a03527b210e80a1008182582087be7788076481f7e300b5d241f8c3c3cb05a6cf2e4cce9d646097c4e5f585795840c35066edb16261ab50bfa8449d86f9ae1b17950bc62c14f800abb2ef380fccb8cf6a18ccb7d34cfd1ac95ccefa4cd50259ee798ea8945b46f3440bf8dad92b00f5f6"
}
11. Transfer tx.signed to the online PC.

🟢 On online PC

12. Inspect the transaction and verify the parameters:
$ $CLI transaction view --tx-file tx.signed
...
fee: 176589 Lovelace
inputs:
- fb6bcbeaa3fb9c978a0e9b4ab3f9e1d9ab0a5b9b1184e523e5d5f2a7ccfd0696#1
...
outputs:
- address: addr1qxdc8j62jel6z9x9nctrpyc9wsf5c93kr33yj4n776myjpujwf5glr8e3qzldsgy330rpsd6gu79ufuxrdnwwwxx5wnqcnyalv
  address era: Shelley
  amount:
    lovelace: 3000000
...
- address: addr1qxptzvmwgpt4lwghez5qnp6xv2wfxrsy72mzj0vzy74fnavjwf5glr8e3qzldsgy330rpsd6gu79ufuxrdnwwwxx5wnqq7s6yp
  address era: Shelley
  amount:
    lovelace: 6823411
...
13. Submit the transaction:
$ $CLI transaction submit --mainnet --tx-file tx.signed
Transaction successfully submitted.

That’s it! The transaction is submitted. Now you need to wait until one of the validators picks it up. For me, this took about 5 minutes. Once that happened, 3 ADA appeared in the dest.addr wallet.

While you are waiting, monitor the slot numbers either on Cardano Explorer or via $CLI query tip --mainnet. If the slot numbers go higher than the number you specified in --invalid-hereafter — transaction hasn’t been picked up. Try again, possibly, with a larger TTL.

Tip. If you get an error during the transaction submission, try googling the error code. Possibly, you didn’t fully spend all UTxOs. Also, if a UTxO you want to spend contains a token, you have to spend the token too. Meaning that you have to specify this token in one of the transaction outputs.

You can also delegate your ADA from an offline PC. Maybe I’ll extend the instructions in the future.

🛠 Exercise

I have left a few ADA in a wallet derived from the seed phrase used in this article. Feel free to claim this ADA as an exercise. Hint: try playing around with BIP-44 address indexes. Have fun!

Update. The ADA has been claimed!

💜 Thank you for reading!

🐱 About me

I’m a security researcher and a software engineer focusing on the Linux kernel.

I contributed to several security-related Linux kernel subsystems and tools, including KASAN — a fast dynamic bug detector, syzkaller — a production-grade kernel fuzzer, and Arm Memory Tagging Extension — an exploit mitigation. I also wrote a few Linux kernel exploits for the bugs I found.

Occasionally, I’m having fun with hardware hacking, teaching, and other random stuff.

Follow me @andreyknvl on X, @xairy@infosec.exchange on Mastodon, or @xairy on LinkedIn for notifications about new articles, talks, and training sessions.