Testnet launching soon!Join the Community

Guides

Introduction to Pyro

Pyro is a statically-typed, high-level programming language that is used to write asynchronous smart contracts that run on Firechain. Pyro is designed to be easy to learn and use, while also being powerful and expressive. Pyro is a compiled language, which means that it is compiled to bytecode before it is executed by the Async VM.

Work in progress

The Pyro language is still experimental and under active development. This page is a work in progress, and will be updated as the language evolves.

Types

Pyro is a statically-typed language, which means that the type of every variable and expression is known at compile time. Pyro has a rich type system, including support for primitive types, user-defined types, and collection types. Certain types can interact with each other, and the compiler will ensure that these interactions are valid. For example, the compiler will ensure that you cannot add a number to a string, but it won't stop you from multiplying a fixed-point number by an integer.

Note that the compiler will not perform any implicit type conversions, so you must explicitly convert between types if you want to do so. For example, if you multiply a fixed-point number by an integer, the result can only be assigned to a fixed variable. You could, however, explicitly cast the result to an integer type (which may result in truncation).

This section will introduce the types that are available in Pyro.

Primitive Types

Pyro has a number of primitive types that are built-in to the language. These types are used to represent numbers, strings, booleans, and other basic types. The following table lists the primitive types that are available in Pyro.

  • bool - A boolean value, either true or false.
  • int - A signed integer value.
  • uint - An unsigned integer value.
  • fixed - A fixed-point number.
  • ufixed - An unsigned fixed-point number.
  • address - An address value.
  • byte - A byte value.
  • string - A string value.
  • void - A special type that represents the absence of a value.

Collections

Pyro also has a number of types that are used to represent collections of values. The following table lists these types that are available in Pyro.

  • bytes - A byte array. Length may be fixed or dynamic.
  • tuple - A tuple of values. Values can be of any type.
  • array - An array of values. Values can be of any type. Length may be fixed or dynamic. Order is not guaranteed.
  • Set(T) - A unique set of values. Supports any type[^1], but all values must be of the same type. Length may be fixed or dynamic. Order is preserved.
  • Map(K, V) - A map from keys to values. Keys are unique and can only be primitive types, while values can be of any type. Order is not guaranteed.

User-Defined Types

Finally, Pyro has support for custom types defined using the type keyword. For example, the following code defines a new type called Point that represents a point in 3D space using three fixed-point coordinates.

type Point {
  x: fixed
  y: fixed
  z: fixed
}

User-defined types can be used to define the typing of variables, function parameters, function return values, broadcasted events, or even fields of other types.

Variables

Variables are used to store ephemeral values during the current execution context. Variables can be declared using the local keyword as shown in the following example.

function foo() {
  local x: int = 1;
  local y: int = 2;
  local z: int;
}

In the above code, the types of x, y and z are all defined as int. The compiler will ensure that the assigned values (x = 1 and y = 2) are compatible with their defined types. The compiler will also automatically assign a default value to z based on its type (in this case z = 0). All three of these variables are ephemeral, meaning that they will be destroyed when the function returns.

Variables cannot be declared without an explicit type at this time. The compiler will be able to infer the type of a variable based on usage in the future, but this feature is not yet implemented.

Entities

Entities in Pyro are analogous to classes in popular object-oriented languages and contracts in Solidity. Entities can have persistent storage and functions that perform arbitrary logic. They can emit observable events and define listeners that runs in response to various triggers. Entities can also define jobs, a type of function scheduled to run at a specific cadence.

For example, the following code defines an entity called Ticker that has a single field called value that is used to store the current value. The entity also defines a function called tick that updates the value of the counter, and an observable event called Tick that is emitted whenever the value changes.

entity Ticker {
  public $value: fixed;

  observable Tick(from: fixed, to: fixed);
  
  @protected function tick(amount: fixed): void {
    assert(amount > 0, "Amount must be greater than 0");
    broadcast Tick(from: $value, to: $value += amount);
  }
}

