Smart Accounts#
A smart account is an account with an account script. Normally a node accepts a transaction from an account when proof 0 is a valid signature by the account’s key. Once the account has a script, the node runs the script instead, and the script alone decides. That turns an account into a set of rules: several people must agree, money may only go to certain addresses, or funds unlock at a given height.
Note
Verified against node-scala v1.8.0 on 2026-10-25. Both scripts compile on the public Testnet node (DecentralChain v1.8.0-dev-e6248f2408) and the Mainnet node (DCC v1.3.5), and the fees were confirmed with the Testnet node’s POST /transactions/calculateFee. The TypeScript is typechecked against the published SDK; it has not been broadcast as part of this documentation pass.
How Verification Works#
The script replaces the signature check; it does not add to it. The node no longer checks any proof by itself. If the rule needs a signature, the script must call
sigVerifyitself. A script that just returnstruelets anyone spend from the account.It runs for everything the account sends. That means every transaction, including the Set Script that would change or remove the script, and every order placed on the DEX. It does not run when the account receives something.
It sees the whole transaction. The built-in
txholds the transaction being checked, with all its fields,bodyBytes(the signed bytes) andproofs. The script can also read chain state:height, balances, and data entries of any account.thisis the account’s own address.There are up to 8 proofs. In Ride,
tx.proofsalways has 8 elements. Proofs the sender left out are empty, andsigVerifyreturnsfalsefor an empty proof, so a script can test any position without failing.A complex script costs a fee. If the script’s complexity is above 200, every transaction from the account costs 0.004 DCC more. Both scripts on this page are above that line (the multisig is 609, the escrow 768), so their transactions pay 0.001 + 0.004 = 0.005 DCC.
Removing the script is a transaction like any other. Send Set Script with
script: null. The current script must approve it.
The language side is in Account Script.
npm install @decentralchain/transactions@5.0.1
The examples import compileRide from Compile a Script and read their scripts from a ride folder next to them. The public keys and addresses in the examples belong to throwaway example accounts. Deployed unchanged, a script would hand control to whoever holds those accounts’ seeds, so always replace them with your own.
Walkthrough: a 2-of-3 Multisig Account#
Three owners share one account. Any two of them together can send a transaction from it; one alone cannot.
Save as ride/multisig-2-of-3.ride, with your owners’ public keys in place of the example ones:
{-# STDLIB_VERSION 5 #-}
{-# CONTENT_TYPE EXPRESSION #-}
{-# SCRIPT_TYPE ACCOUNT #-}
# Example keys. Replace them with your three owners' own public keys.
# Owner A signs into proof 0, B into proof 1, C into proof 2.
let ownerA = base58'Ch79CxM7xSWXsQ6o1MughNW7ELHaMwJDqV8FDTZMrosq'
let ownerB = base58'8DAes8dPJqgRZ8J3YgXh2ppgTxQea9VcccRJfB71GZct'
let ownerC = base58'7g2vpXFYV75q2QWuQipD5VwbUdkJuqyi92U9nqbumdLV'
func approvals(proofIndex: Int, owner: ByteVector) =
if (sigVerify(tx.bodyBytes, tx.proofs[proofIndex], owner)) then 1 else 0
approvals(0, ownerA) + approvals(1, ownerB) + approvals(2, ownerC) >= 2
Each owner has a fixed proof position. Fixed positions keep the script cheap, and they stop one owner from counting twice by putting the same signature in two slots.
The script has no match, so it applies to every kind of transaction. Changing the owners later is itself a Set Script transaction, so it also needs two approvals.
import { readFile } from 'node:fs/promises';
import { setScript, transfer, broadcast, waitForTx, libs } from '@decentralchain/transactions';
import { compileRide } from './compile-ride.js';
const nodeUrl = 'https://testnet-node.decentralchain.io';
const sharedSeed = 'shared account secret seed phrase here';
const ownerASeed = 'owner A secret seed phrase here';
const ownerCSeed = 'owner C secret seed phrase here';
const sharedPublicKey = libs.crypto.publicKey(sharedSeed);
// Once: install the script. After this, the shared account's own seed can no longer sign for it.
const source = await readFile(new URL('./ride/multisig-2-of-3.ride', import.meta.url), 'utf8');
const install = setScript({ script: await compileRide(source, nodeUrl), chainId: '!' }, sharedSeed);
await broadcast(install, nodeUrl);
await waitForTx(install.id, { apiBase: nodeUrl, timeout: 120_000 });
// Owner A builds the transfer and signs it into proof 0.
const proposal = transfer(
{
recipient: '31LAqgqVTLySNCPuYqgLAYhcgS76wvBaLr9',
amount: 100_000_000, // 1 DCC
senderPublicKey: sharedPublicKey, // the account that sends and pays, not the signer
additionalFee: 400_000, // the script's complexity is above 200
chainId: '!',
},
[ownerASeed],
);
// Owner C receives the proposal (for example as JSON), checks it and adds a signature to proof 2.
const approved = transfer(proposal, [null, null, ownerCSeed]);
await broadcast(approved, nodeUrl);
Points worth knowing:
senderPublicKeynames the shared account. That account sends the transaction and pays its fee. The seeds passed as the second argument only add proofs.A seed array maps to proof positions.
[ownerASeed]fills proof 0, and[null, null, ownerCSeed]fills proof 2 without touching the others. Passing an already signed transaction back to the builder keeps its existing proofs, timestamp and fee.Owners sign the same bytes. The transaction’s ID depends only on its body, not on its proofs. Every owner can check that they are approving the same transaction by comparing IDs before signing.
There is a time limit. A node rejects a transaction whose timestamp is more than 120 minutes older than the last block, so collect the signatures within about two hours of creating the transaction.
The fee here is 0.005 DCC: 0.001 for the transfer and 0.004 for the script.
Walkthrough: an Escrow Account#
A buyer pays for goods in advance, but the seller should only get the money once the goods arrive. The buyer moves the payment into a fresh escrow account and locks that account with this script. From then on, the funds can leave exactly once, in one piece, and only with the right signature:
Funds go to |
Allowed if signed by |
|---|---|
Seller |
the buyer (goods received) or the arbiter |
Buyer |
the seller (refund) or the arbiter, or the buyer alone once |
Anywhere else, or any other transaction |
never |
Save as ride/escrow.ride, with the real parties’ public keys in place of the example ones:
{-# STDLIB_VERSION 5 #-}
{-# CONTENT_TYPE EXPRESSION #-}
{-# SCRIPT_TYPE ACCOUNT #-}
# Example keys. Replace them with the buyer's, the seller's and the arbiter's own public keys.
let buyer = base58'CPT5HpPYQ1rA8Eot2swk8tuXb4PE5XDhRbQ7Gpp3sr4D'
let seller = base58'Emx5RBMowyZLqqn3hX8gGhg4Lgd2UktvDb7UHvHTDjts'
let arbiter = base58'FzQmZTezonLeREVNQdbBAx5i9cUHqevug78VUgYWjSMg'
# Written to the escrow account's data storage before this script is set:
# the escrowed amount, and the height from which the buyer can take it back alone.
let amount = getIntegerValue(this, "amount")
let timeoutHeight = getIntegerValue(this, "timeoutHeight")
# 0.001 DCC for the transfer plus 0.004 DCC for this script.
let payoutFee = 500000
func signedBy(key: ByteVector) = sigVerify(tx.bodyBytes, tx.proofs[0], key)
match tx {
case t: TransferTransaction =>
let to = addressFromRecipient(t.recipient)
# The only valid payout moves exactly the escrowed DCC with the standard fee, so the
# funds can leave once, in one piece, and nothing leaks out through fees.
let validPayout = !isDefined(t.assetId) && !isDefined(t.feeAssetId) && t.amount == amount && t.fee == payoutFee
if (!validPayout) then false
else if (to == addressFromPublicKey(seller)) then signedBy(buyer) || signedBy(arbiter)
else if (to == addressFromPublicKey(buyer)) then
signedBy(seller) || signedBy(arbiter) || (signedBy(buyer) && height >= timeoutHeight)
else false
case _ => false
}
This script inspects the transaction, not only its signatures:
match txpicks out Transfer transactions. Every other transaction type falls intocase _ => false, so the escrow account can never get a new script, lease its balance or write data.addressFromRecipientresolves the recipient, which may be an alias, to an address.validPayoutonly accepts a transfer of exactly the escrowedamountin DCC, with exactly the 0.005 DCC fee paid in DCC. Without it, a party allowed to sign a payout could send the funds away as a huge fee, or in many small pieces that each burn a fee. The escrow holds only enough for one payout, so the first valid one empties it.amountandtimeoutHeightcome from the account’s own data storage. They are written before the script is set, so they can be computed at that moment, and nobody can change them afterwards.
The buyer sets up the escrow, and later releases the funds once the goods arrive:
import { readFile } from 'node:fs/promises';
import { data, setScript, transfer, broadcast, waitForTx, libs, nodeInteraction } from '@decentralchain/transactions';
import { compileRide } from './compile-ride.js';
const nodeUrl = 'https://testnet-node.decentralchain.io';
const escrowSeed = 'escrow account secret seed phrase here';
const buyerSeed = 'buyer secret seed phrase here';
const sellerAddress = '31VgE1dHbGoMeesqWFVr6GoSdB7zX4wS1w8'; // example address: use the seller's own
const amount = 100_000_000; // 1 DCC
// The buyer funds the escrow account with the amount plus 0.008 DCC for its three transactions:
// 0.001 for the settings, 0.002 for the script (1,043 bytes) and 0.005 for the payout.
// 1. Write the settings while the escrow account still signs normally.
const timeoutHeight = (await nodeInteraction.currentHeight(nodeUrl)) + 20_160; // about a week of 30-second blocks
const settings = data(
{
data: [
{ key: 'amount', type: 'integer', value: amount },
{ key: 'timeoutHeight', type: 'integer', value: timeoutHeight },
],
chainId: '!',
},
escrowSeed,
);
await broadcast(settings, nodeUrl);
await waitForTx(settings.id, { apiBase: nodeUrl, timeout: 120_000 });
// 2. Lock the account with the escrow script.
const source = await readFile(new URL('./ride/escrow.ride', import.meta.url), 'utf8');
const lock = setScript({ script: await compileRide(source, nodeUrl), chainId: '!' }, escrowSeed);
await broadcast(lock, nodeUrl);
await waitForTx(lock.id, { apiBase: nodeUrl, timeout: 120_000 });
// 3. The goods arrived: the buyer releases the funds to the seller.
const release = transfer(
{
recipient: sellerAddress,
amount, // must equal the stored amount
fee: 500_000, // must equal the script's payoutFee: 0.001 + 0.004 DCC
senderPublicKey: libs.crypto.publicKey(escrowSeed),
chainId: '!',
},
buyerSeed,
);
await broadcast(release, nodeUrl);
Things to settle before locking the account:
The escrow account pays the fees. Fund it with the amount plus 0.008 DCC: 0.001 for the settings, 0.002 for the Set Script (the script is 1,043 bytes) and 0.005 for the payout. If it holds less, no payout can succeed until someone sends it the difference.
Pick a timeout that suits the deal. The sample sets
timeoutHeightabout a week (20,160 blocks of 30 seconds) after the current height.Everyone must agree on the script before the funds move. The seller and the arbiter should decompile the deployed script (
GET /addresses/scriptInfo/{address}, thenPOST /utils/script/decompile) and read its data (GET /addresses/data/{address}) before shipping anything.Use a fresh account. Throw away its seed after the Set Script transaction. The seed has no power once the script is set, but keeping it around invites confusion.
Inspecting a Smart Account#
GET /addresses/scriptInfo/{address} returns the account’s compiled script, its complexity, its verifierComplexity and the extraFee its transactions must add. If the account has no script, script is null.
When a transaction is rejected, broadcast() rejects with the node’s message. It reads Transaction is not allowed by account-script when the script returned false, and it includes the script’s error when the script failed.