Insufficient balance when pushing a tx signed by a canister - ETH

What type of topic is this?
Support

Hello everyone,

I’m working on a larger project and need to get this piece working. Specifically, I’m retrieving the public key from the canister, deriving the corresponding Ethereum address, and funding that address so I can convert its balance into ckEth via the handler contract. However, whenever I broadcast the ETH transaction, I keep getting an “insufficient balance” error.

Here are the canisters methods
Github: andredevjs/poc-cketh/blob/main/src/eth-bridge-poc-backend/utils/ECDSA.mo

 public func getPublicKey(caller: Principal) : async { #Ok : { public_key_hex: Text }; #Err : Text } {
    try {
      let { public_key } = await ic.ecdsa_public_key({
          canister_id = null;
          derivation_path = [ Principal.toBlob(caller) ];
          key_id = { curve = #secp256k1; name = "dfx_test_key" };
      });
      #Ok({ public_key_hex = Hex.encode(Blob.toArray(public_key)) })
    } catch (err) {
      #Err(Error.message(err))
    }
  };

  public func sign(caller: Principal, message: Text) : async { #Ok : { signature_hex: Text }; #Err : Text } {
    try {
      let message_hash: Blob = Blob.fromArray(SHA256.sha256(Blob.toArray(Text.encodeUtf8(message))));
      Cycles.add<system>(30_000_000_000);
      let { signature } = await ic.sign_with_ecdsa({
          message_hash;
          derivation_path = [ Principal.toBlob(caller) ];
          key_id = { curve = #secp256k1; name = "dfx_test_key" };
      });
      #Ok({ signature_hex = Hex.encode(Blob.toArray(signature))})
    } catch (err) {
      #Err(Error.message(err))
    }
  };```

And the JS here
andredevjs/poc-cketh/blob/main/src/eth-bridge-poc-frontend/src/App.jsx

const principal = principalToBytes32(OwnerPrincipal);
const subaccount = principalToBytes32(DevJourneyPrincipal);
const amount = ethers.parseEther("0.01"); 

console.log("🔑 Derived address:", signerAddress);

// 2) Fetch balance, fee data, and network in parallel
const [balance, feeData, network] = await Promise.all([
  provider.getBalance(signerAddress),
  provider.getFeeData(),
  provider.getNetwork()
]);

console.log("🌐 Network:", network.name, "(chainId:", network.chainId + ")");
console.log("💰 Balance:", ethers.formatEther(balance), "ETH");

const tx = await contract.depositEth.populateTransaction(
  principal,
  subaccount,
  {
    value: amount,
    gasLimit: 23600,
    gasPrice: feeData.gasPrice,
  },
);

const unsignedSerialized = serialize(tx);
const unsignedBytes = ethers.getBytes(unsignedSerialized);
const digest = keccak256(unsignedBytes);

const result = await eth_bridge_poc_backend.sign(digest);

const signatureHex ='0x' + result.Ok.signature_hex;
const signature = ethers.Signature.from(signatureHex);

const txHex = serialize({...tx}, signature);
console.log(ethers.Transaction.from(txHex).from);

const txResponse = await provider.broadcastTransaction(txHex);
console.log('Transaction Hash:', txResponse);

// Wait for the transaction to be mined
const receipt = await txResponse.wait();
console.log('Transaction mined in block:', receipt.blockNumber);

But I always get this error:

ethers.js?v=a7670b8a:325 Uncaught (in promise) Error: insufficient funds for intrinsic transaction cost (transaction="0xf8b0808501ee0a87c2825c30942d39863d30716aaf2b7fffd85dd03dda2bfc2e38872386f26fc10000b84417c819c41dde12780352f89e9505135cd6167b5ee8776ad3c8fb1bd384c8b00a150200001da527d220b995574d0f5e8f31ea96fda034f292dc652d9d4a94a4bd130200001ba0e6df99a65bc47303170f9e40fec1bec34f6f31decd092edca65aca86a4f73f57a03731a0989e9cb38a0e0cd0c3d0b0d2e587093


The generated address is fine, because I double check that I get the same value in both JS and Rust, so it's gotta be something with the signature. 

Any ideas what could I be doing wrong?

This might not be an issue with the derived address, but with the signature input or gas settings.

A few things to check:

  1. Message Hash:
    Make sure the digest you send to the canister is a SHA256 hash, since sign_with_ecdsa expects that. If you’re sending a Keccak256 hash, it could fail.
  2. Gas Limit:
    Try increasing gasLimit to 50,000 or more — the depositEth call may use more gas than estimated.
  3. Nonce:
    Use provider.getTransactionCount(signerAddress) to confirm the nonce is correct.

Thanks for your reply, but I tried signing with the sha256 hash and didn’t work either.
Looking at the actual ICP sample it does seem that they sign using the keccak

Thanks for your answer. I also tried using SHA-256, but it didn’t help. According to the official docs, they use a Keccak-256 digest when signing transactions:
/docs/building-apps/chain-fusion/ethereum/using-eth/signing-transactions

packages/ic-evm-utils/src/evm_signer.rs
pub async fn sign_eip1559_transaction(
    tx: Eip1559TransactionRequest,
    key_id: EcdsaKeyId,
    derivation_path: Vec<Vec<u8>>,
) -> SignedTransaction {
    const EIP1559_TX_ID: u8 = 2;

    let ecdsa_pub_key =
        get_canister_public_key(key_id.clone(), None, derivation_path.clone()).await;

    let mut unsigned_tx_bytes = tx.rlp().to_vec();
    unsigned_tx_bytes.insert(0, EIP1559_TX_ID);

    let txhash = keccak256(&unsigned_tx_bytes);

    let signature = sign_with_ecdsa(SignWithEcdsaArgument {
        message_hash: txhash.to_vec(),
        derivation_path,
        key_id,
    })
    .await
    .expect("failed to sign the transaction")
    .0
    .signature;

    let signature = Signature {
        v: y_parity(&txhash, &signature, &ecdsa_pub_key),
        r: U256::from_big_endian(&signature[0..32]),
        s: U256::from_big_endian(&signature[32..64]),
    };

    let mut signed_tx_bytes = tx.rlp_signed(&signature).to_vec();
    signed_tx_bytes.insert(0, EIP1559_TX_ID);

    SignedTransaction {
        tx_hex: format!("0x{}", hex::encode(&signed_tx_bytes)),
        tx_hash: format!("0x{}", hex::encode(keccak256(&signed_tx_bytes))),
    }
}

Despite this, every time I recover the public key or address I get a different result. To eliminate one variable, I’ve updated my Motoko canister to use a fixed principal—though I don’t think that’s the root cause. Here’s the revised canister code:

// Github /dfinity/examples/blob/master/motoko/threshold-ecdsa/src/ecdsa_example_motoko/main.mo

import Text       "mo:base/Text";
import Principal  "mo:base/Principal";
import Blob "mo:base/Blob";
import Error "mo:base/Error";
import Hex "./utils/Hex";
import SHA256 "./utils/SHA256";
import Cycles "mo:base/ExperimentalCycles";
actor {
  // For testing, let's use a fixed principal.
  private let caller : Principal = Principal.fromText("udsqg-qo6cj-4agux-yt2kq-ke242-ylhwx-xio5v-nhsh3-dpjyj-sfqbi-kqe");

  type IC = actor {
    ecdsa_public_key : ({
      canister_id      : ?Principal;
      derivation_path : [Blob];
      key_id           : { curve: { #secp256k1 }; name: Text };
    }) -> async ({ public_key : Blob; chain_code : Blob; });

    sign_with_ecdsa : ({
      message_hash     : Blob;
      derivation_path : [Blob];
      key_id           : { curve: { #secp256k1 }; name: Text };
    }) -> async ({ signature : Blob });
  };

  let ic : IC = actor("aaaaa-aa");

  public shared func public_key() : async { #Ok : { public_key_hex: Text }; #Err : Text } {
    try {
      let { public_key } = await ic.ecdsa_public_key({
          canister_id = null;
          derivation_path = [ Principal.toBlob(caller) ];
          key_id = { curve = #secp256k1; name = "dfx_test_key" };
      });
      #Ok({ public_key_hex = Hex.encode(Blob.toArray(public_key)) })
    } catch (err) {
      #Err(Error.message(err))
    }
  };

  public shared func sign(message: Text) : async { #Ok : { signature_hex: Text }; #Err : Text } {
    try {
      let message_hash: Blob = Blob.fromArray(SHA256.sha256(Blob.toArray(Text.encodeUtf8(message))));
      Cycles.add<system>(30_000_000_000);
      let { signature } = await ic.sign_with_ecdsa({
          message_hash;
          derivation_path = [ Principal.toBlob(caller) ];
          key_id = { curve = #secp256k1; name = "dfx_test_key" };
      });
      #Ok({ signature_hex = Hex.encode(Blob.toArray(signature))})
    } catch (err) {
      #Err(Error.message(err))
    }
  };

  public query func greet(name : Text) : async Text {
    return "Hello, " # name # "!";
  };
}

I’ve also downgraded Ethers just to rule out a library bug, but the issue remains. Here’s the JavaScript I’m using to derive and verify the address:

 function getEthAddress (publicKey) {
  const key = publicKey.startsWith("0x") ? publicKey : `0x${publicKey}`;
  const uncompressed = ethers.utils.computePublicKey(key, /* compressed = */ false);
  return ethers.utils.computeAddress(uncompressed);
}

  useEffect(() => {
    const fetchPublicKey = async () => {
      try  {

        const result = await eth_bridge_poc_backend.public_key();
        if (result.Err) {
          setError(result.Err);
          return;
        }

        const pb = result.Ok;
        setPublicKey(pb.public_key_hex);

        const addr = getEthAddress(pb.public_key_hex);
        setSignerAddress(addr);

      } catch(err) {
        console.log(err);
      }
    }

    fetchPublicKey();
  }, []);

 async function handleSubmit(event) {
    event.preventDefault();
    if (!signerAddress) {
      return;
    }
   
    const amount = ethers.utils.parseEther("0.01"); 

    console.log("🔑 Derived address:", signerAddress);

    // 2) Fetch balance, fee data, and network in parallel
    const [balance, feeData, network] = await Promise.all([
      provider.getBalance(signerAddress),
      provider.getFeeData(),
      provider.getNetwork()
    ]);

    console.log("🌐 Network:", network.name, "(chainId:", network.chainId + ")");
    console.log("💰 Balance:", ethers.utils.formatEther(balance), "ETH");

    const nonce = await provider.getTransactionCount(signerAddress) 
    const chainId = Number(network.chainId);

    const tx = {
      type: 2,
      chainId,
      nonce,
      maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
      maxFeePerGas: feeData.maxFeePerGas,
      gasLimit: 53600,
      to:    '0x4BD55c4D51ba16420eD10c88fB87958d2107e5fA',
      value: amount,
      gasPrice: null,
      accessList: [],
    };
    
    console.log('Unsigned transaction object:', tx);

    const unsignedSerialized = ethers.utils.serializeTransaction(tx);
    console.log('Unsigned serialized tx:', unsignedSerialized);

    const unsignedBytes = ethers.utils.arrayify(unsignedSerialized);
    const digest = ethers.utils.keccak256(unsignedBytes);
    console.log('Digest being sent for signing:', digest);

    const result = await eth_bridge_poc_backend.sign(digest);
    const signatureHex ='0x' + result.Ok.signature_hex;
    
    const recoveredPubkey = ethers.utils.recoverPublicKey(digest, signatureHex);
    const recoveredAddress = ethers.utils.computeAddress(recoveredPubkey);
    console.log('Recovered public key:', recoveredPubkey);
    console.log('Recovered address:', recoveredAddress);

    if (recoveredAddress !== signerAddress) {
      console.error('Recovered address does not match signer address');
    }

    const signature = ethers.utils.splitSignature(signatureHex);
    const txHex = ethers.utils.serializeTransaction(tx, signature);
    console.log("Full serialized transaction (txHex):", txHex);

    const parsedTransaction = ethers.utils.parseTransaction(txHex);
    console.log(parsedTransaction);
    console.log("From address (after signing):", parsedTransaction.from);
    
    const txResponse = await provider.sendTransaction(txHex);
    console.log('Transaction Hash:', txResponse);
  }

Any idea why the recovered address keeps changing?

Problem was that in the signature, I was applying a sha256 to it, and that’s not required

Here the sign method used

public shared(_msg) func sign(digest: Text):  async { #Ok: { signature_hex: Text }; #Err: Text } {
       try {
            let { signature } = await ic.sign_with_ecdsa({
                message_hash = Blob.fromArray(AU.fromText(digest));
                derivation_path = derivationPath;
                key_id = { curve = #secp256k1; name = keyName };
            });

            return #Ok({ signature_hex = Hex.encode(Blob.toArray(signature));  });
         } catch (err) {
            #Err(Error.message(err))
        }
    };