Entities can also inherit from other entities. Here is an example of an entity that inherits from the Ticker entity defined above and overrides the tick function. It multiplies the supplied value by a random number and lets the parent implementation take over from there by calling super, which refers to the parent entity's original implementation of the function.

entity RandomTicker @inherits(Ticker) {
  @protected function tick(amount: fixed): void {
    super(amount * @context.rand());
  }
}

Overrides are `super` powerful

It's possible to call super at any point in the overridden function, which allows the child to perform arbitrary logic before and/or after the parent's implementation. It's not possible to call super more than once in an overridden function, however this restriction may be lifted in the future.

It isn't required to call super in an overridden function, but it is a good practice to defer as much as possible to the parent's implementation.

The @context is a special object that is available in all functions and provides access to the AVM's current execution context. The @context.rand() function is defined by the Async VM and returns a secure random unsigned integer. We'll discuss this and other functionality surfaced by @context in more detail a bit later.

Storage

Storage is used to store persistent state variables that are associated with an entity. In general, the values that are stored by an entity can only be written by that entity (with a few exceptions). All storage is technically accessible to any account, however the AVM won't load a remote entity's storage during execution. This means that entities cannot access the storage of other entities except through asynchronous calls.

Why not load remote storage inline?

This is a design decision that was made to ensure that messages can be processed in parallel without running into all sorts of weird gotchas that would severely limit the scalability of the network.

For example, consensus groups effectively shard the network into multiple independent clusters. If a validator was asked to load storage it didn't already have, it would need to fetch it over the network before proceeding to process the message. What if the data can't be found? Does it not exist, or should you wait? Not only would this introduce a lot of latency into the system, it would also add quite a bit of complexity to message routing and the consensus algorithm.

By only fetching remote data via the standard messaging flow, the consensus algorithm can be kept simple and the network can scale to an arbitrary number of validators without introducing unexpected bottlenecks and potential attack vectors.

It's important to note that storage is always accessible from "outside" of an execution context. This means that you should not rely on storage being kept secret. It's a good idea to assume that any on-chain storage can be read by any account at any time.

Storage variables are prefixed with the dollar sign ($) by convention. The following table lists the types of storage that are available in Pyro.

  • public - Public storage can be read by any entity.
  • protected - Protected storage can be read by the entity and its descendants but only written by the defining entity.
  • private - Private storage is not yet implemented but will enable storage access to be restricted to the defining entity.

For example, the Ticker code sample above declares a single public storage variable called $value which stores the current value as a fixed-point number.

entity Ticker {
  public $value: fixed;
}

Secrets and Storage

The Firechain network is designed to be public, and all storage is technically accessible to anyone. This is a core design principle of Firechain, and it is not going to change. If your use case demands that you store sensitive information, you currently have two options: encrypt it off-chain and only commit the encrypted copy (or a hash) to on-chain storage, or opt-in to a specialized consensus group that provides encrypted and/or restricted access storage.

Firechain's roadmap includes a so-called Vaults featureset which enables accounts to store sensitive information on-chain in such a way that the contents are provable but not publicly accessible. However, it's not yet assigned to a specific sprint as there are a number of blockers to overcome before truly secure on-chain storage can be implemented. We do not currently have a timeline for availability.

Functions

Entities can define functions that perform arbitrary logic. They can act on behalf of the entity, including broadcasting events, operating on the entity's storage, scheduling jobs, or sending messages to other entities. Function visibility can be controlled through the @private, @protected, or @public visibility annotations. The default visibility is @public.

Visibility

The default function visibility is @public, meaning that the function is callable by anyone. The @protected annotation restricts the function to being called by the entity or its parents and descendants. The @private annotation restricts the function to the entity itself, and it also restricts descendants from overriding its logic.

Entities can't change the visibility of an overridden function, but it can add additional logic to further restrict access. For example, the following code again extends our Ticker example, this time restricting the tick function to the entity's creator. This means that the function can only be called by the entity's creator, despite the @protected visibility which would have otherwise allowed it to be called by any of the entity's descendants as well.

