Compiling Contracts¶
compile turns
SilverScript source into a
CompiledContract —
the locking script plus everything needed to build unlocking scripts
to spend.
import kaspa.experimental.silverscript as silverscript
contract = silverscript.compile(source, constructor_args=None)
source is the contract as a string. constructor_args is a list of
values for the contract's constructor parameters. Omit it (or pass None / []) for a contract with
no parameters.
A compile failure — a syntax error, a type error, an incompatible
pragma — raises
SilverScriptError.
Where the compiler records the location, the message ends with the byte span of
the offending source:
try:
silverscript.compile("pragma silverscript ^9.9.9; contract B() {}")
except silverscript.SilverScriptError as e:
print(e)
# unsupported feature: SilverScript compiler cannot support pragmas that cover
# future major versions ... (at bytes 20..26)
Not every error carries one. A type error names the offending declaration instead, and a syntax error arrives as the parser's own caret diagram:
silverscript.compile("contract B() { entry m() { byte x = 256; } }")
# SilverScriptError: unsupported feature: variable 'x' expects byte
Constructor args embed state into the script¶
Constructor arguments are compiled into the locking script. Two different argument sets produce two different scripts, and so two different P2SH addresses:
This is a key mental model for SilverScript. A Counter at count = 0 and the
same Counter at count = 5 are different scripts, and as a result, are different
P2SH addresses. Compilation is deterministic — the same source and args
always produce identical bytes — so you can re-derive an address at any
time from known source and state.
The compiled contract¶
A CompiledContract
is read-only. Its properties:
| Property | What it is |
|---|---|
bytecode |
The locking (redeem) script bytes. |
contract_name |
The contract name from the source. |
compiler_version |
The compiler version that produced it. |
abi |
One EntryAbi per callable entrypoint, ordered alphabetically by name. |
entry(name) |
One entrypoint's EntryAbi, by name. |
state_span |
(offset, len): the byte offset and length of the contract state within the script. |
template_hash |
32-byte digest over the script's template parts, matching the templateHash() builtin. |
Reading the ABI¶
The abi tells
you which entrypoints a contract exposes and what arguments each one
takes.
Each EntryAbi has a
name, a list of params, and a dispatch_tag. Each
ParamAbi has a name
and a type_name — the SilverScript type, e.g. "int", "byte[32]",
"pubkey", "sig".
contract = silverscript.compile(SOURCE, [100])
# One entrypoint, by name:
entry = contract.entry("check")
print(entry.params[0].type_name) # int
# Or enumerate them all:
for entry in contract.abi:
args = ", ".join(f"{p.type_name} {p.name}" for p in entry.params)
print(f"{entry.name}({args})")
# check(int amount)
abi is ordered alphabetically by entrypoint name — the same order a
ContractArtifact
reports, so an entrypoint keeps its position whichever route you reached it
by. Declaration order isn't available: it exists only while the source is
parsed, and a compiled artifact doesn't carry it.
entry(name)
raises
SilverScriptError
for a name the contract doesn't declare — the same error build_sig_script
gives for the same typo.
dispatch_tag is the entrypoint's four-byte identity —
blake3("name(type,type)")[:4]. It is content-addressed over the name and the
resolved parameter types, so every instance of a contract normally shares the
same tags, whatever its constructor arguments. The one exception is a parameter
whose array length is itself a constructor parameter: entry take(byte[n] blob)
on contract Sized(int n) resolves to byte[4] for n = 4 and byte[8] for
n = 8 — different types, and so different tags. Read the tag off the instance
you are spending rather than caching it across instances of such a contract.
The tag is also the final data push of every signature script built for that entrypoint.
The portable artifact¶
Behind the ABI is a portable artifact — the full machine-readable
description of the compiled contract. compile() builds it once, and it is
what each build_sig_script call encodes against.
artifact_json()
hands it to you as JSON:
import json
contract = silverscript.compile(SOURCE, [100])
artifact = json.loads(contract.artifact_json())
print(artifact["contracts"]["Guard"]["entries"]["check"]["dispatch_tag"])
# b0823999
For the same source and constructor arguments this is byte-for-byte what the
upstream silverc compiler writes, so an artifact produced here and one
produced by silverc contract.sil are interchangeable. That makes it a
portable build output: compile in CI, commit the artifact, and let other
tooling read it without a compiler.
Three things about the encoding are worth knowing before you parse it:
bytecodeandtemplate_hashare JSON arrays of integers, not hex. Usebytes(...)to recover the raw value:bytes(artifact["contracts"]["Guard"]["compiled"]["bytecode"]) == contract.bytecode.dispatch_tagis hex — an eight-character string, unlike the two fields above.- Types are tagged objects, e.g.
{"kind": "int"}. The matching value form,{"kind": "int", "value": 100}, is the portable dialect — whatsilverc --constructor-argsreads. It is not the type-directed JSON the SilverScript debugger CLI and.test.jsonfixtures use. The two look similar and are not interchangeable; if you are hand-writing JSON for upstream tooling, check which one that tool wants.
state_span appears here as compiled.state_span, an object with the keys
offset and len rather than a two-tuple — the same two numbers the
attribute gives you.
Loading an artifact back¶
load_artifact
reads that JSON back into a
ContractArtifact:
a contract you can derive an address from and spend, with no source and no
compiler.
from pathlib import Path
# In CI, once:
Path("guard.json").write_text(silverscript.compile(SOURCE, [100]).artifact_json())
# At runtime, from guard.json alone:
artifact = silverscript.load_artifact(Path("guard.json").read_text())
artifact.bytecode # the same redeem script
artifact.build_sig_script("check", [150]) # the same unlocking script
The bytes are identical to the ones the
CompiledContract
would have produced. Both encode against this same artifact — it is the only
thing build_sig_script ever reads.
A ContractArtifact carries contract_name, compiler_version,
schema_version, bytecode, template_hash, state_span, abi and
entry(name), plus both build_sig_script methods and to_json(). Two
things it can't do,
because they need the source: debug_call, and compiling with
different constructor arguments — those are a different contract, and so a
different artifact.
If the JSON holds more than one contract, name the one you want:
load_artifact(text, "Guard"). Omitting the name is fine for anything
compile() produced, which is always a single contract.
Checking an artifact you didn't build¶
load_artifact checks that the JSON parses and that its schema version is one
this release understands. It does not check that the artifact describes itself
consistently — check_consistency() does:
That verifies the recorded template_hash against the bytecode, the state span
against the script, and the entrypoints' dispatch tags for collisions. It
catches corruption and casual edits.
It cannot tell you the bytecode was compiled from any particular source —
nothing inside an artifact can. An artifact is exactly as trustworthy as where
you got it: take it from a build you control, or compare its template_hash
against a value you already trust.
Next: turn one of these entrypoints into an unlocking script in Unlocking Scripts.