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.
| Task | SDK function |
|---|---|
| Get the bytes to sign | supra.transaction.getTransactionSignatureMessage({ rawTxn }) |
| Sign with an SDK account | supra.transaction.signTransaction({ senderAccount, rawTxn }) |
| Sign and submit a serialized transaction | supra.transaction.submit.submitSerializedRawTransaction({ senderAccount, ... }) |
| Submit with a signature made elsewhere | supra.transaction.submit.submitSerializedRawTransactionAndSignature({ ... }) |
| Submit a sponsored transaction | supra.transaction.submit.submitSponsorTransaction({ ... }) |
| Submit a multi-agent transaction | supra.transaction.submit.submitMultiAgentTransaction({ ... }) |
| Release the sequence numbers of an account | supra.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 tsxSign 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.tsThe 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.
Sponsored transactions
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.tsThe 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.tsOn 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.tsThe 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
maxGasholds the account while it estimates the max gas. To submit faster from one account, setmaxGas; 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
sequenceNumberis 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.tsFor 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.