entity ProtectedTicker @inherits(Ticker) {
  @protected function tick(amount: fixed): void {
    assert(
      @context.caller == @context.creator,
      "Only the creator can call this function"
    );

    super(amount);
  }
}

Parameters

Functions can accept parameters, which are defined using the name: type syntax. It's not currently possible to define default values for parameters, but this is planned for a future version of Pyro. All parameters are required to be defined by the caller until Pyro fully supports default values.

Return Values

Functions can return a single value, which is defined using the : type syntax. It's not currently possible to return multiple values, but you can return a tuple or array instead. Functions whose return value is unspecified are implicitly void, and void functions cannot return anything.

Function Calls

Calling a function is done one of a few ways, depending on the calling context. The caller must have the appropriate visibility to call a function, otherwise the call will fail. Functions can be called on-chain in two ways:

  • func() - Calls a local function which is defined or inherited by the current entity. This type of function call runs synchronously, i.e. it is interpreted as a JUMP rather than creating a new message.
  • T(address).func() - Creates a new message to another entity requesting execution of the given function. T is the entity type, and address is the entity's account address. This is an asynchronous call. If the caller is not an entity that implements T (or, more specifically, doesn't handle the message as expected), any response must be manually handled by the caller.

Be safe out there, anon

Relying on the implementation of a function can be dangerous if the caller is not a known entity. This is because the message will be handled by the caller's entity implementation, which can literally be anything. There's currently no way to guarantee that a remote account implements a specific function.

The AVM will attempt to cast the result to the expected return type, but this can lead to unexpected behavior. For example, if your code expects a fixed value but the actual response is of type bytes, the AVM will try to cast the bytes to fixed. This could potentially fail (i.e. if the bytes value is not a valid fixed value) in which case the result will be erroneously 0.

This is not a bug, but it is a "gotcha" that should be addressed down the road.

Events

Entities can broadcast events, and they can define functions that run in response to events. For example, an entity can register a listener for a specific event broadcasted by another entity. It isn't important to know the full implementation of the entity that is broadcasting the event, but it is necessary to know the entity's address and the event's name and parameters in order to register a listener. Currently, it's only possible to register listeners at creation due to the registration mechanism, but we're working toward support for dynamic listeners.

Whenever a listener function is invoked, the @context.caller is the entity handling the event, and the @context.sender is the broadcast source. Usually the broadcaster is an entity, but it can also be a non-entity account.

The following code sample shows how to register a listener for the Tick event that is broadcasted by a Ticker entity at the given address. The listener function is invoked with the same parameters as the event, and it is fired asynchronously.

abstract entity Ticker {
  observable Tick(from: fixed, to: fixed);
}

entity TickWatcher {
  public $ticker: Ticker = Ticker(0x021f400f);
  public $history: Map(time: uint, value: fixed);

  @trigger($ticker, "Tick")
  listener onTick(from: fixed, to: fixed) {
    $history.set(@context.msgTime, to);
  }
}

In the above code, the TickWatcher entity registers a listener for the Tick event emitted by an entity with the public address 0x021f...400f. When the Tick event is emitted, the TickWatcher entity adds the value to its own $history mapping. The @context.msgTime expression evaluates to the time at which the event was emitted.

Stale listeners?

In the example above, we registered a listener for the Tick event that is emitted by the Ticker entity at the given address. But what if we want to switch to tracking a different Ticker entity? It's possible to observe a different entity by reassigning the variable referenced in the listener definition, but there are a few...quirks, if you will.

For example, you'll need to manually re-register your entity's listeners after reassignment. The only way to do that right now is to freeze() and subsequently unfreeze() the entity, which effectively registers the entity's listeners again. But it doesn't prune existing listeners, so the old listener will still be registered for the old entity, and you'll receive events for both. This is an issue that we're aware of, and we're working on a solution.

Jobs

Entities can have functions that run at a specific cadence. These functions are called jobs and are declared using the job keyword. For example:

entity TickWatcher {
  public $history: Map(time: uint, value: fixed);

  @cadence(300) @tip(100 sparks)
  job analyze() {
    # targets one run every 300 seconds
    # pays 100 sparks to incentivize the job
  }
}

