Skip to Content
Supra Layer 1SupraMVMTypeScript SDKGuidesSign and Submit Transactions

Sign and Submit Transactions

How to sign a transaction with a key the SDK does not hold, how to submit sponsored and multi-agent transactions, and how the SDK assigns sequence numbers.

TaskSDK function
Get the bytes to signsupra.transaction.getTransactionSignatureMessage({ rawTxn })
Sign with an SDK accountsupra.transaction.signTransaction({ senderAccount, rawTxn })
Sign and submit a serialized transactionsupra.transaction.submit.submitSerializedRawTransaction({ senderAccount, ... })
Submit with a signature made elsewheresupra.transaction.submit.submitSerializedRawTransactionAndSignature({ ... })
Submit a sponsored transactionsupra.transaction.submit.submitSponsorTransaction({ ... })
Submit a multi-agent transactionsupra.transaction.submit.submitMultiAgentTransaction({ ... })
Release the sequence numbers of an accountsupra.resetSequenceNumber(address)

The samples use Devnet and fund the accounts they create from the faucet. Use Network.TESTNET for Testnet.

mkdir supra-sign && cd supra-sign npm init -y npm install supra-ts-sdk@devnet npm install --save-dev tsx

Sign outside the SDK

submitRawTransaction and submitSerializedRawTransaction sign with the SupraAccount that the caller passes, so the SDK holds the private key while it signs. A wallet or a remote signer keeps the key to itself. The SDK then builds the transaction, the signer signs the bytes that getTransactionSignatureMessage returns, and submitSerializedRawTransactionAndSignature submits the BCS bytes of the transaction with the signer’s public key and signature. Only Ed25519 signatures are supported.

The message is the SHA3-256 hash of the domain separator SUPRA::RawTransaction followed by the BCS bytes of the transaction. For a fee payer or multi-agent transaction, the separator is SUPRA::RawTransactionWithData and the bytes are those of the FeePayerRawTransaction or MultiAgentRawTransaction.

The sample generates an Ed25519 key with Node’s crypto module. It stands in for the wallet: the SDK code below never reads the private key. A SupraAccount signs the same message and returns the same kind of signature:

const signature = await supra.transaction.signTransaction({ senderAccount, rawTxn })

Chain ID

Every transaction carries a chain ID. rawTxnObject puts the chain ID of the client’s configuration in it, and the node refuses a transaction with another chain ID.

signTransaction, submitRawTransaction and submitSerializedRawTransaction read the chain ID of the node before the client’s first signature and throw SupraChainIdMismatchError before they sign when the configured chain ID, or the one in the transaction, is not the node’s. getTransactionSignatureMessage and submitSerializedRawTransactionAndSignature sign nothing and make no check. A transaction built for another chain reaches the node, which refuses it: the submission fails with a SupraAPIError of status 400 that names BAD_CHAIN_ID, or, with enableTransactionSimulation: true, the simulation fails with SupraSimulationFailedError and nothing is submitted. To fail before the external signer is asked for a signature, call supra.validateChainId(), which throws SupraChainIdMismatchError when the node’s chain ID is not the configured one.

Create external_signing.ts

