Skip to content

Smart Contract Author Guide

Author-focused contract material split out from the previous all-in-one guide.

Source: docs\developer\smart-contracts.md.

What You Need

From the repository root:

bash
npm install
npm --prefix apps/noos-contract-sdk-as install
npm run build:contract-wasmtime-sidecar

You also need:

  • a running NOOSChain node API
  • DATABASE_URL pointing at that node's database when using noos tx build
  • a registered user and organization
  • the user's public key and private key file

The examples below use these placeholders:

text
user-admin
org-noos
bucket-reports
contract-report-store

Replace them with real ids for your chain.

Contract Project Layout

The AssemblyScript SDK lives here:

text
apps/noos-contract-sdk-as/

For a first contract, put it under the SDK examples directory:

text
apps/noos-contract-sdk-as/examples/report-store/
  assembly/index.ts
  asconfig.json

The SDK wraps raw WASM host imports so contract code does not need to manage noos.* pointer calls directly.

Tutorial 1: Write A Contract

Create:

text
apps/noos-contract-sdk-as/examples/report-store/assembly/index.ts
ts
import {
  bucketGetMetadataJson,
  emitEventJson,
  storageGetString,
  storageSetJson,
} from "../../../assembly";

export function saveReport(ptr: i32, len: i32): i32 {
  const reportJson = String.UTF8.decodeUnsafe(ptr, len);

  storageSetJson("lastReport", reportJson);
  emitEventJson("report.saved", reportJson);

  return 0;
}

export function readBucket(ptr: i32, len: i32): i32 {
  const bucketId = String.UTF8.decodeUnsafe(ptr, len);
  const bucketJson = bucketGetMetadataJson(bucketId);

  storageSetJson("lastBucket", bucketJson);
  emitEventJson("bucket.read", bucketJson);

  return 0;
}

export function ping(): i32 {
  const previous = storageGetString("lastReport");
  emitEventJson("contract.ping", `{"ok":true,"hasReport":${previous.length > 0}}`);
  return 0;
}

Every exported method must return 0 on success. A non-zero number fails the contract call. If the method traps or a host wrapper throws, the call fails and queued storage writes, bucket writes, and events are discarded.

Tutorial 2: Use The AssemblyScript SDK

The SDK has typed helpers and raw JSON helpers. Contract methods receive canonical JSON args as (ptr, len) when the method accepts arguments.

Use typed helpers for normal contract code:

ts
import {
  DecodeError,
  JsonDecoder,
  JsonEncoder,
  emitEventValue,
  getJsonField,
  i32Codec,
  quoteJsonString,
  readArgs,
  storageGetNullable,
  storageKey,
  storageSet,
  stringCodec,
} from "../../../assembly";

class Report {
  constructor(
    public id: string = "",
    public status: string = "",
    public score: i32 = 0,
  ) {}
}

class ReportCodec implements JsonEncoder<Report>, JsonDecoder<Report> {
  encode(value: Report): string {
    return "{" +
      "\"id\":" + quoteJsonString(value.id) + "," +
      "\"status\":" + quoteJsonString(value.status) + "," +
      "\"score\":" + value.score.toString() +
      "}";
  }

  decode(json: string): Report {
    return new Report(
      stringCodec.decode(getJsonField(json, "id")),
      stringCodec.decode(getJsonField(json, "status")),
      i32Codec.decode(getJsonField(json, "score")),
    );
  }
}

const reportCodec = new ReportCodec();

export function saveTypedReport(ptr: i32, len: i32): i32 {
  const report = readArgs<Report>(ptr, len, reportCodec);
  storageSet<Report>(storageKey("reports", report.id), report, reportCodec);
  emitEventValue<Report>("report.saved", report, reportCodec);
  return 0;
}

export function loadLastReport(): i32 {
  const report = storageGetNullable<Report>("lastReport", reportCodec);
  if (report === null) return 1;
  emitEventValue<Report>("report.loaded", report, reportCodec);
  return 0;
}

DecodeError is thrown when a decoder cannot parse the input. The contract call then fails and no queued writes or events are committed.

The SDK also includes primitive codecs:

ts
stringCodec
boolCodec
i32Codec
u32Codec
i64Codec
f64Codec
jsonStringCodec

Use raw JSON helpers when you already have a JSON string or need an escape hatch:

ts
storageSetJson("lastReport", "{\"ok\":true}");
emitEventJson("report.saved", "{\"ok\":true}");

The lower-level JSON imports are:

ts
import {
  bucketAddEncryptedRecordMetadataJson,
  bucketGetMetadataJson,
  bucketGetRecordMetadataJson,
  emitEventJson,
  emitEventString,
  readLastResultJson,
  storageGetJson,
  storageGetString,
  storageSetJson,
  storageSetString,
} from "../../../assembly";

Common calls:

ts
storageSetString("status", "ready");
storageSetJson("lastReport", "{\"ok\":true}");

const current = storageGetString("status");
const bucket = bucketGetMetadataJson("bucket-reports");

emitEventString("status.changed", current);
emitEventJson("bucket.metadata", bucket);

To write encrypted-record metadata to a bucket:

ts
bucketAddEncryptedRecordMetadataJson(JSON.stringify({
  bucketId: "bucket-reports",
  recordId: "record-from-contract",
  payloadHash: "sha256-placeholder",
  payloadSizeBytes: 0,
  encryption: {
    mode: "external",
  },
  publicIndexes: {
    source: "contract",
  },
}));

The contract must have the required bucket permission in its manifest and in a chain bucket access rule, or the host call fails.

Contract-To-Contract Calls

Use contractCallJson(contractId, method, argsJson) from the AssemblyScript SDK when one contract needs to call another contract. The helper returns the target method's structured return value as raw JSON.

ts
import { contractCallJson, returnJson } from "../../../assembly";

export function summarizeRemote(): i32 {
  const result = contractCallJson("contract-report-target", "summarize", "{\"limit\":10}");
  returnJson(result);
  return 0;
}

The caller manifest must declare the outbound edge:

json
{
  "calls": [
    {
      "contractId": "contract-report-target",
      "methods": ["summarize"],
      "requiredBuckets": []
    }
  ]
}

The target method must explicitly allow the caller contract principal:

json
{
  "invoke": {
    "allow": [
      { "principalType": "contract", "principalId": "contract-report-caller" }
    ]
  }
}

If the target method needs bucket ciphertext, declare matching requiredBuckets on the caller edge. A node will not execute the nested call unless the target contract is ready locally for those bucket requirements.

The repository includes a complete typed example:

text
apps/noos-contract-sdk-as/examples/typed-report-contract/

Build it with:

bash
npm --prefix apps/noos-contract-sdk-as run build:typed-example

Tutorial 3: Add AssemblyScript Build Config

Create:

text
apps/noos-contract-sdk-as/examples/report-store/asconfig.json
json
{
  "extends": "../../asconfig.json",
  "entries": ["./assembly/index.ts"],
  "options": {
    "outFile": "../../build/report-store.wasm",
    "textFile": "../../build/report-store.wat"
  }
}

Build the contract:

bash
npx --prefix apps/noos-contract-sdk-as asc apps/noos-contract-sdk-as/examples/report-store/assembly/index.ts --outFile apps/noos-contract-sdk-as/build/report-store.wasm --textFile apps/noos-contract-sdk-as/build/report-store.wat --sourceMap --optimizeLevel 3 --shrinkLevel 1 --runtime stub

The important output is:

text
apps/noos-contract-sdk-as/build/report-store.wasm

Tutorial 4: Create The Manifest

Create:

text
contract.manifest.json
json
{
  "schemaVersion": 1,
  "name": "report-store",
  "version": "0.1.0",
  "runtime": "wasm-assemblyscript-v1",
  "exports": {
    "methods": {
      "saveReport": {
        "entrypoint": "saveReport",
        "maxFuel": 250000,
        "invoke": {
          "allow": [{ "principalType": "organization", "principalId": "org-noos" }]
        },
        "buckets": []
      },
      "readBucket": {
        "entrypoint": "readBucket",
        "maxFuel": 250000,
        "invoke": {
          "allow": [{ "principalType": "organization", "principalId": "org-noos" }]
        },
        "buckets": [
          {
            "bucketId": "bucket-reports",
            "access": ["bucket:read_metadata"],
            "availability": "metadata"
          }
        ]
      },
      "ping": {
        "entrypoint": "ping",
        "maxFuel": 50000,
        "invoke": {
          "allow": [{ "principalType": "anyUser" }]
        },
        "buckets": []
      }
    }
  }
}

The manifest is part of the contract's identity. Its canonical hash is checked during instantiation. If you change the manifest, compute a new manifest hash.

Tutorial 4.5: Declare Method ABI

Each method can include an optional abi block. The ABI tells wallets, SDKs, and the Web console which JSON args to send, which result shape to expect, and which events the method may emit.