In this example, the TickWatcher entity schedules a job called analyze to run every 300 seconds. The @tip annotation specifies that the job should be paid 100 sparks for each run.

Jobs can currently only be scheduled at creation, but we're working toward support for dynamic scheduling.

Context

Entities can access the current execution environment using a special @context object.

The following properties are available on the @context object:

  • @context.caller: address - The entity that is handling the current message. This is the entity that is executing the current function. If the current function is a listener, this is the entity that is handling the event. If the current function is a job, this is the entity that is running the job.
  • @context.sender: address - The entity that sent the current message. This is the entity that is executing the current function. If the current function is a listener, this is the entity that is handling the event. If the current function is a job, this is the entity that is running the job.
  • @context.time: uint - The current time. This is the time at which the current function is being executed. Does not change during the execution of a function unless an async call is awaited.
  • @context.msgTime: uint - The time at which the current message was sent. This is the time at which the current function was called. If the current function is a listener, this is the time at which the event was emitted. If the current function is a job, this is the time at which the job was scheduled.

Additionally, the following functions are available on the @context object:

  • @context.create<T>(...args?): address - Creates a new entity of type T and returns the address of the new entity. The args are passed to the entity's constructor. The function is asynchronous, and the address of the new entity is deterministically derived from the message context. If awaited and deployment fails, the function will throw an error.
  • @context.send(to: address, amount: uint, token?: address): void - Sends the given amount of the given token to the given address. If no token is specified, the native token is used. This function is asynchronous, and the message can be rejected by the receiver. Tokens are paid from the current account.
  • @context.tip(amount: uint, token?: address): void - Tips the current message's validator. This is a convenience method that simply calls send with the validator's preferred payout address.
  • @context.rand(seed?: bytes): uint - Returns a random number between 0 and 2**256-1 (inclusive). The random number is generated using the Async VM's VRF and seeded with the current message's context plus an optional seed. This function returns synchronously. The random number is deterministically derived, meaning that the same message will always return the same random number unless a different seed is passed.
  • @context.schedule(message: Message, time: uint): uint - Schedules the given message to be executed at the given time. The message can be a function call or a job. Returns the ID of the scheduled message.
  • @context.cancel(id: uint): void - Requests the scheduled message with the given ID be canceled. The message must be a job registered by the current entity. Does nothing if the message is already executed or canceled.
  • @context.freeze(): void - Freezes the current entity. The entity will no longer be able to send messages, and any schedule jobs will be paused. The entity will still be able to receive messages. The entity can be unfrozen using the unfreeze function.
  • @context.unfreeze(): void - Unfreezes the current entity. The entity will be regain the ability to send messages and scheduled jobs resume.

Annotations

Pyro supports annotations that can be used to modify the behavior of entities and their functions. Annotations are declared using the @ symbol and must be placed immediately before the entity or function declaration. Annotations can be declared in any order, and they can be declared multiple times. Annotations can have parameters using the same syntax as a function call.

Supported annotations include:

  • @cadence(seconds: uint) - Specifies the cadence of a job. The job will be executed every seconds seconds. This annotation is required for jobs and does nothing for other functions.
  • @tip(amount: uint, token?: address) - Specifies a tip to be paid to the validator upon execution. The tip will be paid for each execution. If no token is specified, the native token is used. This annotation can be used on jobs and externally visible functions.
  • @trigger(address: address, event: string) - Specifies that the function should be called when the given event is broadcast by the given address. This annotation is required for listeners and does nothing for other functions.
  • @private - Marks a function as private. Private functions can only be called by other functions in the same entity.
  • @public - Marks a function as public. Public functions can be called by any entity.
  • @protected - Marks a function as protected. Protected functions can be called internally, or by the parent and descendants of the current entity.
  • @view - Specifies that a function is read-only and does not modify the state of the entity. Applies only to externally visible functions. Currently, this annotation does nothing, but it will be used in the future to optimize execution and reduce the amount of heat generated when the function is called.
Previous
Understanding Heat