Multi Signature UTXOs with AvalancheJS
Introductionβ
An account on a chain that follows the UTXO model doesn't have a parameter like balance. All it has is a bunch of outputs that are resulted from previous transactions. Each output has some amount of asset associated with them. These outputs can have 1 or multiple owners. The owners are basically the account addresses that can consume this output.
The outputs are the result of a transaction that can be spent by the owner of that output. For example, an account has 3 outputs that it can spend, and hence are currently unspent. That is why we call them Unspent Transaction Outputs (UTXOs). So it is better to use the term unspent outputs rather than just outputs. Similarly, we add the amount in the UTXOs owned by an address to calculate its balance. Signing a transaction basically adds the signature of the UTXO owners included in the inputs.
If an account A wants to send 1.3 AVAX to account B, then it has to include all those unspent outputs in a transaction, that are owned by A and whose sum of amounts in those outputs is more than or equal to 1.3. These UTXOs will be included as inputs in a transaction. Account A also has to create outputs with amount 1.3 and the owner being the receiver (here B). There could be multiple outputs in the outputs array. This means, that using these UTXOs, we can create multiple outputs with different amounts to different addresses.
Once the transaction is committed, the UTXOs in the inputs will be consumed and outputs will become new UTXOs for the receiver. If the inputs have more amount unlocked than being consumed by the outputs, then the excess amount will be burned as fees. Therefore, we should also create a change output which will be assigned to us, if there is an excess amount in the input. In the diagram given below, a total of 1.72 AVAX is getting unlocked in inputs, therefore we have also created a change output for the excess amount (0.41 AVAX) to the sender's address. The remaining amount after being consumed by the outputs like receiver's and change output, is burned as fees (0.01 AVAX).
Multi-Signature UTXOsβ
UTXOs can be associated with multiple addresses. If there are multiple owners of
a UTXO, then we must note the threshold
value. We have to include signatures
of a threshold number of UTXO owners with the unsigned transaction to consume
UTXOs present in the inputs. The threshold value of a UTXO is set while issuing
the transaction.
We can use these multi-sig UTXOs as inputs for multiple purposes and not only for sending assets. For example, we can use them to create Subnets, add delegators, add validators, etc.
Atomic Transactionsβ
On Avalanche, we can even create cross-chain outputs. This means that we can do a native cross-chain transfer of assets. These are made possible through Atomic Transactions. This is a 2-step process -
- Export transaction on source chain
- Import transactions on the destination chain
Atomic transactions are similar to other transactions. We use UTXOs of the source chain as inputs and create outputs owned by destination chain addresses. When the export transactions are issued, the newly created UTXOs stay in the Exported Atomic Memory. These are neither on the source chain nor on the destination chain. These UTXOs can only be used as inputs by their owners on the destination chain while making import transactions. Using these UTXOs on the atomic memory, we can create multiple outputs with different amounts or addresses.
UTXOs on C-Chainβ
We can't use UTXOs on C-Chain to do regular transactions because C-Chain follows the account-based approach. In C-Chain, each address (account) is mapped with its balance, and the assets are transferred simply by adding and subtracting from this balance using the virtual machine.
But we can export UTXOs with one or multiple owners to C-Chain and then import them by signing the transaction with the qualified spenders containing those UTXOs as inputs. The output on C-Chain can only have a single owner (a hexadecimal address). Similarly while exporting from C-Chain to other chains, we can have multiple owners for the output, but input will be signed only by the account whose balance is getting used.
Getting Hands-on Multi-Signature UTXOsβ
Next, we will make utility and other helpful functions, so that, we can use them to create multi-sig UTXOs and spend them with ease. These functions will extract common steps into a function so that we do not have to follow each step every time we are issuing a transaction.
You can either follow the steps below to get a better understanding of concepts and code or directly clone and test the examples from this repo.
Setting Up Projectβ
Make a new directory multisig
for keeping all the project codes and move
there. First, let's install the required dependencies.
npm install --save @avalabs/avalanchejs dotenv
Now create a configuration file named config.js
for storing all the pieces of
information regarding the network and chain we are connecting to. Since we are
making transactions on the Fuji network, its network ID is 5. You can change the
configuration according to the network you are using.
require("dotenv").config();
module.exports = {
protocol: "https",
ip: "api.avax-test.network",
port: 443,
networkID: 5,
privateKeys: JSON.parse(process.env.PRIVATEKEYS),
mnemonic: process.env.MNEMONIC,
};
Create a .env
file for storing sensitive information which we can't make
public like the private keys or the mnemonic. Here are the sample private keys,
which you should not use. You can create a new account on Avalanche
Wallet and paste the mnemonic here for
demonstration.
PRIVATEKEYS=`[
"PrivateKey-ewoqjP7PxY4yr3iLTpLisriqt94hdyDFNgchSxGGztUrTXtNN",
"PrivateKey-R6e8f5QSa89DjpvL9asNdhdJ4u8VqzMJStPV8VVdDmLgPd8a4"
]`
MNEMONIC="mask stand appear..."
Setting Up APIs and Keychainsβ
Create a file importAPI.js
for importing and setting up all the necessary
APIs, Keychains, addresses, etc. Now paste the following snippets into the file.
Importing Dependencies and Configurationsβ
We need dependencies like the AvalancheJS module and other configurations. Let's import them at the top.
const { Avalanche, BinTools, BN } = require("avalanche");
const Web3 = require("web3");
const MnemonicHelper = require("avalanche/dist/utils/mnemonic").default;
const HDNode = require("avalanche/dist/utils/hdnode").default;
const { privateToAddress } = require("ethereumjs-util");
// Importing node details and Private key from the config file.
const {
ip,
port,
protocol,
networkID,
privateKeys,
mnemonic,
} = require("./config.js");
let { avaxAssetID, chainIDs } = require("./constants.js");
// For encoding and decoding to CB58 and buffers.
const bintools = BinTools.getInstance();
Setup Avalanche APIsβ
To make API calls to the Avalanche network and different blockchains like X-Chain, P-Chain and C-Chain, let's set up these by adding the following code snippet.
// Avalanche instance
const avalanche = new Avalanche(ip, port, protocol, networkID);
const nodeURL = `${protocol}://${ip}:${port}/ext/bc/C/rpc`;
const web3 = new Web3(nodeURL);
// Platform and Avax API
const platform = avalanche.PChain();
const avax = avalanche.XChain();
const evm = avalanche.CChain();
Setup Keychains with Private Keysβ
In order to sign transactions with our private keys, we will use the AvalancheJS keychain API. This will locally store our private keys and can be easily used for signing.
// Keychain for signing transactions
const keyChains = {
x: avax.keyChain(),
p: platform.keyChain(),
c: evm.keyChain(),
};
function importPrivateKeys(privKey) {
keyChains.x.importKey(privKey);
keyChains.p.importKey(privKey);
keyChains.c.importKey(privKey);
}
We can either use mnemonics to derive private keys from it or simply use the bare private key for importing keys to the keychain. We can use the following function to get private keys from the mnemonic and address index which we want. For demo purposes, we will use addresses at index 0 and 1.
function getPrivateKey(mnemonic, activeIndex = 0) {
const mnemonicHelper = new MnemonicHelper();
const seed = mnemonicHelper.mnemonicToSeedSync(mnemonic);
const hdNode = new HDNode(seed);
const avaPath = `m/44'/9000'/0'/0/${activeIndex}`;
return hdNode.derive(avaPath).privateKeyCB58;
}
// importing keys in the key chain - use this if you have any private keys
// privateKeys.forEach((privKey) => {
// importPrivateKeys(privKey)
// })
// importing private keys from mnemonic
importPrivateKeys(getPrivateKey(mnemonic, 0));
importPrivateKeys(getPrivateKey(mnemonic, 1));
Setup Addresses and Chain IDsβ
For creating transactions we might need addresses of different formats like
Buffer
or Bech32
etc. And to make issue transactions on different chains we
need their chainID
. Paste the following snippet to achieve the same.
// Buffer representation of addresses
const addresses = {
x: keyChains.x.getAddresses(),
p: keyChains.p.getAddresses(),
c: keyChains.c.getAddresses(),
};
// String representation of addresses
const addressStrings = {
x: keyChains.x.getAddressStrings(),
p: keyChains.p.getAddressStrings(),
c: keyChains.c.getAddressStrings(),
};
avaxAssetID = bintools.cb58Decode(avaxAssetID);
chainIDs = {
x: bintools.cb58Decode(chainIDs.x),
p: bintools.cb58Decode(chainIDs.p),
c: bintools.cb58Decode(chainIDs.c),
};
// Exporting these for other files to use
module.exports = {
networkID,
platform,
avax,
evm,
keyChains,
avaxAssetID,
addresses,
addressStrings,
chainIDs,
bintools,
web3,
BN,
};
We can use the above-exported variables and APIs from other files as required.
Creating Utility Functionsβ
While creating multi-sig transactions, we have a few things in common, like
creating inputs with the UTXOs, creating outputs, and adding signature indexes.
So let's create a file named utils.js
and paste the following snippets that we
can call every time we want to do a repetitive task.
Getting Dependenciesβ
Inputs and outputs are an array of transferable input and transferable output. These contain transfer inputs and associated assetID which is being transferred. There are different types of transfer inputs/outputs for sending assets, minting assets, minting NFTs, etc.
We will be using SECPTransferInput/SECPTransferOutput
for sending our assets.
But since we can't use UTXOs on C-Chain, we cannot directly import them either.
Therefore we need to create a different type of input/output for them called
EVMInput/EVMOutput
.
const { BN, chainIDs, web3 } = require("./importAPI");
let SECPTransferInput,
TransferableInput,
SECPTransferOutput,
TransferableOutput,
EVMInput,
EVMOutput;
const getTransferClass = (chainID) => {
let vm = "";
if (chainID.compare(chainIDs.x) == 0) {
vm = "avm";
} else if (chainID.compare(chainIDs.p) == 0) {
vm = "platformvm";
} else if (chainID.compare(chainIDs.c) == 0) {
vm = "evm";
}
return ({
SECPTransferInput,
TransferableInput,
SECPTransferOutput,
TransferableOutput,
EVMInput,
EVMOutput,
index,
} = require(`avalanche/dist/apis/${vm}/index`));
};
Different chains have their own implementation of TransferInput/Output classes.
Therefore we need to update the required modules according to the chain we
issuing transactions on. To make it more modular, we created a
getTransferClass()
function, that will take chainID
and import modules as
required.
Creating Transferable Outputβ
The createOutput()
function will create and return the transferable output
according to arguments amount, assetID, owner addresses, lock time, and
threshold. Lock time represents the timestamp after which this output could be
spent. Mostly this parameter will be 0.
const createOutput = (amount, assetID, addresses, locktime, threshold) => {
let transferOutput = new SECPTransferOutput(
amount,
addresses,
locktime,
threshold
);
return new TransferableOutput(assetID, transferOutput);
};
Creating Transferable Inputβ
The createInput()
function will create and return transferable input. Input
require arguments like amount in the UTXO, and arguments which identify that
UTXO, like txID of the transaction which the UTXO was the output of, outputIndex
(index of the output in that TX), and qualified signatures (output spenders
which are present in our keychain) whose signature will be required while
signing this transaction.
const createInput = (
amount,
txID,
outputIndex,
assetID,
spenders,
threshold
) => {
// creating transfer input
let transferInput = new SECPTransferInput(amount);
// adding threshold signatures
addSignatureIndexes(spenders, threshold, transferInput);
// creating transferable input
return new TransferableInput(txID, outputIndex, assetID, transferInput);
};
Add Signature Indexesβ
The createSignatureIndexes()
function will add spender addresses along with an
index for each address in the transfer input. While signing the unsigned
transaction, these signature indexes will be used.
By adding signature indexes we are not signing the inputs but just adding a
placeholder of the address at a particular index whose signature is required
when we call the .sign()
function on the unsigned transactions. Once the
threshold spender addresses are added, it will exit.
const addSignatureIndexes = (addresses, threshold, input) => {
let sigIndex = 0;
addresses.every((address) => {
if (threshold > 0) {
input.addSignatureIdx(sigIndex, address);
sigIndex++;
threshold--;
return true;
} else {
return false;
}
});
};