json
{
  "entrypoint": "saveReport",
  "maxFuel": 250000,
  "invoke": {
    "allow": [{ "principalType": "organization", "principalId": "org-noos" }]
  },
  "buckets": [],
  "abi": {
    "description": "Store a report and emit report.saved.",
    "input": {
      "type": "object",
      "properties": {
        "reportId": { "type": "string" },
        "status": {
          "type": "string",
          "enum": ["draft", "submitted", "approved", "rejected"]
        },
        "score": { "type": "integer" }
      },
      "required": ["reportId", "status", "score"]
    },
    "output": {
      "type": "object",
      "properties": {
        "ok": { "type": "boolean" }
      },
      "required": ["ok"]
    },
    "events": [
      {
        "topic": "report.saved",
        "schema": {
          "type": "object",
          "properties": {
            "reportId": { "type": "string" },
            "status": { "type": "string" }
          },
          "required": ["reportId", "status"]
        }
      }
    ]
  }
}

Supported ABI value types are string, number, integer, boolean, null, object, and array. Object schemas may declare properties and required; array schemas may declare items; any value may include description and enum.

ABI is optional for backward compatibility. Legacy methods without ABI are served with input: null, output: null, and events: [].

After deployment, callers can read the ABI through the node HTTP API:

text
GET /contracts/contract-report-store/abi

The TypeScript SDK wraps the same endpoint:

ts
const abi = await client.contracts.abi("contract-report-store");
console.log(abi.methods.saveReport.input);

Tutorial 4.6: Return Structured Values

Contract methods still use their numeric return code for execution status: 0 means success, and any non-zero value fails the call. For successful calls, the AssemblyScript SDK can also store one structured JSON return value as a deterministic call receipt.

ts
import {
  emitEventJson,
  returnJson,
  storageSetJson,
} from "../../../assembly";

export function saveReport(ptr: i32, len: i32): i32 {
  const reportJson = String.UTF8.decodeUnsafe(ptr, len);

  storageSetJson("lastReport", reportJson);
  emitEventJson("report.saved", reportJson);
  returnJson(reportJson);

  return 0;
}

Typed contracts should use returnValue<T>(...):

ts
returnValue<Report>(report, reportCodec);

Convenience helpers are available for primitives:

ts
returnString("ok");
returnBool(true);
returnI32(42);

The returned JSON is stored only when the method succeeds. If the method returns a non-zero status, traps, or exceeds a protocol limit, the return value is discarded with staged storage writes, bucket writes, and events.

Return values are not contract storage. They are durable execution receipts keyed by the transaction CALL_CONTRACT hash:

text
GET /contracts/calls/<CALL_TX_HASH>/result

The TypeScript SDK wraps the same endpoint:

ts
const result = await client.contracts.callResult(call.transaction.hash);
console.log(result.returnValue);

abi.output from the manifest describes this structured return value for callers and the Web console.

Tutorial 5: Query Contract Events

Events emitted with emitEventJson, emitEventString, or emitEventValue are stored after a successful transaction CALL_CONTRACT. Query them when a UI needs an activity feed, an indexer needs to catch up by block range, or a caller wants to inspect the events from one transaction.

HTTP:

text
GET /contracts/events?contractId=contract-report-store&topic=report.saved&fromBlock=10&toBlock=20
GET /contracts/contract-report-store/events?topic=report.saved&method=saveReport

Supported filters are contractId, topic, method, transactionHash, fromBlock, toBlock, limit, offset, and order=asc|desc. Results are ordered by canonical block height and event id, so pagination is stable.

TypeScript SDK:

ts
const events = await client.contracts.events({
  contractId: "contract-report-store",
  topic: "report.saved",
  fromBlock: 10,
  toBlock: 20,
  limit: 25,
});

for (const event of events.events) {
  console.log(event.blockHeight, event.method, event.topic, event.data);
}

For one contract:

ts
const events = await client.contracts.eventsForContract("contract-report-store", {
  topic: "report.saved",
  method: "saveReport",
});

Event data is public chain metadata. Do not emit secrets, decrypted payloads, or private bucket data.

Tutorial 6: Compute Hashes

The TypeScript SDK can compute these hashes for publishing. Manual commands are still useful for audits and CLI-only workflows.

SDK:

ts
import { hashContractCode, hashContractManifest } from "@nooschain/client";

const codeHash = hashContractCode(wasmBytes);
const manifestHash = hashContractManifest(manifest);

Manual code hash:

bash
node -e "const fs=require('fs');const crypto=require('crypto');const b=fs.readFileSync('apps/noos-contract-sdk-as/build/report-store.wasm');console.log(crypto.createHash('sha256').update(b).digest('hex'))"

Manual manifest hash:

bash
npx tsx -e "import { readFileSync } from 'node:fs'; import { hashContractManifest, parseContractManifest } from './src/contracts/manifest.ts'; const manifest = parseContractManifest(JSON.parse(readFileSync('contract.manifest.json', 'utf8'))); console.log(hashContractManifest(manifest));"

Save both values. The examples below use:

text
<CODE_HASH>
<MANIFEST_HASH>

Tutorial 8: Instantiate The Contract

Create:

text
instantiate-contract.json
json
{
  "id": "contract-report-store",
  "codeHash": "<CODE_HASH>",
  "manifestHash": "<MANIFEST_HASH>",
  "manifest": {
    "schemaVersion": 1,
    "name": "report-store",
    "version": "0.1.0",
    "runtime": "wasm-assemblyscript-v1",
    "exports": {
      "methods": {
        "saveReport": {
          "entrypoint": "saveReport",
          "maxFuel": 250000,
          "invoke": {
            "allow": [{ "principalType": "organization", "principalId": "org-noos" }]
          },
          "buckets": []
        },
        "readBucket": {
          "entrypoint": "readBucket",
          "maxFuel": 250000,
          "invoke": {
            "allow": [{ "principalType": "organization", "principalId": "org-noos" }]
          },
          "buckets": [
            {
              "bucketId": "bucket-reports",
              "access": ["bucket:read_metadata"],
              "availability": "metadata"
            }
          ]
        },
        "ping": {
          "entrypoint": "ping",
          "maxFuel": 50000,
          "invoke": {
            "allow": [{ "principalType": "anyUser" }]
          },
          "buckets": []
        }
      }
    }
  },
  "organizationId": "org-noos",
  "ownerUserId": "user-admin",
  "initMsg": {},
  "metadata": {
    "name": "report-store",
    "version": "0.1.0"
  }
}

Build and submit:

bash
npm run noos -- tx build-and-submit --type INSTANTIATE_CONTRACT --payload-file instantiate-contract.json --signer-public-key "<USER_PUBLIC_KEY_PEM>" --signer-private-key-path "<USER_PRIVATE_KEY_PATH>" --yes

This submits transaction INSTANTIATE_CONTRACT.

Instantiation stores the contract instance and evaluates local readiness. The contract can exist before all bucket permissions are granted, but local calls will fail until readiness is active.

Tutorial 9: Grant Bucket Access To The Contract

The manifest says what the contract wants. Bucket access rules say what the chain actually allows.

For the readBucket method above, grant the deployed contract bucket:read_metadata on bucket-reports.

Create:

text
grant-contract-bucket-access.json
json
{
  "id": "rule-contract-report-store-read-bucket-reports",
  "bucketId": "bucket-reports",
  "principalType": "contract",
  "principalId": "contract-report-store",
  "permissions": ["bucket:read_metadata"]
}

Submit:

bash
npm run noos -- tx build-and-submit --type ADD_BUCKET_ACCESS_RULE --payload-file grant-contract-bucket-access.json --signer-public-key "<USER_PUBLIC_KEY_PEM>" --signer-private-key-path "<USER_PRIVATE_KEY_PATH>" --yes

This submits transaction ADD_BUCKET_ACCESS_RULE.

For contract calls, both gates must pass:

  • the caller must match the method invoke policy
  • the contract principal must have bucket permissions for the host calls it makes

Tutorial 10: Call The Contract

Call saveReport with JSON args:

json
{
  "contractId": "contract-report-store",
  "method": "saveReport",
  "args": {
    "reportId": "report-001",
    "status": "submitted",
    "score": 97
  }
}

Save that as:

text
call-save-report.json

Submit:

bash
npm run noos -- tx build-and-submit --type CALL_CONTRACT --payload-file call-save-report.json --signer-public-key "<USER_PUBLIC_KEY_PEM>" --signer-private-key-path "<USER_PRIVATE_KEY_PATH>" --yes

This submits transaction CALL_CONTRACT.

Call readBucket with the bucket id as JSON args:

json
{
  "contractId": "contract-report-store",
  "method": "readBucket",
  "args": "bucket-reports"
}

The runtime canonicalizes args, writes them into WASM memory, and invokes the manifest entrypoint as (ptr, len).

Rules Of Thumb

  • Keep methods deterministic.
  • Do not use wall-clock time, random numbers, networking, or filesystem access.
  • Keep loops bounded.
  • Keep host calls small.
  • Return 0 on success.
  • Use events for observable execution output.
  • Declare every bucket a method needs in the manifest.
  • Grant matching bucket access to the deployed contract principal.
  • Prefer availability: "metadata" unless the method truly needs local encrypted payloads.
  • Treat maxFuel as a safety limit, not a fee.

Audience-first NOOSChain documentation.