Skip to content

Instantly share code, notes, and snippets.

@vbar
Last active June 24, 2022 10:57
Show Gist options
  • Save vbar/6385bf8b00da1f3ec6437e1c2fef3f9f to your computer and use it in GitHub Desktop.
Save vbar/6385bf8b00da1f3ec6437e1c2fef3f9f to your computer and use it in GitHub Desktop.

Notary hook collects signatures for multi-sign transactions. It has two modes of operation:

  1. Attach a proposed transaction to a memo and send it to the hook account
  2. Endorse an already proposed transaction by using its unique ID as invoice ID and sending a 1 drop payment to the hook.

It relies on the signer list on the account the hook is running on. Only accounts on this list can propose and endorse multisign transactions through this hook.

to test:

  • make sure Hooks Builder has at least 3 accounts: Alice, Bob and Carol
  • set up SignerListSet on Alice's account w/ SignerQuorum 2 to Bob & Carol
  • compile notary.c and deploy it to Alice account
  • open debug stream filtered on Alice and run propose.js; it asks for 2 parameters:
    1. notary account is where the hook is running, i.e. Alice
    2. proposer is the account authorized to propose a new transaction, e.g. Bob; the account secret is used to sign the transaction
  • see the hook debug stream, especially that it succeeds and produces and invoice ID (hex-quoted 32 bytes) for the other signers
  • save the invoice ID somewhere
  • run sign.js, as the other authorized user; it asks for 3 parameters:
    1. notary account is Alice, as above
    2. approver is the other authorized account, i.e. Carol
    3. invoice ID is the value saved above
  • see in the hook debug stream (and account balances) that the hook emited the multi-signed transaction

Modifying the example for more signers is left as an exercise for the reader.

