solana: Web3.js transaction serialize => deserialize => serialize can produce "Signature verification failed" due to bug in Message serialization

Problem

The logic here https://github.com/solana-labs/solana-web3.js/blob/74007a55afa29b36e0535bb9bcff3b64dda9b19e/src/transaction.ts#L261-L281

Can lead to different orderings to accountMetas after serializing and deserializing. The “content” of the transaction is the same, however the accountKeys flipped around. So long as the indices still hold true for writable accounts, the transaction will function the same.

The issue is that this is then serialized and used to create a transaction signature. So this non-determinism can mean that a previously correct signature is now incorrect.

Code to reproduce:

    const signer = Keypair.generate();
    const acc0Writable = Keypair.generate();
    const acc1Writable = Keypair.generate();
    const acc2Writable = Keypair.generate();
    const t0 = new Transaction({
      recentBlockhash: "HZaTsZuhN1aaz9WuuimCFMyH7wJ5xiyMUHFCnZSMyguH",
      feePayer: signer.publicKey
    });
    t0.add(new TransactionInstruction({
      keys: [{
        pubkey: signer.publicKey,
        isWritable: true,
        isSigner: true
      }, {
        pubkey: acc0Writable.publicKey,
        isWritable: true,
        isSigner: false
      }],
      programId: Keypair.generate().publicKey
    }))
    t0.add(new TransactionInstruction({
      keys: [{
        pubkey: acc1Writable.publicKey,
        isWritable: false,
        isSigner: false
      }],
      programId: Keypair.generate().publicKey
    }))
    t0.add(new TransactionInstruction({
      keys: [{
        pubkey: acc2Writable.publicKey,
        isWritable: true,
        isSigner: false
      }],
      programId: Keypair.generate().publicKey
    }))
    t0.add(new TransactionInstruction({
      keys: [{
        pubkey: signer.publicKey,
        isWritable: true,
        isSigner: true
      },{
        pubkey: acc0Writable.publicKey,
        isWritable: false,
        isSigner: false
      },  {
        pubkey: acc2Writable.publicKey,
        isWritable: false,
        isSigner: false
      }, {
        pubkey: acc1Writable.publicKey,
        isWritable: true,
        isSigner: false
      }],
      programId: Keypair.generate().publicKey
    }))
    t0.partialSign(signer);
    const t1 = Transaction.from(t0.serialize());
    t1.serialize()

A workaround is to, before pre-signing the transaction, run Transaction.from(tx.serialize({ requireAllSignatures: false })). Then sign the transaction and serialize. This ensures the ordering doesn’t get jumbled.

Proposed Solution

Add a default sorting here based on the alphabetical (or just numerical) sorting of the public keys so that this function is deterministic.

https://github.com/solana-labs/solana-web3.js/blob/74007a55afa29b36e0535bb9bcff3b64dda9b19e/src/transaction.ts#L261

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 2
  • Comments: 23 (11 by maintainers)

Commits related to this issue

Most upvoted comments

I dug into the code and came up with what I think is causing this. Related: #23641, #23610, https://github.com/solana-labs/wallet-adapter/issues/350

  1. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/message.ts#L12-L25 Message, and the wire format of the message, does not store information that allows AccountMeta to be recreated per instruction. Only the number of signatures required, and the numbers of read-only accounts (signed and unsigned) are stored in the message header.

  2. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/transaction.ts#L705-L709 Because of 1), when a Transaction is populated from a Message, if an account has isSigner or isWritable flags for any instruction, these flags will be propagated to the account for every instruction in the transaction.

  3. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/transaction.ts#L261-L270 Because of 2), when AccountMeta are sorted by isSigner and isWritable, accounts that didn’t have isSigner or isWritable flags before serialization may have them after deserialization. The order of these is likely to change.

  4. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/transaction.ts#L272-L285 Because of 3), AccountMeta will be iterated over in a different order to gather the unique keys.

  5. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/transaction.ts#L327-L343 Because of 4), the signed and unsigned keys will be collected in a different order and concatenated.

  6. https://github.com/solana-labs/solana/blob/17cc095d2892654225b5efe91b55c19eb932bfd3/web3.js/src/message.ts#L191-L196 Because of 5), the keys will be serialized as bytes in a different order. When these bytes are signed, this will result in a different signature.

In short, because of 1), Transaction to Message performs lossy compression. You can’t deterministically serialize a Transaction to a Message and deserialize this Message back to the original Transaction.

Fix should be released in @solana/web3.js@1.39.1

PR attempt @ #23720, please review!

I wonder if it makes sense to remove the class Message abstraction and instead merge the message into the transaction.

No. Wrong direction. Transaction is a container for a Message and its signatures, nothing more

I don’t think it’s useful to enforce or follow any kind of canonical account key sorting method. The way Transaction.populate is decompiling the message account keys is losing the ordering used by the processed transaction so I think we have to get rid of that.

Hi @jstarry! Is the order of serialization part of the Solana spec officially? Should the Solana Documentation be updated? We have some problem here when we need to communicate between Dart Library and Js Library: https://github.com/cryptoplease/cryptoplease-dart/pull/184

Hey, do you think you can help me? https://github.com/solana-labs/solana/issues/22391 I think you have described my problem. But Im using offline signing, so I cant user partialSign because I dont have signer object. If you will refactor my code to needed or just give advice I would be really happy!