Skip to main content

Smart Contracts

MiniLedger smart contracts are plain JavaScript functions that run in a sandboxed environment on every node. They allow you to define custom business logic that reads and writes to the ledger's world state. Contracts are deployed as transactions and invoked through the REST API or programmatic API.

Contract Structure

A MiniLedger smart contract is a JavaScript module that returns an object containing one or more methods. Each method receives a context object (ctx) as its first argument, followed by any additional arguments passed during invocation.

return {
// A simple greeting contract
setGreeting(ctx, name) {
ctx.set('greeting', `Hello, ${name}!`);
ctx.log(`Greeting set by ${ctx.sender}`);
},

getGreeting(ctx) {
return ctx.get('greeting');
}
};

Key rules:

  • The contract code must end with a return { ... } statement containing the method map
  • Methods are synchronous -- no async/await or Promises
  • Each method has a 5-second execution timeout. If a method exceeds this limit, the transaction is rejected
  • Contracts execute deterministically: the same inputs must always produce the same outputs across all nodes

The Context API

Every contract method receives a ctx object that provides access to the world state and execution metadata.

State Operations

MethodDescriptionExample
ctx.get(key)Read a value from the world state. Returns undefined if the key does not exist.ctx.get('balance:alice')
ctx.set(key, value)Write a value to the world state. Creates the key if it does not exist. Values are JSON-serializable.ctx.set('balance:alice', 100)
ctx.del(key)Delete a key from the world state. No-op if the key does not exist.ctx.del('temp:session')

Execution Metadata

PropertyTypeDescription
ctx.senderstringThe node ID of the transaction submitter
ctx.blockHeightnumberThe current block height at execution time
ctx.timestampnumberThe block timestamp as a Unix epoch in milliseconds

Logging

MethodDescription
ctx.log(message)Write a message to the contract execution log. Useful for debugging and audit trails. Logged messages are stored with the transaction receipt.

Complete Context Example

return {
transfer(ctx, from, to, amount) {
// Validate input
if (amount <= 0) {
throw new Error('Amount must be positive');
}

// Read current balances
const fromBalance = ctx.get(`balance:${from}`) || 0;
const toBalance = ctx.get(`balance:${to}`) || 0;

// Check sufficient funds
if (fromBalance < amount) {
throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
}

// Update balances atomically
ctx.set(`balance:${from}`, fromBalance - amount);
ctx.set(`balance:${to}`, toBalance + amount);

// Log the transfer for audit
ctx.log(`Transfer: ${from} -> ${to}, amount=${amount}, block=${ctx.blockHeight}`);
}
};

Deploying a Contract

Contracts are deployed by submitting a transaction with type contract:deploy to the REST API.

Via REST API

curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:deploy",
"payload": {
"kind": "contract:deploy",
"name": "token",
"version": "1.0.0",
"code": "return { mint(ctx, account, amount) { const bal = ctx.get(`balance:${account}`) || 0; ctx.set(`balance:${account}`, bal + amount); ctx.log(`Minted ${amount} to ${account}`); }, balance(ctx, account) { return ctx.get(`balance:${account}`) || 0; } };"
}
}'

Deployment Fields

FieldTypeRequiredDescription
namestringYesA unique identifier for the contract
versionstringYesSemantic version string (e.g., "1.0.0")
codestringYesThe JavaScript contract source code

Response

{
"txId": "tx-abc123...",
"status": "committed",
"blockHeight": 5
}
tip

For contracts with complex code, read the source from a file:

CODE=$(cat ./contracts/token.js)
curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d "$(jq -n --arg code "$CODE" '{
type: "contract:deploy",
payload: {
kind: "contract:deploy",
name: "token",
version: "1.0.0",
code: $code
}
}')"

Invoking a Contract

Once deployed, invoke any method on a contract by submitting a transaction with type contract:invoke.

Via REST API

curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:invoke",
"payload": {
"kind": "contract:invoke",
"contract": "token",
"method": "mint",
"args": ["alice", 1000]
}
}'

Invocation Fields

FieldTypeRequiredDescription
contractstringYesThe name of the deployed contract
methodstringYesThe method to call on the contract
argsarrayNoArguments to pass to the method (default: [])

Reading Return Values

If a contract method returns a value, it is included in the transaction receipt:

curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:invoke",
"payload": {
"kind": "contract:invoke",
"contract": "token",
"method": "balance",
"args": ["alice"]
}
}'
{
"txId": "tx-def456...",
"status": "committed",
"result": 1000,
"blockHeight": 7
}

Built-in Contracts