import { createHash, generateKeyPairSync, sign } from 'node:crypto' import { BCS, HexString, Network, SupraAccount, SupraClient } from 'supra-ts-sdk' // Stands in for a wallet or a remote signer: it holds the key and returns the public key and signatures. const { publicKey, privateKey } = generateKeyPairSync('ed25519') const publicKeyBytes = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) // The address of an Ed25519 account is the SHA3-256 hash of its public key followed by the scheme byte 0. const address = `0x${createHash('sha3-256') .update(Buffer.concat([publicKeyBytes, Buffer.from([0])])) .digest('hex')}` const signWithExternalKey = (message: Uint8Array) => new HexString(sign(null, message, privateKey).toString('hex')) async function main() { const supra = new SupraClient({ network: Network.DEVNET }) // Nothing below checks the chain ID, so check it once: this throws SupraChainIdMismatchError when the node's chain ID // is not the configured one. await supra.validateChainId() // Fund the account from the faucet. The account exists on the chain once the funding transaction has committed. const faucet = await supra.faucet.fundAccountWithFaucet({ accountAddress: address }) await supra.transaction.waitForTransaction({ transactionHash: faucet.hash }) const { sequence_number } = await supra.account.getAccountInfo({ accountAddress: address }) // Build the transaction. `rawTxnObject` makes no request. const receiver = new SupraAccount().address() const rawTxn = supra.transaction.build.rawTxnObject({ senderAddress: address, senderSequenceNumber: sequence_number, function: '0x1::supra_account::transfer', functionTypeArgs: [], functionArgs: [receiver.toUint8Array(), BCS.bcsSerializeUint64(1_000n)], optionalTransactionPayloadArgs: { maxGas: 5_000n, gasUnitPrice: await supra.getEstimatedGasUnitPrice() }, }) // The bytes to sign: the SHA3-256 hash of the domain separator `SUPRA::RawTransaction`, then the BCS bytes of the // transaction. The external signer signs them as they are. const message = supra.transaction.getTransactionSignatureMessage({ rawTxn }) const signature = signWithExternalKey(message) const transaction = await supra.transaction.submit.submitSerializedRawTransactionAndSignature({ senderPubkey: new HexString(publicKeyBytes.toString('hex')), signature, serializedRawTransaction: BCS.bcsToBytes(rawTxn), enableTransactionWaitAndSimulationArgs: { enableTransactionSimulation: true, enableWaitForTransaction: true }, }) console.log(transaction.status, transaction.hash) } main()
npx tsx external_signing.ts

The script prints Success and the transaction hash. rawTxnObject makes no request, so it needs the sender’s sequence number from the caller and a gas unit price, here the node’s estimate. Without maxGas it uses 20,000 units; see Call a Function and Read Events. A node that refuses the submission, or a submission whose outcome is unknown, is reported as for the other submit methods; see Transaction hashes and unknown outcomes.

In a sponsored transaction the fee payer pays the gas, and the sender signs the transaction. Both sign a FeePayerRawTransaction that wraps the raw transaction, the list of secondary signer addresses (empty here) and the fee payer’s address. signTransaction returns an AccountAuthenticatorEd25519 for a FeePayerRawTransaction, and submitSponsorTransaction signs nothing: it takes the raw transaction, the addresses and the authenticators. Both signTransaction calls check the chain ID as described above.

Create sponsored_transaction.ts

The sender transfers 1,000 quants and the fee payer pays the gas. The script prints what each of them paid.

