GCULpy is a strict statically typed subset of Python, designed to be safe, readable, and auditable. Its design intentionally limits certain dynamic features of Python to prevent common smart contract vulnerabilities and ensure that the behavior of a contract is always predictable.
This page provides a reference for the GCULpy language specification, covering the core concepts and the lifecycle of a contract on a Universal Ledger network.
Core concepts
The following contract defines a sample ERC20 token in GCULpy:
import gcul
class ERC20Token(gcul.Contract):
"""Sample ERC20 implementation for the Universal Ledger."""
symbol: str
total_supply: int
balance: gcul.AccountField[int]
def __init__(self, symbol: str):
self.symbol = symbol
self.total_supply = 0
def mint(self, beneficiary: gcul.Account, value: int) -> int:
"""Mints tokens to the given beneficiary."""
assert self.is_owner(gcul.sender), "Only the owner can mint"
assert value >= 0, "Mint amount must be non-negative"
self.total_supply += value
self.balance[beneficiary] += value
return value
def transfer(self, beneficiary: gcul.Account, value: int) -> int:
"""Transfers tokens from the sender to the given beneficiary."""
assert value >= 0, "Transfer amount must be non-negative"
assert (
value <= self.balance[gcul.sender]
), "Sender does not have enough balance"
self.balance[gcul.sender] -= value
self.balance[beneficiary] += value
return value
A GCULpy contract is a class that inherits from gcul.Contract. It
contains fields (storing state) and methods (processing logic that
operate on fields).
Fields
State in a GCULpy contract is stored in fields. All fields must be declared with a static type at the class level. There are two kinds of fields:
Contract fields hold a single value stored with the contract itself. In the
ERC20Tokenexample,symbol: strandtotal_supply: intare contract fields.Account fields hold a separate value for each account interacting with the contract. They are declared with
gcul.AccountField[T], whereTis the type of the per-account value. In theERC20Tokenexample,balance: gcul.AccountField[int]is an account field. Before a contract can write to storage space reserved for that account, the storage permission must explicitly be granted to the contract. Once the data is stored, only the contract instance can modify it—the account cannot.
Restriction Fields have the following storage limits. A transaction that would exceed either limit fails.
- All contract fields of a contract share a single storage entry, which can hold about 5 MiB of serialized data in total.
- All account fields that a contract stores for each individual account can hold at most 1 MiB of serialized data in total.
Methods
Methods define the executable logic of a contract. They behave like Python methods and can read or modify the contract's fields.
__init__: The constructor is called only once when the contract is first deployed. Every contract field must be assigned a value in the constructor. The constructor doesn't initialize account fields; each account's value is created when that account grants the storage permission.Private methods: Methods starting with an underscore (for example,
_internal_logic) are private and can only be called by other methods within the same contract. The Universal Ledger interpreter enforces this constraint.Public methods: Any method not starting with an underscore (
_) is public. Public methods can be called by any user with theROLE_CONTRACT_PARTICIPANTby submitting an InvokeContractMethod transaction.
Restriction Public methods and all
@gcul.readonly methods cannot use mutable types in their signatures, for example list or dict. Instead, use read-only type declarations such as Sequence or Mapping.
Restriction During execution, methods are limited to a network-wide upper bound of computation usage. Exceeding this limit results in a transaction failure.
Contract lifecycle
The following sections walk through the typical operations involved in the lifecycle of a GCULpy contract.
Deploy a contract
First, you compile your GCULpy source code using the gculpyc
compiler. Then, a user with the ROLE_CONTRACT_CREATOR may submit a
CreateContract
transaction to deploy the compiled bytecode to a Universal Ledger network. For
detailed instructions see the
Deploy a programmable contract
tutorial.
Such a transaction would look like:
client_transaction {
sender_id: "OWNER_ACCOUNT_ID"
create_contract_transaction {
contract_bytes: "COMPILED_BYTECODE"
init_arguments {
key: "symbol"
value: { string_value: "US02079K1079" }
}
}
}
When the network processes this transaction:
- The constructor, that is the
__init__method, is executed with the provided arguments to create a new contract instance. - The sender of the transaction becomes the contract's owner.
- The contract instance is permanently stored in the ledger and assigned a unique contract ID which is returned as part of the transaction output.
Grant permissions
Before a contract can store data in an account field on behalf of an account, either an account for a user or another contract, the storage permission must be granted to the contract storing the data. This is a critical security step.
Granting permissions for a user account
A user with a ROLE_CONTRACT_PARTICIPANT may submit a
GrantContractPermissions
transaction for a specific contract ID:
client_transaction {
sender_id: "PARTICIPANT_ACCOUNT_ID"
grant_contract_permissions_transaction {
contract_id: "CONTRACT_ID"
permissions: CONTRACT_PERMISSION_STORAGE
}
}
When the network processes this transaction:
- If the contract does not define any account fields, the transaction is rejected.
- If the contract defines account fields, all of them are populated with
default values (for example,
contract.balance[gcul.sender] = 0). These values are then stored in the world state as part of the account's data.
Granting permissions for a delegate contract
A user with a ROLE_CONTRACT_PARTICIPANT may submit a
GrantContractPermissions
transaction to allow one contract to store data on behalf of another contract
they own:
client_transaction {
sender_id: "OWNER_ACCOUNT_ID"
grant_contract_permissions_transaction {
contract_id: "TARGET_CONTRACT_ID"
delegate_contract_id: "DELEGATE_CONTRACT_ID"
permissions: CONTRACT_PERMISSION_STORAGE
}
}
When the network processes this transaction:
- The transaction sender, that is
sender_id, must be the owner of the contract identified bydelegate_contract_id. - A contract cannot grant storage permissions to itself, so
delegate_contract_idmust differ fromcontract_id. - Storage permissions are granted exclusively to the contract specified in
delegate_contract_id, rather than the transaction sender. - Account fields defined on the target contract,
contract_id, are initialized for the delegate contract,delegate_contract_id, allowing methods on the target contract to safely access account fields keyed bygcul.callerorgcul.senderduring cross-contract invocations.
Invoke contract methods
Once a contract is deployed and any necessary permissions are granted, users can
interact with it by calling its public methods. A user with a
ROLE_CONTRACT_PARTICIPANT may submit an
InvokeContractMethod
transaction specifying the contract ID, method name, and method argument values.
client_transaction {
sender_id: "PARTICIPANT_ACCOUNT_ID"
invoke_contract_method_transaction {
contract_id: "CONTRACT_ID"
method_name: "mint"
method_arguments {
key: "beneficiary"
value: { account_id: "BENEFICIARY_ID" }
}
method_arguments {
key: "value"
value: { int64_value: 10 }
}
}
}
When the network processes this transaction:
- The contract instance associated with the provided
CONTRACT_IDis retrieved. - The method
mint(beneficiary=Account("BENEFICIARY_ID"), value=10)is executed. TheAccountobject for the beneficiary is built and validated by the runtime. The method's logic may safely assume that the provided ID is valid and refers to an existing account on the ledger. - If the method fails for any reason, the transaction will fail and no updates will be made to the contract's state.
- If the method succeeds, the updated state of the contract is recorded in the world state.
Language specification
GCULpy is designed for safety and predictability and, as such, disallows several Python features; these will be called out with the Restriction label. These restrictions are intended to be permanent language features, introduced to make the contract logic easier to read, audit, and analyze statically, limiting surprising or unsafe behaviors.
Other features are called out with the Roadmap label, these are on the
implementation roadmap but not yet supported by the gculpyc compiler.
Types
GCULpy supports a range of common variable types with a strong emphasis on static typing.
Core value types:
int,bool,str,Noneare already supported.- Roadmap
Decimal,bytes,Enumare on the roadmap. - Restriction
floatandcomplexare not allowed. - Restriction
intvalues are signed 64-bit integers, from -263 to 263 - 1. An integer literal outside this range will result in a compile-time error, and an operation whose result is outside this range will result in a runtime error. - Restriction
strvalues can be at most 1,000,000 characters long. A longer string literal will result in a compile-time error. Passing a longer string as a method argument, or producing one as the result of an operation, will result in a runtime error.
Container types:
listanddict:list[T]: A mutable sequence. Supports indexing, for examplemy_list[0], and theappend()method.dict[K, V]: A mutable mapping. Supports indexing, for examplemy_dict[key], and thepop()method.
collections.abc.Sequenceandcollections.abc.Mapping:collections.abc.Sequence[T]: A read-only reference to a sequence. Supports reading elements by index, for examplemy_seq[0], but elements can't be modified or deleted.list[T]is a subtype ofSequence[T], so you can pass, return, or assign alist[T]value or list literal to a target typed asSequence[T], but you can't assign aSequence[T]to alist[T].collections.abc.Mapping[K, V]: A read-only reference to a mapping. Supports reading entries by key, for examplemy_map[key], but entries can't be modified or deleted.dict[K, V]is a subtype ofMapping[K, V], so you can pass, return, or assign adict[K, V]value or dictionary literal to a target typed asMapping[K, V], but you can't assign aMapping[K, V]to adict[K, V].
- Roadmap
tuple,set,dataclassare on the roadmap. - Restriction Concrete types must be specified for values in
a container, for example
dict[str, int]is allowed, but a plaindictordict[str, Any]are not. - Roadmap List slicing, for example
my_list[1:3]. - Roadmap Other methods, such as
.get(),.keys(),.values(),.items(),.clear(), or.extend().
Restriction All variables—including contract and account fields, function parameters and return types—must be defined and typed statically. Their type cannot be changed at runtime and only concrete types are supported. Types cannot be used as values, for example they cannot be stored in variables or passed to functions as arguments. Trying to assign a value to an undeclared field will result in a compile-time error.
Restriction A local variable must be assigned before it's read. Local variables have no default value, so reading one before it's assigned results in a runtime error.
Classes and inheritance
You can only define classes that are direct subclasses of either
gcul.Contract or gcul.ContractInterface.
This strict rule prevents the complexities of full Python
inheritance, which can introduce hard-to-find bugs and make code difficult to
reason about. For security, attempting to override a property or method from a
parent class will raise an error, providing a clear safeguard against unexpected
behavior.
Roadmap GCULpy will offer more flexibility while
maintaining its core principles. The roadmap includes support for
single inheritance on user-defined classes with method overriding explicitly
managed with an @override decorator. Additionally, the super() built-in will
be only supported in its argument-free form, to ensure direct, predictable
operations.
The gcul module
GCULpy provides a built-in gcul module with essential types,
decorators, and variables for contract development.
class gcul.Contract
The base class for all contracts. You cannot create instances of it directly;
contracts are only ever instantiated through
CreateContract
transactions. Methods and properties from the base gcul.Contract class cannot
be overridden in subclasses.
Contract.is_owner(account: Account) -> boolReturns
Trueif the provided account is the contract owner.Contract.call(target: ContractInterface, payment: Amount | None = None) -> ContractMethodsInitiates a cross-contract interaction with
target. Instead of calling methods on the target directly (e.g.,target.foo()), you must useself.call()to explicitly route the invocation through the ledger runtime. This mechanism returns a secure proxy object used to invoke corresponding public methods on the target contract, ensuring that execution boundaries are strictly enforced and anypaymentis safely delivered. See ContractInterface for details.A
paymentmust be supplied when calling a method decorated with@gcul.payable; and must be omitted for non-payable methods.targetmust be a different contract. Calling the current contract throughself.call()raises a runtime error. To call another method on the same contract, call it directly, for example,self.foo().Contract.native_transfer(beneficiary: Account, amount: Amount) -> NoneTransfers an on-ledger amount directly from the contract's balance to
beneficiary.Contract.id: strUnique string ID of the contract.
class gcul.Account
A built-in type representing a user or contract account on the ledger. You
cannot create gcul.Account objects directly; the runtime environment creates
them for you and provides them as function or method arguments. To pass an
account as a transaction argument, supply an
AccountValue
holding the account ID. The runtime validates the ID and converts it into a
full account object. If the ID doesn't belong to a registered account, the
transaction fails. This ensures you only ever work with valid accounts.
The definition of the class is roughly equivalent to:
@dataclasses.dataclass(frozen=True)
class Account:
"""A valid account on the ledger."""
id: str # The ID of the account as a string.
Account.id: strUnique string ID of the account.
class gcul.Amount
Represents a monetary value denominated in a specific ledger currency.
Instantiate an amount using
gcul.Amount(value: int, currency: gcul.Currency). The definition of the
class is roughly equivalent to:
@dataclasses.dataclass(frozen=True)
class Amount:
"""Represents an amount denominated in a ledger currency."""
value: int
currency: Currency
class gcul.Currency
Represents a fully qualified currency on the ledger. The definition of the class is roughly equivalent to:
@dataclasses.dataclass(frozen=True)
class Currency:
"""Represents a fully qualified currency on the ledger."""
code: str
operator_id: str
class gcul.ContractInterface
The base class for declaring the public interface of a contract.
Methods and properties from the base gcul.ContractInterface class cannot be
overridden in subclasses. Furthermore, the methods in the interface declaration
cannot include any code, only
... is allowed.
For a contract to call methods on another contract deployed on the ledger,
declare an interface class inheriting from gcul.ContractInterface. Method
stubs must match their corresponding declaration in the target contract. This
includes arguments, return type, and decorator annotations such as
@gcul.readonly or @gcul.payable.
Inside your contract code, use self.call(target, payment) to
invoke a method from another contract.
The following example illustrates how a TokenVendor contract interacts
with another contract implementing the ERC20Interface. It uses
self.call() to transfer tokens to a buyer in exchange for an on-ledger
currency payment:
class ERC20Interface(gcul.ContractInterface):
"""Interface for an external ERC20 token contract."""
def transfer(self, beneficiary: gcul.Account, value: int) -> int:
...
class TokenVendor(gcul.Contract):
"""A vendor contract that distributes ERC20 tokens."""
price_per_token: int
def __init__(self, price_per_token: int) -> None:
self.price_per_token = price_per_token
@gcul.payable
def purchase_tokens(self, token: ERC20Interface, quantity: int) -> int:
"""Accepts on-ledger currency for a specific quantity of ERC20 tokens."""
# Enforce positive quantity.
assert quantity > 0, "Quantity must be positive"
# Calculate the required payment.
required_payment = quantity * self.price_per_token
assert gcul.payment.value == required_payment, "Incorrect payment amount"
# Invokes `transfer` on the external ERC20 token contract
# to move tokens from the vendor's own balance to the caller.
return self.call(token).transfer(gcul.caller, quantity)
Interface types can also be used as contract fields, not just as method
parameters. Storing an interface in a field lets a contract hold a reference to
another contract across transactions, and replace that reference later. The
following example contract uses the ERC20Interface declared earlier:
class Router(gcul.Contract):
"""Holds a replaceable reference to an ERC20 token contract."""
target: ERC20Interface
def __init__(self, target: ERC20Interface) -> None:
self.target = target
def set_target(self, new_target: ERC20Interface) -> None:
assert self.is_owner(gcul.sender), "Only the owner can set the target"
self.target = new_target
class gcul.AccountField
A built-in generic type for declaring account fields. gcul.AccountField[T]
holds a separate value of type T for each account that has granted the
contract the storage permission. It behaves as a mapping with gcul.Account
keys, which you can use to read or write a value for each specific account.
For example:
class Token(gcul.Contract):
balance: gcul.AccountField[int]
def deposit(self, account: gcul.Account, value: int) -> None:
self.balance[account] = value # Writes the value for one account.
def get_balance(self, account: gcul.Account) -> int:
return self.balance[account] # Reads the value for one account.
T must be a type that has a default value, so gcul.Account, gcul.Amount,
gcul.Currency, and interface types cannot be used as account field
values. When an account grants the storage permission to a contract, every
account field is populated with the default value of its type for that
account.
Restriction Indexing with an account is the only operation an account field supports. The field itself can't be assigned to a variable, passed as an argument, returned from a function, or iterated over.
Restriction Account field entries can't be deleted. A stored value can be overwritten, but the entry itself is permanent.
Restriction Account fields for gcul.sender, gcul.caller, and any
gcul.Account passed as a method argument (including inside lists or other
containers) are loaded before method execution. For other accounts, a single
transaction can lazily load account fields for at most 15 distinct
(contract, account) pairs; accessing an account field for the same pair
multiple times in a transaction counts only once. A transaction that exceeds
this limit fails.
@gcul.readonly
Annotates a contract method as read-only. Method declarations with this
decorator can't mutate contract state or make on-ledger currency transfers. They
also cannot use mutable container types in their signatures, for example list
or dict; they must use read-only type declarations such as Sequence or
Mapping instead.
@gcul.payable
Annotates a contract method as requiring a payment to be executed. If a method
expects to receive an on-ledger currency payment using gcul.payment, it must
be marked with this decorator, otherwise the transaction will fail.
gcul.sender: gcul.Account
Holds a reference to the account that signed and submitted the current
InvokeContractMethod
transaction. The value of gcul.sender does not change throughout transaction
execution, even if it involves cross-contract calls, unlike gcul.caller
which references the immediate caller.
gcul.caller: gcul.Account
Holds a reference to the immediate caller of the current method. For direct user
transactions, gcul.caller matches gcul.sender pointing to whoever submitted
the transaction. For cross-contract calls, gcul.caller corresponds to the
calling contract's account ID. Internal calls within a contract don't change
the value of gcul.caller.
gcul.payment: gcul.Amount
Holds the payment transferred to a method decorated with @gcul.payable.
Accessing gcul.payment inside a non-payable method raises a compile-time
error.
Operators
Most operators available in Python are supported in GCULpy and work as expected.
- Addition (
+) and subtraction (-), including unary and binary forms. - Multiplication (
*), floor division (//), and modulo (%). - Exponentiation (
**) for positive exponents. - Comparisons (
<,<=,>,>=,==,!=). - Bitwise and (
&), or (|), xor (^), left shift (<<), right shift (>>), negation (~). - Boolean operations (
and,or,not). - Roadmap Object identity (
is). - Restriction Negative exponents are not allowed and raise a runtime error.
- Restriction True division (
/), as it has afloatreturn type, is not allowed and results in a compile-time error.
Control flow
Most control-flow statements from Python work in GCULpy with the same semantics:
passstatements.- internal function calls (same contract, non-recursive).
assertstatements.if ... then .. else ...statements.for VAR in CONTAINERstatements.- external function calls (to any other contract, non-recursive).
breakandcontinuestatements.raisestatements.- Roadmap
try ... exceptstatements. - Roadmap
matchstatements. - Roadmap
generatorsandyieldstatements. - Roadmap context managers and
withstatements.
Restriction GCULpy is intentionally Turing-incomplete to prevent infinite loops and facilitate static analysis. Here's how it enforces this:
- No infinite loops: Iteration is only allowed using
forloops over finite containers or bounded sequences withrange(...);whileloops are not allowed. Some container updates, for example adding or removing elements to a list or keys to a dictionary, are not allowed while iterating over them. - No recursion: A function cannot call itself, either directly or indirectly. The runtime environment performs both static and runtime checks to detect and reject the use of recursion.
- No async control flow: Use of
asyncprimitives is not allowed to maintain the core design principles of predictability, security, and deterministic execution. Asynchronous operations make it difficult to reason about the control flow of a program, often leading to vulnerabilities and race conditions.
Built-in functions
GCULpy supports the following built-in functions:
abs(x)Returns the absolute value of an integer
x. Arguments must be of typeint.len(s)Returns the length, that is the number of items, of a string or finite container.
max(iterable)
max(arg1, arg2, *args)Returns the largest item in an iterable or the largest of two or more arguments. Calling
max()on an empty iterable raises a runtime error.min(iterable)
min(arg1, arg2, *args)Returns the smallest item in an iterable or the smallest of two or more arguments. Calling
min()on an empty iterable raises a runtime error.range(stop)
range(start, stop[, step=1])Generates an arithmetic sequence of integers with a non-zero integer
step.Restriction Can only be used in-line with
forloops, for examplefor i in range(10): .... Range objects cannot be assigned to variables or stored in fields, passed as arguments to functions, or queried withlen().
Roadmap The following built-in functions are on the implementation roadmap:
|
A
B
C
D |
E
F
H
I
L |
O
P
R |
S
T
Z |
Release notes
03 Nov, 2026. Released the GA version of the
gculpyccompiler (v0.2.0), including support for cross-contract calls, on-ledger currency transfers and payments,@gcul.readonlymethods,list, additional control-flow statements, and built-in functions.28 Jan, 2026. Early version of
gculpyccompiler made available to participants in the Universal Ledger private preview. For a tutorial using the compiler, see Deploy a programmable contract.