LessonsExtensions: Built inLesson 64

Keys and coins with CoinXT

How Bitcoin and Ethereum keep coins safe, with CoinXT: hashes, private and public keys, addresses and their checksums, recovery words and wallets that grow from them, signatures, and transactions.

  • Extensions
  • Lesson 64 of 83
  • About 15 minutes
  • 10 examples

Coins are keys

Nobody holds a bitcoin the way you hold a coin. What you hold is a private key, 32 secret bytes, and whoever has the key can spend what it controls. CoinXT is the xTalk Suite's layer over trezor-crypto and libsecp256k1, the code that hardware wallets and Bitcoin itself run: it makes keys, addresses, recovery words and signatures for Bitcoin and Ethereum. Its handlers begin with cx.

Hashes

Coins run on hashes (fingerprints), 32 bytes each. cxSha256 is the hash Bitcoin uses, cxHash256 runs it twice, which is how Bitcoin names blocks and transactions, and cxKeccak256 is Ethereum's. cxHexEncode spells bytes as hex:

ExampleRun it in the playground
put textEncode("Hello, Bitcoin", "UTF-8") into tData
put cxHexEncode(cxSha256(tData))
put cxHexEncode(cxHash256(tData))
put cxHexEncode(cxKeccak256(tData))
put the number of bytes in cxSha256(empty)
Output
d8125d715a1f87012093af8dfd389e1d96d9c68ab58bf024a7a8d9e73056724b
644055e4a5a523bccf09f7295e56e8d4aa60dfbb8eb028a1cd6c8b805df3e907
1d6bbf77c5ed6566afd8bc4c95de519455d0623c00525b3bfd7d8d341558e9f8
32

Private and public keys

A private key is 32 bytes, almost any 32 bytes; cxSeckeyIsValid checks. From it, cxPublicKey makes the public key, which is safe to share and from which nobody can work back to the private one: 33 bytes in its short (compressed) form, 65 in its long form, which Ethereum uses:

ExampleRun it in the playground
-- a key made from a phrase: for learning only
put cxSha256(textEncode("my first key", "UTF-8")) into tKey
put cxSeckeyIsValid(tKey)
put cxPublicKey(tKey, true) into tPublic
put the number of bytes in tPublic
put cxHexEncode(tPublic)
put the number of bytes in cxPublicKey(tKey, false)
Output
true
33
03e47416d316a13dbbdde7a3060f9438c9c3028c256af835194bf29acd91866165
65

Addresses

An address is where people send you coins, made from your public key. One Bitcoin key has several kinds, and the first letters say which: cxBtcAddressP2PKH gives the oldest kind, starting with 1; cxBtcAddressP2WPKH gives SegWit, starting with bc1q, cheaper to spend from; and cxBtcAddressP2TRFromInternal gives Taproot, starting with bc1p, from the 32-byte "x-only" key of cxXOnlyPubkey. An Ethereum address is the end of the Keccak hash of the long public key, after 0x:

ExampleRun it in the playground
put cxSha256(textEncode("my first key", "UTF-8")) into tKey
put cxPublicKey(tKey, true) into tPublic
put cxBtcAddressP2PKH(tPublic)
put cxBtcAddressP2WPKH(tPublic)
put cxBtcAddressP2TRFromInternal(cxXOnlyPubkey(tKey), empty)
put cxEthAddress(cxPublicKey(tKey, false))
Output
18GvyZLsvxUY7WgPANwnNzqxkvDy9DNeQa
bc1qflxz6y7ej9ccyn87pxtn3yv4ylvcrycxghpq2u
bc1pnc3xf8pvf9kgaslxwrer4ady8d9hq5z2gkl2cxrdvmfanl803uzqcg72du
0x740c064c79e895ef9617cca9cf7079f2bb1e7526

Catching typos

A mistyped address sends coins nowhere, for ever, so each kind carries a checksum: letters worked out from the rest, which a typo makes disagree. Ethereum hides its checksum in the capital letters: cxEthAddressChecksum writes them, and cxEthAddressIsChecksummed checks them. cxWifEncode writes a private key as WIF text, which wallets import, and cxBase58CheckDecode stops with an error when a letter of such text is wrong:

ExampleRun it in the playground
put cxSha256(textEncode("my first key", "UTF-8")) into tKey
put cxEthAddressChecksum(cxEthAddress(cxPublicKey(tKey, false))) into tAddress
put tAddress
put cxEthAddressIsChecksummed(tAddress)
put "c" into char 6 of tAddress
put cxEthAddressIsChecksummed(tAddress)

put cxWifEncode(cxHexEncode(tKey), "mainnet", true) into tWif
put cxWifDecode(tWif) into tDecoded
put tDecoded["seckey"] is cxHexEncode(tKey)
put "X" into char 10 of tWif
try
   get cxBase58CheckDecode(tWif)
   put "a typo got through!"
catch tError
   put "a typo: refused"
end try
Output
0x740C064c79E895eF9617CCa9cF7079F2BB1E7526
true
false
true
a typo: refused

A SegWit address checks its network as well: cxSegwitAddressDecode takes one apart for the network you name, "bc" for real Bitcoin or "tb" for the test network, and refuses the other, so a test wallet cannot send real coins by mistake:

ExampleRun it in the playground
put "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" into tAddress
put cxSegwitAddressDecode("bc", tAddress) into tParts
put "version" && tParts["version"] & "," && the number of bytes in tParts["program"] && "bytes"
put cxSegwitAddressEncode("bc", 0, tParts["program"]) is tAddress
try
   get cxSegwitAddressDecode("tb", tAddress)
catch tError
   put "the wrong network: refused"
end try
Output
version 0, 20 bytes
true
the wrong network: refused

Twelve words

A wallet is backed up as a recovery phrase: twelve or twenty-four ordinary words, written on paper, that rebuild it on any device. cxMnemonicFromEntropy turns 16 random bytes into 12 words from a list of 2048, and cxMnemonicValidate checks them: the last word holds a checksum, so a wrong word is caught. Sixteen zero bytes are a famous test value:

ExampleRun it in the playground
put cxMnemonicFromEntropy(cxHexDecode("00000000000000000000000000000000")) into tWords
put the number of words of tWords
put tWords
put cxMnemonicValidate(tWords)
put "zoo" into word -1 of tWords
put cxMnemonicValidate(tWords)
Output
12
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
true
false

One phrase grows a whole tree of keys. cxMnemonicToSeed turns the words (and an optional passphrase) into a 64-byte seed, cxHdFromSeed makes the root of the tree, and cxHdDerivePath walks a path such as m/84'/0'/0'/0/0 to one key. The last number counts up, a new address each time, and the same words always grow the same tree, which is how a new wallet finds every old address:

ExampleRun it in the playground
put "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" into tWords
put cxMnemonicToSeed(tWords, empty) into tSeed
put the number of bytes in tSeed
put cxHdFromSeed(tSeed) into tRoot
repeat with tIndex = 0 to 2
   put cxHdDerivePath(tRoot, "m/84'/0'/0'/0/" & tIndex) into tNode
   put tIndex & ":" && cxBtcAddressP2WPKH(tNode["pubkey"])
end repeat
Output
64
0: bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu
1: bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g
2: bc1qp59yckz4ae5c4efgw2s5wfyvrz0ala7rgvuz8z

Signing

To spend, you sign: you prove you hold the private key without showing it. A signature covers exactly 32 bytes, so you sign the hash of the message. cxSign signs, and cxVerify checks with the public key: anyone can check, nobody else can sign. Ethereum's cxSignRecoverable adds one byte from which cxRecover finds the signer's public key, so a message need not say who sent it; and Taproot's Schnorr signatures (cxSchnorrSign, cxSchnorrVerify) use the 32-byte x-only key:

ExampleRun it in the playground
put cxSha256(textEncode("my first key", "UTF-8")) into tKey
put cxPublicKey(tKey, true) into tPublic
put cxSha256(textEncode("Pay Bob 5", "UTF-8")) into tDigest
put cxSign(tKey, tDigest) into tSignature
put the number of bytes in tSignature
put cxVerify(tPublic, tDigest, tSignature)
put cxVerify(tPublic, cxSha256(textEncode("Pay Bob 500", "UTF-8")), tSignature)

put cxSignRecoverable(tKey, tDigest) into tSignature
put cxEthAddress(cxRecover(tSignature, tDigest)) is cxEthAddress(cxPublicKey(tKey, false))

put cxSchnorrSign(tKey, tDigest, empty) into tSignature
put cxSchnorrVerify(cxXOnlyPubkey(tKey), tDigest, tSignature)
Output
64
true
false
true
true

Always compare a recovered key with the one you expected: any 65 bytes recover some key.

Transactions

A Bitcoin transaction spends earlier outputs and makes new ones. An input points at an earlier output, by its transaction's id and its number (cxBtcOutpoint); an output is an amount in satoshi, a hundred-millionth of a bitcoin, and the script that locks it (cxBtcOutput). cxBtcTxid gives the new transaction's id, the hash that names it on every block explorer:

ExampleRun it in the playground
put "4ce18f49ba153a51bcda9bb80d7f978e3de6e81b5fc326f00465d4a9b0d32a2b" into tPrevious
put cxHexEncode(cxBtcOutpoint(tPrevious, 0)) into tInput
put cxHexEncode(cxBtcOutput(50000, "0014751e76e8199196d454941c45d1b3a323f1433bd6")) into tOutput
put the length of tInput / 2 && "bytes in"
put the length of tOutput / 2 && "bytes out"
-- version 2, the input's sequence, then the output, and lock time 0
put cxBtcTxid(2, tInput, empty, 4294967295, tOutput, 0)
Output
36 bytes in
31 bytes out
c73feaa3700e7091e724775123c4955d710a10319bbbc900553f7e576bad9dd3

CoinXT also builds and signs Ethereum payments (cxEth1559Sighash, cxEth1559Encode); the Dictionary has every handler, and Learn CoinXT (Help > Examples) walks through them.

Try it yourself

Write a function looksLikeBitcoinAddress that says whether text is a valid SegWit address on the real Bitcoin network, without stopping with an error for text that is not.

Show a solution
ExampleRun it in the playground
function looksLikeBitcoinAddress pText
   try
      get cxSegwitAddressDecode("bc", pText)
      return true
   catch tError
      return false
   end try
end looksLikeBitcoinAddress

put looksLikeBitcoinAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
put looksLikeBitcoinAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5")
put looksLikeBitcoinAddress("hello")
Output
true
false
false

Words in this lesson

Each one finds every lesson that uses it. The Dictionary has the whole story of each, with its syntax and more examples.