Managed transaction (payer !== authorizations)

Hello folks,

I’m playing around with onflow/fcl but I’m stuck on something that I don’t understand. Looking for some explanation about signin transaction without using any wallet provider.

I wasn’t able to find anything on discord nor the forum, but please feel free to copy paste some doc or URL, I’ll be glad to read :nerd_face:

Soo, here is my headache :
From this minimal reproducible example, you can directly jump on the main or read the copy/paste here :

index.ts
import { SHA3 } from 'sha3';
import * as Elliptic from 'elliptic';
import * as fcl from '@onflow/fcl';

fcl.config().put('accessNode.api', 'https://access-testnet.onflow.org');

// eslint-disable-next-line new-cap
const ec = new Elliptic.ec('p256');

// COPY PASTE FROM @qvvg : http://forum.flow.com/t/request-for-best-practices-re-wallet-account-creation-server-side/446/3#post_4

function hashMsgHex(msgHex: string) {
  const sha = new SHA3(256);
  sha.update(Buffer.from(msgHex, 'hex'));
  return sha.digest();
}

function signWithKey(privateKey: string, data: string) {
  const key = ec.keyFromPrivate(Buffer.from(privateKey, 'hex'));
  const sig = key.sign(hashMsgHex(data));
  const n = 32; // half of signature length?
  const r = sig.r.toArrayLike(Buffer, 'be', n);
  const s = sig.s.toArrayLike(Buffer, 'be', n);
  return Buffer.concat([r, s]).toString('hex');
}

// END COPY/PASTE

interface Account {
  address: string;
  publicKey: string;
  privateKey: string;
  keyId: number;
}

export const buildAuthorization = ({ address, keyId, privateKey }: Account) => (
  account: any
) => ({
  ...account,
  tempId: address,
  addr: address,
  keyId: keyId,
  resolve: null,
  signingFunction: (data: any) => {
    return {
      addr: address,
      keyId: keyId,
      signature: signWithKey(privateKey, data.message),
    };
  },
});

const admin: Account = {
  address: '0x3b814323826c6a63',
  publicKey:
    '2db41d2982317754f477dfab19aecc9d1fcdef382fd35444b35afbd3645e49f3e55a53dcb293fe54e2741a5b12159c76b29bd2b80f4fd7ea9c47c637a033a03d',
  privateKey:
    'e83db5cd93ecd4ec28cdaf425de0c60acc4cdb9a00950312b6b6b5d0722dd703',
  keyId: 0,
};

const user: Account = {
  address: '0xdfe6fafe93966abc',
  publicKey:
    '151518e2e990e714ca32025c03936cd2104890a8d64f651a619a80ef1b28fdbcef5613d2a92c80df3d13f4e166dffcd36d459c655ca631bfb196a8ac19908e1f',
  privateKey:
    '5bc078d3a1f8a439230268c71063ca066922c97ab5aa09f2da9162a50abe84a9',
  keyId: 0,
};

async function handleTransaction(description: string, args: any) {
  try {
    console.log(description);
    const transaction = await fcl.send(args);
    console.log('-->', transaction.transactionId);
    await fcl.tx(transaction).onceSealed();
    console.log('OK');
  } catch (e) {
    console.log('KO : ', e);
  }
}

