Documentation · Tutorial
Your first signed transaction
Build one signed transaction with two transfers. Encode it, decode it, then change an amount and watch signature verification fail.
You need: a terminal, Node.js 22, npm, and basic JavaScript. Installation uses the network; the exercise runs offline with temporary keys. No wallet, funds or node is required.
Your result: two instructions, 264 wire bytes, a valid signed round-trip, and rejection of the modified message. Nothing is submitted.
1. Install the pinned SDK
Tested with Node 22.22.2, npm 9.2.0, and @0xsingularity/web3.js@0.2.0. Keep this lesson pinned to that package; release differences are tracked separately. Official package metadata.
In a new directory:
mkdir singularity-first-transaction
cd singularity-first-transaction
npm init -y
npm install --ignore-scripts --no-audit --no-fund --save-exact @0xsingularity/web3.js@0.2.0Keep the generated lockfile to preserve the resolved dependencies. The package supplies Solana transaction types plus Singularity amount and serialization helpers. SDK exports.
2. Create the two-instruction message
Save this as first-transaction.cjs:
const assert = require('node:assert/strict');
const sdk = require('@0xsingularity/web3.js');
const {
Keypair, Transaction, SystemProgram, serializeUnchecked, singToLamports,
} = sdk;
const sender = Keypair.generate();
const recipientA = Keypair.generate().publicKey;
const recipientB = Keypair.generate().publicKey;
// Fixed dummy blockhash makes the example wholly offline. Never submit it.
const transaction = new Transaction({
feePayer: sender.publicKey,
recentBlockhash: '11111111111111111111111111111111',
}).add(
SystemProgram.transfer({
fromPubkey: sender.publicKey,
toPubkey: recipientA,
lamports: singToLamports(0.001),
}),
SystemProgram.transfer({
fromPubkey: sender.publicKey,
toPubkey: recipientB,
lamports: singToLamports(0.002),
}),
);
transaction.sign(sender);
assert.equal(transaction.verifySignatures(), true);
const bytes = serializeUnchecked(transaction);
const decoded = Transaction.from(bytes);
assert.equal(decoded.instructions.length, 2);
assert.equal(decoded.verifySignatures(), true);
// Change one byte in the first transfer amount, retaining the old signature.
const tampered = Transaction.from(bytes);
tampered.instructions[0].data[4] ^= 1;
assert.equal(tampered.verifySignatures(), false);
console.log(JSON.stringify({
sdkVersion: require('@0xsingularity/web3.js/package.json').version,
instructionCount: decoded.instructions.length,
wireBytes: bytes.length,
signedRoundTrip: true,
modifiedAmountRejected: true,
submitted: false,
}, null, 2));3. Run it and check the result
From the same directory, run:
node first-transaction.cjsThe fresh-directory run on 8 September 2026 at 20:17 UTC exited 0 and produced:
{
"sdkVersion": "0.2.0",
"instructionCount": 2,
"wireBytes": 264,
"signedRoundTrip": true,
"modifiedAmountRejected": true,
"submitted": false
}The keys stay in memory. The all-ones blockhash is an offline fixture: do not submit this transaction to a node.
| If you see | Do this |
|---|---|
| Module not found | Run the install command in this directory, then retry. |
| An assertion failure | Compare your saved file with the example. |
A punycode deprecation warning | Check the assertions and JSON result. The tested run passed with this dependency warning. |
4. Try authorizing the changed message
The original signature fails after the amount changes. What happens if the sender signs that changed message?
Immediately after assert.equal(tampered.verifySignatures(), false);, add:
tampered.sign(sender);
assert.equal(tampered.verifySignatures(), true);
console.log('Modified message authorized by a new signature');Run the file again. The added line prints before the JSON report, and both assertions pass: the old signature failed; the new signature authorizes the changed message. This variation also passed in the tested environment above.
5. What you checked
The instructions encode 0.001 and 0.002 SING: 1,000,000 and 2,000,000 lamports. The sender signs the complete message, including both instructions and their account permissions.
| Checkpoint | Result |
|---|---|
| Sign, encode and decode | Both instructions retain a valid signature. |
| Change an amount | The original signature fails. |
| Sign the changed message | The new signature passes. |
serializeUnchecked is a wire serializer, not a signature verifier. It bypasses an upstream packet-size assertion and can serialize incomplete signatures; the explicit verifySignatures() assertions are therefore meaningful. Larger applications still face account-index, instruction-encoding, SDK packing and node transport limits. Serialization implementation, amount conversion.
This exercise checks client construction and authorization. Funded execution, fees, inclusion, cross-VM behavior and durability require a node or engine fixture. In execution, the outer transaction owns its program writes; Transaction lifecycle explains rollback and the fee/nonce effects that can remain after failure.
Continue from this result
| Next need | Continue here |
|---|---|
| Understand what happens after real submission | Transaction lifecycle, then RPC and SDK reference |
| Create a persistent wallet or inspect a saved signature | CLI and wallet setup, then Inspect a transaction |
| Understand shared execution and local rollback | System overview and cross-VM composition |
| Explore foreign evidence or financial execution | HILSA and Bombil |