import { BCS, Network, SupraAccount, SupraClient, TxnBuilderTypes } from 'supra-ts-sdk' async function main() { const supra = new SupraClient({ network: Network.DEVNET }) const sender = new SupraAccount() const feePayer = new SupraAccount() const receiver = new SupraAccount().address() // Both accounts get SUPRA from the faucet. for (const account of [sender, feePayer]) { const faucet = await supra.faucet.fundAccountWithFaucet({ accountAddress: account.address() }) await supra.transaction.waitForTransaction({ transactionHash: faucet.hash }) } // The transaction is the sender's: its sender, sequence number and payload are the sender's. const { sequence_number } = await supra.account.getAccountInfo({ accountAddress: sender.address() }) const rawTxn = supra.transaction.build.rawTxnObject({ senderAddress: sender.address(), senderSequenceNumber: sequence_number, function: '0x1::supra_account::transfer', functionTypeArgs: [], functionArgs: [receiver.toUint8Array(), BCS.bcsSerializeUint64(1_000n)], optionalTransactionPayloadArgs: { maxGas: 5_000n, gasUnitPrice: await supra.getEstimatedGasUnitPrice() }, }) // The sender and the fee payer sign the same fee payer transaction. const feePayerTxn = new TxnBuilderTypes.FeePayerRawTransaction( rawTxn, [], new TxnBuilderTypes.AccountAddress(feePayer.address().toUint8Array()), ) const senderAuthenticator = await supra.transaction.signTransaction({ senderAccount: sender, rawTxn: feePayerTxn }) const feePayerAuthenticator = await supra.transaction.signTransaction({ senderAccount: feePayer, rawTxn: feePayerTxn, }) const before = { sender: await supra.account.getAccountSupraCoinBalance({ accountAddress: sender.address() }), feePayer: await supra.account.getAccountSupraCoinBalance({ accountAddress: feePayer.address() }), } const transaction = await supra.transaction.submit.submitSponsorTransaction({ feePayerAddress: feePayer.address().toString(), secondarySignersAccountAddress: [], rawTxn, senderAuthenticator, feePayerAuthenticator, secondarySignersAuthenticator: [], enableTransactionWaitAndSimulationArgs: { enableTransactionSimulation: true, enableWaitForTransaction: true }, }) console.log(transaction.status, transaction.hash) // The sender's balance falls by the transferred amount; the fee payer's falls by the gas. const after = { sender: await supra.account.getAccountSupraCoinBalance({ accountAddress: sender.address() }), feePayer: await supra.account.getAccountSupraCoinBalance({ accountAddress: feePayer.address() }), } console.log('sender paid', before.sender - after.sender, 'quants') console.log('fee payer paid', before.feePayer - after.feePayer, 'quants') } main()
npx tsx sponsored_transaction.ts

The script prints Success, the hash, sender paid 1000n quants and the gas the fee payer paid.

A sender without an account

A sender that has no Account resource sends its first transaction with sequence number 0. isAccountExists returns false for it on every node, and getAccountInfo throws for it (SupraNotFoundError on a v12 node, a SupraAPIError of status 400 on a v11 node), so check first and use 0. The simulation with that number succeeds on every node.

A sender without an account needs a node from supra_node_v12.0.0, which Devnet runs. A v11.7 node (Testnet and Mainnet today) refuses the submission with HTTP 400 and the message “Transaction validation failed: Failed to read the AccountResource”. The SDK exempts this behaviour from semantic versioning until supra_node_v12.0.0 reaches Testnet.

The transaction creates the sender’s account, and the sender’s sequence number is 1 afterwards. The fee payer pays the gas, but a new sender holds no SUPRA, so the payload must not spend from the sender’s balance: a transfer from it aborts. 0x1::supra_account::create_account spends nothing.

Create sponsored_new_sender.ts

import { Network, SupraAccount, SupraClient, TxnBuilderTypes } from 'supra-ts-sdk' async function main() { const supra = new SupraClient({ network: Network.DEVNET }) const sender = new SupraAccount() // has no account on the chain const feePayer = new SupraAccount() const faucet = await supra.faucet.fundAccountWithFaucet({ accountAddress: feePayer.address() }) await supra.transaction.waitForTransaction({ transactionHash: faucet.hash }) // An account that does not exist yet has sequence number 0. `getAccountInfo` throws for it. const accountAddress = sender.address() const senderSequenceNumber = (await supra.account.isAccountExists({ accountAddress })) ? (await supra.account.getAccountInfo({ accountAddress })).sequence_number : 0n // `create_account` spends nothing from the sender, which holds no SUPRA. const rawTxn = supra.transaction.build.rawTxnObject({ senderAddress: accountAddress, senderSequenceNumber, function: '0x1::supra_account::create_account', functionTypeArgs: [], functionArgs: [new SupraAccount().address().toUint8Array()], optionalTransactionPayloadArgs: { maxGas: 5_000n, gasUnitPrice: await supra.getEstimatedGasUnitPrice() }, }) const feePayerTxn = new TxnBuilderTypes.FeePayerRawTransaction( rawTxn, [], new TxnBuilderTypes.AccountAddress(feePayer.address().toUint8Array()), ) const senderAuthenticator = await supra.transaction.signTransaction({ senderAccount: sender, rawTxn: feePayerTxn }) const feePayerAuthenticator = await supra.transaction.signTransaction({ senderAccount: feePayer, rawTxn: feePayerTxn, }) const transaction = await supra.transaction.submit.submitSponsorTransaction({ feePayerAddress: feePayer.address().toString(), secondarySignersAccountAddress: [], rawTxn, senderAuthenticator, feePayerAuthenticator, secondarySignersAuthenticator: [], enableTransactionWaitAndSimulationArgs: { enableTransactionSimulation: true, enableWaitForTransaction: true }, }) console.log(transaction.status, transaction.hash) // The transaction created the sender's account, and its sequence number is now 1. const info = await supra.account.getAccountInfo({ accountAddress }) console.log('sequence number', info.sequence_number) } main()
npx tsx sponsored_new_sender.ts

On Devnet the script prints Success, the hash and sequence number 1n.

Multi-agent transactions

A multi-agent transaction has one sender and one or more secondary signers. Its entry function takes a &signer parameter for each of them, the sender’s first. The sender and every secondary signer sign a MultiAgentRawTransaction that wraps the raw transaction and the addresses of the secondary signers. submitMultiAgentTransaction takes the secondary signers’ addresses and their authenticators in the same order, which is the order of the &signer parameters after the first. The entry function’s other arguments go in functionArgs of rawTxnObject; the signers are not among them.

The sample calls pair, an entry function that takes two signers. Save this module as sources/pairing.move in a package named pairing, give the named address pairing_addr the address of the account that publishes it, and publish it as in Publish a Package:

module pairing_addr::pairing { use std::signer; struct Pair has key { partner: address, } /// Records each signer as the partner of the other. Both signers must sign the transaction. public entry fun pair(first: &signer, second: &signer) { move_to(first, Pair { partner: signer::address_of(second) }); move_to(second, Pair { partner: signer::address_of(first) }); } #[view] public fun partner(addr: address): address acquires Pair { borrow_global<Pair>(addr).partner } }

Create multi_agent.ts

The script reads the address that the module is published at from MODULE_ADDRESS.

import { Network, SupraAccount, SupraClient, TxnBuilderTypes } from 'supra-ts-sdk' async function main() { const supra = new SupraClient({ network: Network.DEVNET }) // The address that the `pairing` module is published at. const moduleAddress = process.env.MODULE_ADDRESS! const first = new SupraAccount() // the sender const second = new SupraAccount() // the secondary signer for (const account of [first, second]) { const faucet = await supra.faucet.fundAccountWithFaucet({ accountAddress: account.address() }) await supra.transaction.waitForTransaction({ transactionHash: faucet.hash }) } // `pair(first: &signer, second: &signer)` takes no arguments besides its two signers. const { sequence_number } = await supra.account.getAccountInfo({ accountAddress: first.address() }) const rawTxn = supra.transaction.build.rawTxnObject({ senderAddress: first.address(), senderSequenceNumber: sequence_number, function: `${moduleAddress}::pairing::pair`, functionTypeArgs: [], functionArgs: [], optionalTransactionPayloadArgs: { maxGas: 5_000n, gasUnitPrice: await supra.getEstimatedGasUnitPrice() }, }) // The secondary signers are listed in the order of the signer parameters after the sender's. const multiAgentTxn = new TxnBuilderTypes.MultiAgentRawTransaction(rawTxn, [ new TxnBuilderTypes.AccountAddress(second.address().toUint8Array()), ]) const senderAuthenticator = await supra.transaction.signTransaction({ senderAccount: first, rawTxn: multiAgentTxn }) const secondAuthenticator = await supra.transaction.signTransaction({ senderAccount: second, rawTxn: multiAgentTxn }) const transaction = await supra.transaction.submit.submitMultiAgentTransaction({ secondarySignersAccountAddress: [second.address().toString()], rawTxn, senderAuthenticator, secondarySignersAuthenticator: [secondAuthenticator], enableTransactionWaitAndSimulationArgs: { enableTransactionSimulation: true, enableWaitForTransaction: true }, }) console.log(transaction.status, transaction.hash) // Each account now holds a `Pair` that names the other. const [partnerOfFirst] = await supra.methods.view({ function: `${moduleAddress}::pairing::partner`, typeArguments: [], functionArguments: [first.address().toString()], }) console.log(partnerOfFirst === second.address().toString() ? 'paired' : 'not paired') } main()
MODULE_ADDRESS=<MODULE_ADDRESS> npx tsx multi_agent.ts

The script prints Success, the hash and paired.

Sequence numbers

A transaction carries the sender’s sequence number, and the node executes it only when its number is the account’s next number. The builders of supra.transaction.build take the number from the caller, as senderSequenceNumber. The helpers that build a transaction themselves take it from a counter that the SupraClient keeps for each account: transferSupraCoin, transferCoin, transferSupraFungibleAsset, transferFungibleAsset, migrateCoinToFungibleStore, publishPackage, cancelAutomationTask, stopAutomationTasks, the multisig helpers and the entry functions of Contract.fromABI. Passing sequenceNumber in optionalTransactionPayloadArgs makes a helper use that number as given.

The counter records each transaction that the client submits, with its expiration time and hash:

  • The number handed out is the lowest number at or above the account’s number on the chain that has no record. Transactions sent without waiting for the previous one to commit get consecutive numbers, also when they are sent concurrently with Promise.all.
  • Calls from one account on one client are submitted one after another. A call without maxGas holds the account while it estimates the max gas. To submit faster from one account, set maxGas; to submit in parallel, send from several accounts.
  • A record is dropped when the account’s number on the chain passes it, when the latest block’s timestamp has reached its expiration time, or when a read of the transaction returns Invalid. The next call then reads the account’s number from the chain.
  • A transaction that fails before the node accepts it, for example because of an insufficient balance, leaves no record, and the next call uses its number.
  • When the node refuses a number as stale, or because a pending transaction already holds it (for example one sent by another client), the helper sends the transaction once more with the next number. A transaction with an explicit sequenceNumber is not sent again.
  • A node accepts a number ahead of the account’s next one and holds the transaction until the numbers before it have been sent. It holds at most 25 numbers per account by default and refuses a number beyond them with SupraAccountQueueFullError, so wait for commits (enableWaitForTransaction: true) when sending more than about 25 transactions from one account.

After an unknown outcome

When the submission request times out, fails before the node answers, or is answered with a 5xx status other than 503, the node may have accepted the transaction. The call throws SupraSubmissionOutcomeUnknownError; see Transaction hashes and unknown outcomes for its fields and for how the call waits for the hash. The client keeps the transaction’s record, so its sequence number is held until the transaction’s expiration time passes or supra.resetSequenceNumber(address) is called.

If the node did not accept the transaction, the transactions that follow it wait in the node behind its number until the chain’s time reaches its expiration time, which is txExpirySecs after the client’s clock, 60 seconds by default. The next call after that takes the number, and the waiting transactions commit if they have not expired themselves.

resetSequenceNumber forgets the counter and the records of an account. The next call takes the account’s number on the chain, and while a pending transaction of this client holds it, the node refuses the call. Call it only when no transaction of this client from the account is pending, or when the expiration times of those that are have passed. Transactions that another client, a process or a wallet sends from the same account need no reset: the refusal of a stale number makes the next call read the chain.

Create sequence_numbers.ts

The script sends five transfers at once and prints their sequence numbers, then sends one more with the recovery code for an unknown outcome.

import { Network, SupraAccount, SupraClient, SupraSubmissionOutcomeUnknownError } from 'supra-ts-sdk' async function main() { const supra = new SupraClient({ network: Network.DEVNET }) const sender = new SupraAccount() const receiver = new SupraAccount().address() const faucet = await supra.faucet.fundAccountWithFaucet({ accountAddress: sender.address() }) await supra.transaction.waitForTransaction({ transactionHash: faucet.hash }) const send = () => supra.coin.transferSupraCoin({ senderAccount: sender, receiverAccountAddress: receiver, amount: 1_000n, optionalTransactionArgs: { enableTransactionWaitAndSimulationArgs: { enableWaitForTransaction: true } }, }) // Five transfers from one account, sent without waiting for each other. The client gives them consecutive numbers. const transfers = await Promise.all(Array.from({ length: 5 }, send)) console.log(transfers.map((transfer) => ('header' in transfer ? transfer.header.sequence_number : undefined))) // A submission whose outcome is unknown: the request failed or timed out, and the node may have accepted it. try { await send() } catch (error) { if (!(error instanceof SupraSubmissionOutcomeUnknownError)) { throw error } console.log(`Outcome unknown: ${error.hash}, sequence number ${error.sequenceNumber}`) console.log(`It can commit until ${new Date(Number(error.expirationTimestampSecs) * 1000).toISOString()}`) // The client holds that sequence number until the transaction expires. Read the transaction by its hash // before sending anything for the same purpose again. const transaction = await supra.transaction .getTransactionByHash({ transactionHash: error.hash }) .catch(() => undefined) console.log(transaction?.status ?? 'The node has not seen the transaction') } } main()
npx tsx sequence_numbers.ts

For a new account the first line is [ 0, 1, 2, 3, 4 ]. The second part prints only when a submission has an unknown outcome; a script that is not interrupted by a network failure ends after the first line.

Last updated on