MiniLedger ships with two built-in contracts that are available without deployment.

transfer

A simple value transfer contract for moving numeric balances between accounts.

# Mint initial balance
curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:invoke",
"payload": {
"kind": "contract:invoke",
"contract": "transfer",
"method": "mint",
"args": ["alice", 500]
}
}'

# Transfer between accounts
curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:invoke",
"payload": {
"kind": "contract:invoke",
"contract": "transfer",
"method": "send",
"args": ["alice", "bob", 200]
}
}'

Methods:

MethodArgumentsDescription
mint(ctx, account, amount)account name, amountCreate new balance for an account
send(ctx, from, to, amount)sender, receiver, amountTransfer balance between accounts
balance(ctx, account)account nameQuery current balance

kv-store

A general-purpose key-value store contract for arbitrary data.

curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:invoke",
"payload": {
"kind": "contract:invoke",
"contract": "kv-store",
"method": "put",
"args": ["config:app-name", "My dApp"]
}
}'

Methods:

MethodArgumentsDescription
put(ctx, key, value)key, valueStore a key-value pair
get(ctx, key)keyRetrieve a value by key
del(ctx, key)keyDelete a key-value pair

Error Handling

When a contract method throws an error, the transaction is rejected and no state changes are applied. The error message is returned in the transaction receipt.

Throwing Errors in Contracts

return {
withdraw(ctx, account, amount) {
const balance = ctx.get(`balance:${account}`) || 0;

if (amount <= 0) {
throw new Error('Amount must be a positive number');
}

if (balance < amount) {
throw new Error(`Insufficient funds: have ${balance}, need ${amount}`);
}

ctx.set(`balance:${account}`, balance - amount);
}
};

Error Response

{
"txId": "tx-err789...",
"status": "rejected",
"error": "Insufficient funds: have 100, need 500",
"blockHeight": 12
}

Common Error Scenarios

ScenarioCauseResolution
Contract not foundThe contract name does not match any deployed contractVerify the contract name and check it was deployed
Method not foundThe method does not exist on the contractCheck the contract source for available methods
Execution timeoutMethod exceeded the 5-second time limitOptimize the contract logic or break it into smaller operations
Runtime errorUnhandled exception in contract codeAdd proper validation and error handling

The 5-Second Execution Timeout

Every contract method invocation has a strict 5-second timeout. This prevents infinite loops and runaway computations from stalling the network. If a method does not complete within 5 seconds:

  1. Execution is forcibly terminated
  2. All state changes from the method are rolled back
  3. The transaction is marked as rejected with an Execution timeout error

Best practices to avoid timeouts:

  • Avoid unbounded loops -- always set an upper limit on iterations
  • Minimize the number of ctx.get() and ctx.set() calls per method
  • Move complex computations off-chain and submit only the results to the contract
  • Break large batch operations into multiple transactions

Contract Versioning and Upgrades

When you deploy a contract with the same name but a different version, the new version replaces the old one. The world state created by previous versions is preserved.

# Deploy v1
curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:deploy",
"payload": {
"kind": "contract:deploy",
"name": "token",
"version": "1.0.0",
"code": "return { balance(ctx, acct) { return ctx.get(`balance:${acct}`) || 0; } };"
}
}'

# Deploy v2 with additional methods
curl -X POST http://localhost:4441/tx \
-H "Content-Type: application/json" \
-d '{
"type": "contract:deploy",
"payload": {
"kind": "contract:deploy",
"name": "token",
"version": "2.0.0",
"code": "return { balance(ctx, acct) { return ctx.get(`balance:${acct}`) || 0; }, totalSupply(ctx) { return ctx.get(\"totalSupply\") || 0; } };"
}
}'

For governed contract upgrades in a multi-node cluster, see the Governance guide.

Contract Development Tips

  1. Keep contracts small and focused. Each contract should handle a single domain (e.g., token management, access control).

  2. Use key prefixes for namespacing. Prefix all keys with the contract name to avoid collisions: token:balance:alice, token:supply.

  3. Validate all inputs. Never trust method arguments -- check types, ranges, and permissions before modifying state.

  4. Use ctx.log() for observability. Logged messages help with debugging and create an audit trail that is stored alongside the transaction.

  5. Test contracts locally. Use MiniLedger's programmatic API to spin up a single-node instance and test contracts in your CI pipeline.

Next Steps

  • Governance -- Manage contract upgrades through on-chain proposals
  • SQL Queries -- Query world state created by your contracts using SQL
  • Programmatic API -- Deploy and invoke contracts from your Node.js application code