/**
* Notary.c - An example hook for collecting signatures for multi-sign transactions without blocking sequence number
* on the account.
*
* Author: Richard Holland
* Date: 11 Feb 2021
*
**/
#include <stdint.h>
#include "hookapi.h"
// maximum tx blob
#define MAX_MEMO_SIZE 4096
// LastLedgerSeq must be this far ahead of current to submit a new txn blob
#define MINIMUM_FUTURE_LEDGER 60
/**
* Notary - easy multisign with Hooks
* Two modes of operation:
* 1. Attach a proposed transaction to a memo and send it to the hook account
* 2. Endorse an already proposed transaction by using its unique ID as invoice ID and sending a 1 drop payment
* to the hook.
*
* This hook relies on the signer list on the account the hook is running on.
* Only accounts on this list can propose and endorse multisign transactions through this Hook.
*/
int64_t hook(uint32_t reserved)
{
TRACESTR("enter hook");
// this api fetches the AccountID of the account the hook currently executing is installed on
// since hooks can be triggered by both incoming and outgoing transactions this is important to know
unsigned char hook_accid[20];
hook_account((uint32_t)hook_accid, 20);
etxn_reserve(1);
// next fetch the sfAccount field from the originating transaction
uint8_t account_field[20];
int32_t account_field_len = otxn_field(SBUF(account_field), sfAccount);
if (account_field_len < 20) // negative values indicate errors from every api
rollback(SBUF("Notary: sfAccount field missing!!!"), 10); // this code could never be hit in prod
// but it's here for completeness
// compare the "From Account" (sfAccount) on the transaction with the account the hook is running on
int equal = 0; BUFFER_EQUAL(equal, hook_accid, account_field, 20);
if (equal)
accept(SBUF("Notary: Outgoing transaction"), 20);
uint8_t tx_blob[MAX_MEMO_SIZE];
int64_t tx_len = 0;
uint8_t invoice_id[32];
int64_t invoice_id_len =
otxn_field(SBUF(invoice_id), sfInvoiceID);
// check if an invoice ID was provided... this would be mode 2 above
if (invoice_id_len == 32)
{
// it was, so this is an attempt at endorsing an existing proposed multisig transaction
// attempt to retrieve the proposed txn blob from the Hook State by setting the last nibble of the invoice ID
// to `F` and using it as state key
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + 0x0FU;
tx_len = state(SBUF(tx_blob), SBUF(invoice_id));
if (tx_len < 0)
rollback(SBUF("Notary: Received invoice id that did not correspond to a submitted multisig txn."), 1);
// proposed txn exists... but it may have expired so we need to check that first
int64_t lls_lookup = sto_subfield(tx_blob, tx_len, sfLastLedgerSequence);
uint8_t* lls_ptr = SUB_OFFSET(lls_lookup) + tx_blob;
uint32_t lls_len = SUB_LENGTH(lls_lookup);
if (lls_len != 4 || UINT32_FROM_BUF(lls_ptr) < ledger_seq())
{
// expired or invalid tx, purging
if (state_set(0, 0, SBUF(invoice_id)) < 0)
rollback(SBUF("Notary: Error erasing old txn blob."), 40);
accept(SBUF("Notary: Multisig txn was too old (last ledger seq passed) and was erased."), 1);
}
// execution to here means the invoice ID corresponded to a currently valid proposed multisig transaction
// that exists in the Hook State for this account
// however we still need to check if this user is on the signer list before proceeding.
}
// check for the presence of a memo
uint8_t memos[MAX_MEMO_SIZE];
int64_t memos_len = otxn_field(SBUF(memos), sfMemos);
uint32_t payload_len = 0;
uint8_t* payload_ptr = 0;
// if there is a memo present then we are in mode 1 above, but we need to ensure the user isn't invoking
// undefined behaviour by making them pick either mode 1 or mode 2:
if (memos_len <= 0 && invoice_id_len <= 0)
accept(SBUF("Notary: Incoming txn with neither memo nor invoice ID, passing."), 0);
if (memos_len > 0 && invoice_id_len > 0)
rollback(SBUF("Notary: Incoming txn with both memo and invoice ID, abort."), 0);
// now check if the sender is on the signer list
// we can do this by first creating a keylet that describes the signer list on the hook account
uint8_t keylet[34];
CLEARBUF(keylet);
if (util_keylet(SBUF(keylet), KEYLET_SIGNERS, SBUF(hook_accid), 0, 0, 0, 0) != 34)
rollback(SBUF("Notary: Internal error, could not generate keylet"), 10);
// then requesting XRPLD slot that keylet into a new slot for us
int64_t slot_no = slot_set(SBUF(keylet), 0);
TRACEVAR(slot_no);
if (slot_no < 0)
rollback(SBUF("Notary: Could not set keylet in slot"), 10);
// once slotted we can examine the signer list object
// the first field we are interested in is the required quorum to actually pass a multisign transaction
int64_t result = slot_subfield(slot_no, sfSignerQuorum, 0);
if (result < 0)
rollback(SBUF("Notary: Could not find sfSignerQuorum on hook account"), 20);
// we will retrieve the 4 byte quorum into a buffer, in future the will be a shortcut for this
uint32_t signer_quorum = 0;
uint8_t buf[4];
result = slot(SBUF(buf), result);
if (result != 4)
rollback(SBUF("Notary: Could not fetch sfSignerQuorum from sfSignerEntries."), 80);
// then conver the four byte buffer to an unsigned 32 bit integer
signer_quorum = UINT32_FROM_BUF(buf);
TRACEVAR(signer_quorum); // print the integer for debugging purposes
// next we want to examine the signer entries, we can do this by loading the signer entries field into a new slot
// or in this case we'll just reuse the existing slot since we're done with the parent object.
result = slot_subfield(slot_no, sfSignerEntries, slot_no);
if (result < 0)
rollback(SBUF("Notary: Could not find sfSignerEntries on hook account"), 20);
// since sfSignerEntries is an array type we can request its length with slot_count
int64_t signer_count = slot_count(slot_no);
if (signer_count < 0)
rollback(SBUF("Notary: Could not fetch sfSignerEntries count"), 30);
// now we need to iterate through all the signers in the signer entries array
// if the account that created the originating transaction is in the list then we can pass here
// otherwise we must rollback because the account is unauthorized
int subslot = 0;
uint8_t found = 0;
uint16_t signer_weight = 0;
for (int i = 0; GUARD(8), i < signer_count + 1; ++i)
{
// load the next array entry into a slot
subslot = slot_subarray(slot_no, i, subslot);
if (subslot < 0)
rollback(SBUF("Notary: Could not fetch one of the sfSigner entries [subarray]."), 40);
// load the account field from that entry into a new slot
result = slot_subfield(subslot, sfAccount, 0);
if (result < 0)
rollback(SBUF("Notary: Could not fetch one of the account entires in sfSigner."), 50);
// dump the new slot into a buffer
uint8_t signer_account[20];
result = slot(SBUF(signer_account), result);
if (result != 20)
rollback(SBUF("Notary: Could not fetch one of the sfSigner entries [slot sfAccount]."), 60);
// load the weight field into a new slot
result = slot_subfield(subslot, sfSignerWeight, 0);
if (result < 0)
rollback(SBUF("Notary: Could not fetch sfSignerWeight from sfSignerEntry."), 70);
// dump the weight field into a buffer
result = slot(buf, 2, result);
if (result != 2)
rollback(SBUF("Notary: Could not fetch sfSignerWeight from sfSignerEntry."), 80);
// convert weight buffer to an integer
signer_weight = UINT16_FROM_BUF(buf);
// some debug output to see the progress
TRACEVAR(signer_weight);
TRACEHEX(account_field);
TRACEHEX(signer_account);
// compare the signer account for this signer entry against the originating transaction (sending) account
int equal = 0;
BUFFER_EQUAL_GUARD(equal, signer_account, 20, account_field, 20, 8);
if (equal)
{
// if the otxn account was in the signer list we can stop iterating
found = i + 1;
break;
}
}
// ensure the otxn account is authed
if (!found)
rollback(SBUF("Notary: Your account was not present in the signer list."), 70);
// execution to this point means the following:
// 1. the originating transaction (sending) account is authorized as one of the signers on the hook account
// 2. either an invoice ID or a memo was sent to the hook (but not both).
// if a memo was sent to the hook it must be mode 1 above (proposing a new multisign transaction)
if (memos_len > 0)
{
// this is a defensive check, it is actually never executed due to an identical condition above
if (invoice_id_len > 0)
rollback(SBUF("Notary: Incoming transaction with both invoice id and memo. Aborting."), 0);
// since our memos are in a buffer inside the hook (as opposed to being a slot) we use the sto api with it
// the sto apis probe into a serialized object returning offsets and lengths of subfields or array entries
int64_t memo_lookup = sto_subarray(memos, memos_len, 0);
uint8_t* memo_ptr = SUB_OFFSET(memo_lookup) + memos;
uint32_t memo_len = SUB_LENGTH(memo_lookup);
// memos are nested inside an actual memo object, so we need to subfield
// equivalently in JSON this would look like memo_array[i]["Memo"]
memo_lookup = sto_subfield(memo_ptr, memo_len, sfMemo);
memo_ptr = SUB_OFFSET(memo_lookup) + memo_ptr;
memo_len = SUB_LENGTH(memo_lookup);
if (memo_lookup < 0)
rollback(SBUF("Notary: Incoming txn had a blank sfMemos, abort."), 1);
int64_t format_lookup = sto_subfield(memo_ptr, memo_len, sfMemoFormat);
uint8_t* format_ptr = SUB_OFFSET(format_lookup) + memo_ptr;
uint32_t format_len = SUB_LENGTH(format_lookup);
int is_unsigned_payload = 0;
BUFFER_EQUAL_STR_GUARD(is_unsigned_payload, format_ptr, format_len, "unsigned/payload+1", 1);
if (!is_unsigned_payload)
accept(SBUF("Notary: Memo is an invalid format. Passing txn."), 50);
int64_t data_lookup = sto_subfield(memo_ptr, memo_len, sfMemoData);
uint8_t* data_ptr = SUB_OFFSET(data_lookup) + memo_ptr;
uint32_t data_len = SUB_LENGTH(data_lookup);
if (data_len > MAX_MEMO_SIZE)
rollback(SBUF("Notary: Memo too large (4kib max)."), 4);
// inspect unsigned payload
// first check that sfTransactionType appears in the memo... if it doesn't then it can't be a transaction
int64_t txtype_lookup = sto_subfield(data_ptr, data_len, sfTransactionType);
if (txtype_lookup < 0)
rollback(SBUF("Notary: Memo is invalid format. Should be an unsigned transaction."), 2);
// next check the lastLedgerSequence is sensibly set otherwise there will be no chance for the other signers
// to endorse the txn before it expires
int64_t lls_lookup = sto_subfield(data_ptr, data_len, sfLastLedgerSequence);
uint8_t* lls_ptr = SUB_OFFSET(lls_lookup) + data_ptr;
uint32_t lls_len = SUB_LENGTH(lls_lookup);
// check for expired txn
if (lls_len != 4 || UINT32_FROM_BUF(lls_ptr) < ledger_seq() + MINIMUM_FUTURE_LEDGER)
rollback(SBUF("Notary: Provided txn blob expires too soo (LastLedgerSeq)."), 3);
// compute txn hash, this becomes the ID passed as an invoice ID by the endorsers (other signers)
if (util_sha512h(SBUF(invoice_id), data_ptr, data_len) < 0)
rollback(SBUF("Notary: Could not compute sha512 over the submitted txn."), 5);
TRACEHEX(invoice_id);
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + 0x0FU;
// write blob to state... the state key for the txn blob is the txn ID with `F` as the last nibble.
int64_t ssrv = state_set(data_ptr, data_len, SBUF(invoice_id));
if (ssrv < 0) {
TRACEVAR(ssrv);
rollback(SBUF("Notary: Could not write txn to hook state."), 6);
}
}
// execution to here means if we were in mode 1 we now drop into mode 2, because the proposed txn is now recorded
// so we simply treat this as an endorsement (mode 2) from here...
// record the signature... the state key for this is the txn ID with (1 + signer number) as the last nibble
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + found;
// the value we record against the signer is his/her signer weight at the time the endorsement or proposal happened
UINT16_TO_BUF(buf, signer_weight);
if (state_set(buf, 2, SBUF(invoice_id)) != 2)
rollback(SBUF("Notary: Could not write signature to hook state."), 7);
// check if we have managed to achieve a quorum by loading all current signatures and adding together the signer
// weights (stored as the HookState values)
uint32_t total = 0;
for (uint8_t i = 1; GUARD(8), i < 9; ++i)
{
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + i;
if (state(buf, 2, SBUF(invoice_id)) == 2)
total += UINT16_FROM_BUF(buf);
}
TRACEVAR(total);
TRACEVAR(signer_quorum);
// if we haven't achieved a quorum we will output the ID as the hook result string so it can be given to the
// other endorsers
if (total < signer_quorum)
{
uint8_t header[] = "Notary: Accepted waiting for other signers...: ";
uint8_t returnval[112];
uint8_t* ptr = returnval;
for (int i = 0; GUARD(47), i < 47; ++i)
*ptr++ = header[i];
for (int i = 0; GUARD(32),i < 32; ++i)
{
uint8_t hi = (invoice_id[i] >> 4U);
uint8_t lo = (invoice_id[i] & 0xFU);
hi += ( hi > 9 ? ('A'-10) : '0' );
lo += ( lo > 9 ? ('A'-10) : '0' );
*ptr++ = hi;
*ptr++ = lo;
}
accept(SBUF(returnval), 0);
}
// execution to here means we achieved a quorum on a proposed txn
// therefore we must now emit the txn then garbage collect the old state
int should_emit = 1;
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + 0x0FU;
tx_len = state(SBUF(tx_blob), SBUF(invoice_id));
if (tx_len < 0)
should_emit = 0;
// delete everything from state before emitting
state_set(0, 0, SBUF(invoice_id));
for (uint8_t i = 1; GUARD(8), i < 9; ++i)
{
invoice_id[31] = ( invoice_id[31] & 0xF0U ) + i;
state_set(0, 0, SBUF(invoice_id));
}
if (!should_emit)
rollback(SBUF("Notary: Tried to emit multisig txn but it was missing"), 1);
// blob exists, check expiry
int64_t lls_lookup = sto_subfield(tx_blob, tx_len, sfLastLedgerSequence);
uint8_t* lls_ptr = SUB_OFFSET(lls_lookup) + tx_blob;
uint32_t lls_len = SUB_LENGTH(lls_lookup);
if (lls_len != 4)
rollback(SBUF("Notary: Was about to emit txn but it doesn't have LastLedgerSequence"), 1);
uint32_t lls_old = UINT32_FROM_BUF(lls_ptr);
if (lls_old < ledger_seq())
rollback(SBUF("Notary: Was about to emit txn but it's too old now"), 1);
// modify the txn for emission
// we need to remove sfSigners if it exists
// we need to zero sfSequence sfSigningPubKey and sfTxnSignature
// we need to correctly set sfFirstLedgerSequence
// first do the erasure, this can fail if there is no such sfSigner field, so swap buffers to immitate success
uint8_t buffer[MAX_MEMO_SIZE];
uint8_t* buffer2 = buffer;
uint8_t* buffer1 = tx_blob;
result = sto_erase(buffer2, MAX_MEMO_SIZE, buffer1, tx_len, sfSigners);
if (result > 0)
tx_len = result;
else
BUFFER_SWAP(buffer1, buffer2);
// next zero sfSequence
uint8_t zeroed[6];
CLEARBUF(zeroed);
zeroed[0] = 0x24U; // this is the lead byte for sfSequence
tx_len = sto_emplace(buffer1, MAX_MEMO_SIZE, buffer2, tx_len, zeroed, 5, sfSequence);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfSequence failed."), 1);
// next set sfTxnSignature to 0
zeroed[0] = 0x74U; // lead byte for sfTxnSignature, next byte is length which is 0
tx_len = sto_emplace(buffer2, MAX_MEMO_SIZE, buffer1, tx_len, zeroed, 2, sfTxnSignature);
TRACEVAR(tx_len);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfTxnSignature failed."), 1);
// next set sfSigningPubKey to 0
zeroed[0] = 0x73U; // this is the lead byte for sfSigningPubkey, note that the next byte is 0 which is the length
tx_len = sto_emplace(buffer1, MAX_MEMO_SIZE, buffer2, tx_len, zeroed, 2, sfSigningPubKey);
TRACEVAR(tx_len);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfSigningPubKey failed."), 1);
// finally set FirstLedgerSeq appropriately
uint32_t fls = ledger_seq() + 1;
zeroed[0] = 0x20U;
zeroed[1] = 0x1AU;
UINT32_TO_BUF(zeroed + 2, fls);
tx_len = sto_emplace(buffer2, MAX_MEMO_SIZE, buffer1, tx_len, zeroed, 6, sfFirstLedgerSequence);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfFirstLedgerSequence failed."), 1);
uint32_t lls_new = fls + 4;
if (lls_old > lls_new) {
trace("fixing", 6, buffer2, tx_len, 1);
tx_len = sto_erase(buffer1, MAX_MEMO_SIZE, buffer2, tx_len, sfLastLedgerSequence);
if (tx_len <= 0)
rollback(SBUF("Notary: Erasing sfLastLedgerSequence failed."), 1);
zeroed[1] = 0x1BU;
UINT32_TO_BUF(zeroed + 2, lls_new);
tx_len = sto_emplace(buffer2, MAX_MEMO_SIZE, buffer1, tx_len, zeroed, 6, sfLastLedgerSequence);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfLastLedgerSequence failed."), 1);
}
// finally add emit details
uint8_t emitdet[138];
result = etxn_details(SBUF(emitdet));
if (result < 0)
rollback(SBUF("Notary: EmitDetails failed to generate."), 1);
tx_len = sto_emplace(buffer1, MAX_MEMO_SIZE, buffer2, tx_len, emitdet, result, sfEmitDetails);
if (tx_len < 0)
rollback(SBUF("Notary: Emplacing sfEmitDetails failed."), 1);
// replace fee with something currently appropriate
uint8_t fee[ENCODE_DROPS_SIZE];
uint8_t* fee_ptr = fee; // this ptr is incremented by the macro, so just throw it away
int64_t fee_to_pay = etxn_fee_base(buffer1, tx_len);
if (fee_to_pay < 0)
rollback(SBUF("Notary: Computing sfFee failed."), 1);
ENCODE_DROPS(fee_ptr, fee_to_pay, amFEE);
tx_len = sto_emplace(buffer2, MAX_MEMO_SIZE, buffer1, tx_len, SBUF(fee), sfFee);
if (tx_len <= 0)
rollback(SBUF("Notary: Emplacing sfFee failed."), 1);
uint8_t emithash[32];
int64_t erv = emit(SBUF(emithash), buffer2, tx_len);
if (erv < 0) {
TRACEVAR(erv);
accept(SBUF("Notary: All conditions met but emission failed: proposed txn was malformed."), 1);
}
accept(SBUF("Notary: Emitted multisigned txn"), 0);
return 0;
}
import lib from "https://esm.sh/xrpl-accountlib?bundle";
import bin from "https://esm.sh/ripple-binary-codec?bundle";
import { XrplClient } from "https://esm.sh/xrpl-client?bundle";
import keypairs from "https://esm.sh/ripple-keypairs?bundle";
// Alice
const notary_account = "{{ customize_input notary_account type='select' attach='account_address' }}";
// Bob
const proposer_secret = "{{ customize_input proposer_secret type='select' attach='account_secret' }}";
const proposer_keypair = lib.derive.familySeed(proposer_secret);
const proposer_account = keypairs.deriveAddress(proposer_keypair.keypair.publicKey);
const client = new XrplClient('wss://hooks-testnet-v2.xrpl-labs.com');
const main = async () => {
console.log('notary_account', notary_account);
const proposed_tx = {
TransactionType: 'Payment',
Account: notary_account,
Amount: '100',
Destination: proposer_account,
DestinationTag: '42',
LastLedgerSequence: "4000000000",
Fee: '0',
Sequence: 0
};
const inner_tx = bin.encode(proposed_tx);
const { account_data } = await client.send({ command: 'account_info', 'account': proposer_account });
if (!account_data) {
console.log('Proposer account not found.');
client.close();
return;
}
const tx = {
TransactionType: 'Payment',
Account: proposer_account,
Amount: '1',
Destination: notary_account,
Fee: '12000000',
Memos: [
{
Memo: {
MemoData: inner_tx,
MemoFormat: "unsigned/payload+1",
MemoType: "notary/proposed"
}
}
],
Sequence: account_data.Sequence
};
hexlify_memos(tx);
const {signedTransaction} = lib.sign(tx, proposer_keypair);
const submit = await client.send({ command: 'submit', 'tx_blob': signedTransaction });
console.log(submit);
console.log('Shutting down...');
client.close();
};
function hexlify_memos(x)
{
if (!("Memos" in x))
return;
for (let y = 0; y < x["Memos"].length; ++y)
{
let Memo = x["Memos"][y]["Memo"];
let Fields = ["MemoFormat", "MemoType", "MemoData"];
for (let z = 0; z < Fields.length; ++z)
{
if (Fields[z] in Memo)
{
let u = Memo[Fields[z]].toUpperCase()
if (u.match(/^[0-9A-F]+$/))
{
Memo[Fields[z]] = u;
continue;
}
let v = Memo[Fields[z]], q = "";
for (let i = 0; i < v.length; ++i)
{
q += Number(v.charCodeAt(i)).toString(16).padStart(2, '0');
}
Memo[Fields[z]] = q.toUpperCase();
}
}
}
}
main()
import lib from "https://esm.sh/xrpl-accountlib?bundle";
import { XrplClient } from "https://esm.sh/xrpl-client?bundle";
import keypairs from "https://esm.sh/ripple-keypairs?bundle";
// Alice
const notary_account = "{{ customize_input notary_account type='select' attach='account_address' }}";
// Carol
const approver_secret = "{{ customize_input approver_secret type='select' attach='account_secret' }}";
const approver_keypair = lib.derive.familySeed(approver_secret);
const approver_account = keypairs.deriveAddress(approver_keypair.keypair.publicKey);
const client = new XrplClient('wss://hooks-testnet-v2.xrpl-labs.com');
const main = async (proposal) => {
console.log("proposal", proposal);
try {
const { account_data } = await client.send({ command: 'account_info', 'account': approver_account });
if (!account_data) {
console.log('Approver account not found.');
client.close();
return;
}
console.log("sequence", account_data.Sequence);
const tx = {
Account: approver_account,
TransactionType: "Payment",
Amount: "1",
Destination: notary_account,
Fee: "1000000",
InvoiceID: proposal,
Sequence: account_data.Sequence
};
const {signedTransaction} = lib.sign(tx, approver_keypair);
const submit = await client.send({ command: 'submit', 'tx_blob': signedTransaction });
console.log(submit);
} catch (err) {
error(err)
}
console.log('Shutting down...');
client.close();
};
main("{{invoice_id}}");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment