Using Ethereum wallets on TON šŸ’Ž
October 28th, 2024

Or How to handle ECDSA signatures in TON

TLDR

This post explains how to sign and verify data and transactions in TON using ECDSA (though TON primarily uses EDDSA). It assumes familiarity with EVM and basic TON concepts)

Keep in mind, this is my very first post. Cheers.


Hey, it's @swift1337! I do protocol @ ZetaChain 🐲

Being a protocol engineer is fun. It involves solving specific, niche challenges every day. At ZetaChain we're are working hard to bring omnichain capabilities to The Open Network (TON) šŸ’Ž

During this journey, one of the interesting technical challenges was signing TON transactions using ZetaChain's Threshold Signature Schemes (TSS) protocol, which relies on cryptography that differs from what is used in TON. The main use case is to bridge and withdraw locked TON during cross-chain transactions, with support of calling arbitrary smart contracts on connected chains.

In this post, I want to elaborate on how we solved ECDSA on TON and enabled support for Multi-Party Computation (MPC).

Other use cases may include using Metamask for signing ton transactions, as well as cross-chain governance.

All of the sources are available in our repo:

On Curves and Signatures

Facts
Facts

Bitcoin, Ethereum, and many other blockchains use the Elliptic Curve Digital Signature Algorithm (ECDSA) and specifically the secp256k1 curve to sign and verify data. TON, on the other hand, uses the Edwards-curve Digital Signature Algorithm (EdDSA) with  ed25519 curve.

What is a cryptographic signature?

Basically, it's a mathematical way of proving that a message came from a specific signer.

For example, we can make a signature of "Alice says Hello, Bob!". Technically, it would work like this:

Signer: Alice (0x0fA03A88042647025f8524b12899c6A3f1781A2e)
Input: "Hello, Bob!"

Signature:
0x07965b9c69767e2b45fe086aebc4fc103baf26f27f6176e9eb6a3458743ceef877d4e04987619321c7bcf5a2311d25fa61e48ac68c388b4d45bf42f098de831c1b

Magic šŸ§™šŸ»ā€ā™‚ļø This is literally the backbone of any blockchain

The big brains of cryptography (not meme coins) 🧠 realized that it's inconvenient to operate with raw messages of arbitrary size (imagine signing a large book). To keep signatures consistent, we sign the hash of the message (usually SHA-256) instead of the entire message.

A process of getting a public key from a signature is called signature recovery. Technically, it looks like this:

// pseudo code
alicePubKey = ecdsa_recover(sha256("Hello, Bob!"), signature)

alice = pubKeyToAddress(alicePubKey) // 0x0fA03A88042647025f85....

Regarding the structure of this signature, it has 65 bytes:

  • 32 bytes for R: One of the elliptic curve point coordinates, linking the message to the signature.

  • 32 bytes for S: Derived from the message, private key, and r value. Both r and s are needed to verify the signature.

  • 1 byte for V: A recovery identifier (0 or 1) used to determine the correct public key for signature verification.

If you want to have an overview the topic, I suggest these introductory sources:

TON of Quirks and Superiority

TON is far superior to ETH in technical terms (link). Because async and sharding are badass words for a reason! Just acknowledge it, comrade. /s

Very Interesting, but a regular wallet is also a smart contract. Due to this, even a simple coin transfer is actually two transactions ("Alice sends a message with coins to Bob" + "Bob receives a message with coins from Alice").

avg TON wallet transaction
avg TON wallet transaction

When Alice sends 1 TON to Bob, Alice creates and signs a message off-chain and then broadcasts it to Alice's smart contract on TON. As of Q4 2024, the latest wallet implementation is WalletV5. It uses the following message structure for handling an external message (recv_external):

// pseudocode
cell msg_body: $someData + $signature

Okay, forget pseudo code, we're not pseudo nerds! Warm up your fingers, let's see some superiority in practice:

How Wallet V5 checks EDDSA signatures

() process_signed_request(slice in_msg_body, int is_external) impure inline {
  slice signature = in_msg_body.get_last_bits(size::signature);
  slice signed_slice = in_msg_body.remove_last_bits(size::signature);

  ;; ...
  
  slice data_slice = get_data().begin_parse();
  
  ;; The wallet fetches Alice's public key from its state
  ;; It's stored when Alice deploys her wallet
  ;; This is usually done automatically by most wallets (e.g. TonKeeper)
  ;; during the first transaction
  int public_key = data_slice~load_uint(size::public_key);

  ;; ...

  int is_signature_valid = check_signature(slice_hash(signed_slice), signature, public_key);

  ;; further wallet's logic ......
}