async function run() {
  console.log('Ping...');
  await fcl.send([fcl.ping()]);
  console.log('OK');

  await handleTransaction('Simple transaction...', [
    fcl.transaction`
      transaction() {
        prepare(account: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(admin)),
    fcl.authorizations([buildAuthorization(admin)]),
  ]);

  await handleTransaction('Simple managed transaction...', [
    fcl.transaction`
      transaction() {
        prepare(account: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(admin)),
    fcl.authorizations([buildAuthorization(user)]),
  ]);

  await handleTransaction('Multi managed transaction payer === proposer...', [
    fcl.transaction`
      transaction() {
        prepare(accountA: AuthAccount, accountB: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(admin)),
    fcl.authorizations([buildAuthorization(user), buildAuthorization(admin)]),
  ]);

  await handleTransaction('Multi managed transaction payer !== proposer...', [
    fcl.transaction`
      transaction() {
        prepare(accountA: AuthAccount, accountB: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(user)),
    fcl.authorizations([buildAuthorization(user), buildAuthorization(admin)]),
  ]);
}

run().catch(console.error);

Note that all privates keys/address/config are real but I created them only for this example.

Executing this file will log :

Ping...
OK

Simple transaction...
--> 8f13a80e70366993cf41b2f344a10f1a5a3a1fd43dd24bfcea89f1dfc8b43c2b
OK

Simple managed transaction...
--> 43c3d97a355064cb633a9e5d3006b2e2a43499b26165306882396e13028b7fef
KO :  invalid signature: signature could not be verified using public key with index 0 on account 3b814323826c6a63

Multi managed transaction payer === proposer...
--> 824fa5d43ae620f58d59bee101e6fe0afaa9f0232ec6cee074a2d24df47244e3
KO :  invalid signature: signature could not be verified using public key with index 0 on account 3b814323826c6a63

Multi managed transaction payer !== proposer...
--> dcac475352a94925fb5ac9b7c5529374406dd283562eac7fdd0adddbbbabd5b5
OK

What I don’t understand is that multi-sign transaction using a payer different than the proposer is working but every other multi transaction is not… I also tryied to manage myself the sequenceNum but I had the same result.

@qvvg I’m pinging you since I already spoke with you (some time ago, Binou on discord), and I copy paste some of your code héhé :angel:

I’m not really looking for some debugging here, I’m just trying to understand how to code the authorization/signing function to have it consistently work, either with a multi-sign or with an account that pay & propose the transaction for avoiding to my users to pay.

Thanks for your help, and if you have any question, I’m here or on discord (@Binou)

OK so just to summarize because the code could be a little bit hard to read :

Using a self signin mechanism
// eslint-disable-next-line new-cap
const ec = new Elliptic.ec('p256');

// COPY PASTE FROM @qvvg : http://forum.flow.com/t/request-for-best-practices-re-wallet-account-creation-server-side/446/3#post_4

function hashMsgHex(msgHex: string) {
  const sha = new SHA3(256);
  sha.update(Buffer.from(msgHex, 'hex'));
  return sha.digest();
}

function signWithKey(privateKey: string, data: string) {
  const key = ec.keyFromPrivate(Buffer.from(privateKey, 'hex'));
  const sig = key.sign(hashMsgHex(data));
  const n = 32; // half of signature length?
  const r = sig.r.toArrayLike(Buffer, 'be', n);
  const s = sig.s.toArrayLike(Buffer, 'be', n);
  return Buffer.concat([r, s]).toString('hex');
}

// END COPY/PASTE

interface Account {
  address: string;
  publicKey: string;
  privateKey: string;
  keyId: number;
}

export const buildAuthorization = ({ address, keyId, privateKey }: Account) => (
  account: any
) => ({
  ...account,
  tempId: address,
  addr: address,
  keyId: keyId,
  resolve: null,
  signingFunction: (data: any) => {
    return {
      addr: address,
      keyId: keyId,
      signature: signWithKey(privateKey, data.message),
    };
  },
});

A transaction like this is OK :

fcl.send([
    fcl.transaction`
      transaction() {
        prepare(accountA: AuthAccount, accountB: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(user)),
    fcl.authorizations([buildAuthorization(user), buildAuthorization(admin)]),
  ]);

But this one (only the proposer changed) is KO (due to invalid signature error) :

fcl.send([
    fcl.transaction`
      transaction() {
        prepare(accountA: AuthAccount, accountB: AuthAccount) {
          log("Hello World");
        }
      }
    `,
    fcl.payer(buildAuthorization(admin)),
    fcl.proposer(buildAuthorization(admin)),
    fcl.authorizations([buildAuthorization(user), buildAuthorization(admin)]),
  ]);

Sooo, just to keep updated, I finally found a solution.

It seems that the proposer must be one of the signer, and I now use different key for payer/proposer if its the same account (key with weight 1000 for a payer, key with weight 1 for proposer).

Works as I intended like this