o1js icon indicating copy to clipboard operation
o1js copied to clipboard

transaction.sign produces an invalid signature after import from JSON

Open dfstio opened this issue 1 year ago • 5 comments

In case the transaction is exported to JSON, then imported from JSON, signed after import, and sent, the “Check signature: Invalid signature” error is thrown, and the authorization field of the transaction is undefined.

In the case of exporting already signed transaction, everything is working as expected.

The code to reproduce the error:

import { describe, expect, it } from "@jest/globals";
import fs from "fs/promises";
import {
  AccountUpdate,
  PrivateKey,
  Mina,
  PublicKey,
  UInt64,
  Types,
} from "o1js";

jest.setTimeout(1000 * 60 * 60 * 1); // 1 hour
const transactionFee = 150_000_000;
let senderPrivateKey: PrivateKey | undefined = undefined;
let senderPublicKey: PublicKey | undefined = undefined;

beforeAll(async () => {
  const Local = Mina.LocalBlockchain({ proofsEnabled: true });
  Mina.setActiveInstance(Local);
  const { privateKey } = Local.testAccounts[0];
  senderPrivateKey = privateKey;
  senderPublicKey = senderPrivateKey.toPublicKey();
  expect(senderPublicKey).not.toBeUndefined();
  expect(senderPrivateKey).not.toBeUndefined();
});

describe("Sign, export, and import transaction", () => {
  it("should sign and export transaction", async () => {
    if (senderPublicKey === undefined || senderPrivateKey === undefined) return;
    const sender: PublicKey = senderPublicKey;
    const transaction = await Mina.transaction(
      { sender, fee: transactionFee },
      () => {
        AccountUpdate.fundNewAccount(sender);
        const senderUpdate = AccountUpdate.create(sender);
        senderUpdate.requireSignature();
        senderUpdate.send({
          to: PrivateKey.random().toPublicKey(),
          amount: UInt64.from(1_000_000_000n),
        });
      }
    );
    // Sign BEFORE exporting
    transaction.sign([senderPrivateKey]);
    await fs.writeFile("./json/tx-signed.json", transaction.toJSON());
  });

  it("should send a signed transaction", async () => {
    const transaction: Mina.Transaction = Mina.Transaction.fromJSON(
      JSON.parse(
        await fs.readFile("./json/tx-signed.json", "utf8")
      ) as Types.Json.ZkappCommand
    ) as Mina.Transaction;
    console.log("transaction signed before export:", transaction.toPretty());
    const tx = await transaction.send();
    expect(tx.isSuccess).toBe(true);
  });
});

describe("Export, import and sign transaction", () => {
  it("should export unsigned transaction", async () => {
    if (senderPublicKey === undefined || senderPrivateKey === undefined) return;
    const sender: PublicKey = senderPublicKey;
    const transaction = await Mina.transaction(
      { sender, fee: transactionFee },
      () => {
        AccountUpdate.fundNewAccount(sender);
        const senderUpdate = AccountUpdate.create(sender);
        senderUpdate.requireSignature();
        senderUpdate.send({
          to: PrivateKey.random().toPublicKey(),
          amount: UInt64.from(1_000_000_000n),
        });
      }
    );
    await fs.writeFile("./json/tx-unsigned.json", transaction.toJSON());
  });

  it("should import, sign and sendtransaction", async () => {
    const transaction: Mina.Transaction = Mina.Transaction.fromJSON(
      JSON.parse(
        await fs.readFile("./json/tx-unsigned.json", "utf8")
      ) as Types.Json.ZkappCommand
    ) as Mina.Transaction;
    expect(senderPrivateKey).not.toBeUndefined();
    if (senderPrivateKey === undefined) return;
    // Sign AFTER importing
    transaction.sign([senderPrivateKey]);
    console.log("transaction signed after import:", transaction.toPretty());
    const tx = await transaction.send();
    expect(tx.isSuccess).toBe(true);
  });
});

dfstio avatar Nov 02 '23 17:11 dfstio

It seems like feePayer.lazyAuthorization is not being exported and imported

dfstio avatar Nov 03 '23 08:11 dfstio

Yes, lazy authorization is not part of the JSON. So for signing after parsing from JSON you need to do something more custom. Either, reset the lazy authorization, or use mina-signer which is designed to sign JSON transactions

mitschabaude avatar Nov 12 '23 23:11 mitschabaude

Maybe fromJSON could make the fact that lazy authorizations are missing detectable, so that sign() could throw a helpful error. Seems low priority though

mitschabaude avatar Nov 13 '23 00:11 mitschabaude

It can be used mina-signer as the example below:

import { jest, describe, expect, it } from "@jest/globals";
import Client from 'mina-signer';
import fs from "fs/promises";
import {
  AccountUpdate,
  PrivateKey,
  Mina,
  PublicKey,
  UInt64,
  Types,
} from "o1js";

jest.setTimeout(1000 * 60 * 60 * 1); // 1 hour
const transactionFee = 150_000_000;
let senderPrivateKey: PrivateKey | undefined = undefined;
let senderPublicKey: PublicKey | undefined = undefined;
let client: Client | undefined;

beforeAll(async () => {
  const Local = Mina.LocalBlockchain({ proofsEnabled: true });
  Mina.setActiveInstance(Local);
  client = new Client({ network: Local.getNetworkId() }); 
  const { privateKey } = Local.testAccounts[0];
  senderPrivateKey = privateKey;
  senderPublicKey = senderPrivateKey.toPublicKey();
  expect(senderPublicKey).not.toBeUndefined();
  expect(senderPrivateKey).not.toBeUndefined();

  await fs.mkdir('./json', { recursive: true });
});

describe("Sign, export, and import transaction", () => {
  it("should sign and export transaction", async () => {
    if (senderPublicKey === undefined || senderPrivateKey === undefined) return;
    const sender: PublicKey = senderPublicKey;
    const transaction = await Mina.transaction(
      { sender, fee: transactionFee },
      () => {
        AccountUpdate.fundNewAccount(sender);
        const senderUpdate = AccountUpdate.create(sender);
        senderUpdate.requireSignature();
        senderUpdate.send({
          to: PrivateKey.random().toPublicKey(),
          amount: UInt64.from(1_000_000_000n),
        });
      }
    );
    // Sign BEFORE exporting
    transaction.sign([senderPrivateKey]);
    await fs.writeFile("./json/tx-signed.json", transaction.toJSON());
  });

  it("should send a signed transaction", async () => {
    // @ts-ignore
    const transaction: Mina.Transaction = Mina.Transaction.fromJSON(
      JSON.parse(
        await fs.readFile("./json/tx-signed.json", "utf8")
      ) as Types.Json.ZkappCommand
    ) as Mina.Transaction;

    const tx = await transaction.send();
    expect(tx.status).toBe('pending');
  });
});

describe("Export, import and sign transaction", () => {
  it("should export unsigned transaction", async () => {
    if (senderPublicKey === undefined || senderPrivateKey === undefined) return;
    const sender: PublicKey = senderPublicKey;
    const transaction = await Mina.transaction(
      { sender, fee: transactionFee },
      () => {
        AccountUpdate.fundNewAccount(sender);
        const senderUpdate = AccountUpdate.create(sender);
        senderUpdate.requireSignature();
        senderUpdate.send({
          to: PrivateKey.random().toPublicKey(),
          amount: UInt64.from(1_000_000_000n),
        });
      }
    );
    await fs.writeFile("./json/tx-unsigned.json", transaction.toJSON());
  });

  it("should import, sign and sendtransaction", async () => {
    let signBody = {}
    const unsignedTx = JSON.parse(
      await fs.readFile("./json/tx-unsigned.json", "utf8")
    )

    signBody = {
      zkappCommand: unsignedTx,
      feePayer: {
          feePayer: unsignedTx.feePayer.body.publicKey,
          fee: unsignedTx.feePayer.body.fee,
          nonce: unsignedTx.feePayer.body.nonce,
          memo: unsignedTx.memo.substring(0, 32)||""
      },
    }

    expect(senderPrivateKey).not.toBeUndefined();
    if (senderPrivateKey === undefined) return;

    // Sign with mina signer after importing unsigned tx. 
    // NOTE: There is a char length issue with memo.
    const signedTx = client?.signTransaction(signBody, senderPrivateKey.toBase58())

    // @ts-ignore
    const transaction: Mina.Transaction = Mina.Transaction.fromJSON(
      signedTx?.data.zkappCommand
    ) as Mina.Transaction;

    const tx = await transaction.send();
    expect(tx.status).toBe('pending');
  });
});

https://github.com/berkingurcan/mina-signer-example/blob/main/src/Add.test.ts

Only thing is that there is a character limit for memo and I cropped it to 32.

berkingurcan avatar Mar 25 '24 11:03 berkingurcan

Only thing is that there is a character limit for memo and I cropped it to 32.

explanation: the memo in the JSON transaction is bas58-encoded, but what mina-signer expects in the feePayer field is the original, unencoded memo string.

I think the solution is to not take the memo from the JSON tx. Either just not use a memo and pass memo: "" to mina-signer, or store the original memo you want to use separately from the JSON tx and use it directly

mitschabaude avatar Mar 25 '24 13:03 mitschabaude