As you can see, the logic is pretty straightforward:

  • Split the message into signed_slice (payload) and signature

  • Hash the payload: slice_hash(signed_slice)

  • Take the owner's public key from the contract's state

  • Call check_signature(hash, signature, public_key)

And check_signature is actually an alias from stdlib.fc:

;; EDDSA signature check
int check_signature(int hash, slice signature, int public_key) asm "CHKSIGNU";

You can read more about TVM instructions here

The Key Point

To implement a contract or a wallet in TON that can send assets and invoke operations based on an "EVM signer", we need to have a way to recover an ECDSA signature. Actually, there is a dedicated TVM instruction for that! Meet ECRECOVER:

Recovers public key from signature, identical to Bitcoin/Ethereum operations. Takes 32-byte hash as uint256 hash; 65-byte signature as uint8 v and uint256 rs. Returns 0 on failure, public key and -1 on success. The 65-byte public key is returned as uint8 h, uint256 x1x2.

Show Me the Code

Here's the flow that we'll use:

  1. Encode TON external message as bytes

  2. Sign it using ECDSA

  3. Prepend/Append it to the message

  4. Check the signature using ECRECOVER

  5. If the signature matches the expected sender, invoke the operation (e.g., send TON coins)

Let's say we want to implement a custody contract that allows us to send TON based on an ECDSA signature signed from Metamask. Then we can use the following external message body:

cell
  > signature: 65 bytes -> slice of 520 bits
  > cell_ref:
    > cell: arbitrary payload

If you think ā€œwtf is a cell?!ā€, you can read this.

As an example, I'll use ZetaChain's cross-chain Gateway implementation.

cell auth::ecdsa::external(slice message, slice expected_evm_address) inline {
  ;; 1. Get signature
  slice signature = message~load_bits(size::signature_size);

  ;; 2. Get payload cell
  throw_if(error::no_signed_payload, message.slice_refs_empty?());
  cell payload = message~load_ref();

  ;; 3. Calculate payload hash
  int payload_hash = cell_hash(payload);

  ;; 4. Check signature
  int sig_check = check_ecdsa_signature(payload_hash, signature, expected_evm_address);

  if (sig_check != true) {
      ~strdump("check_ecdsa_signature");
      sig_check~dump();
      throw(error::invalid_signature);
  }

  return payload;
}

crypto.fc

;; Returns keccak256 hash of the data as uint256
int hash_keccak256(builder b) asm "1 INT HASHEXT_KECCAK256";

;; ECRECOVER FunC wrapper
(int, int, int, int) ecdsa_recover(int hash, int v, int r, int s) asm "ECRECOVER";

;; TVM uses `v` ONLY as `0` or `1`. In ETH/BTC, a prefix is used. See RFC6979
;; See https://bitcoin.stackexchange.com/questions/38351/ecdsa-v-r-s-what-is-v
int normalize_ecdsa_recovery_id(int v) inline {
  ;; "compressed recovery_id for pub key"
  if v >= 31 {
      return v - 31;
  }

  ;; "uncompressed recovery_id for pub key"
  if v >= 27 {
      return v - 27;
  }

  return v;
}

;; Checks ECDSA signature. Returns int as an outcome:
;; 1: unable to recover public key
;; 2: recovered key is not uncompressed
;; 3: recovered address does not match the expected address
;; -1 (true): signature is valid
(int) check_ecdsa_signature(int hash, slice signature, slice expected_evm_address) impure inline_ref {
  ;; 1 Parse (v, r, s)
  int v = signature~load_uint(8).normalize_ecdsa_recovery_id();
  int r = signature~load_uint(256);
  int s = signature~load_uint(256);

  ;; 2. Recover public key
  (int h, int x1, int x2, int flag) = ecdsa_recover(hash, v, r, s);
  if flag != true {
      return 1;
  }

  ;; Deny compressed public keys (0x04 prefix means uncompressed)
  if h != 4 {
      return 2;
  }

  ;; 3. Derive 20 bytes evm address from the public key
  int pub_key_hash = begin_cell()
      .store_uint(x1, 256)
      .store_uint(x2, 256)
      .hash_keccak256();

  slice actual_evm_address = begin_cell()
      .store_uint(pub_key_hash, 256)
      .end_cell()
      .begin_parse()
      .slice_last(20 * 8);

  ;; 4. Compare with the expected address
  if equal_slices(expected_evm_address, actual_evm_address) == false {
      return 3;
  }

  return true;
}

And that’s a wrap. Congrats for reading this. ECDSA on TON — because why settle for one chain when you can bridge them all?

Subscribe to dmitrybase
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from dmitrybase

Skeleton

Skeleton

Skeleton