Skip to content
Document

Programmers Guide to smart contracts

This page is a quick overview of the software development kit for Partisia Blockchain.

Smart Contract Overview

A smart contract on Partisia Blockchain consists of, on a surface level, some state, and some actions defined to either operate on the state and/or to interact with other contracts.

Consider for example a basic public voting contract:

State:

  • What are we voting on? (if applicable)
  • Who are allowed to vote?
  • Deadline (if applicable)
  • Who have voted?
  • Are we done yet?
  • What is the result if we are done?

Actions:

  • Voters should be able to vote.
  • Anybody should be able to retrieve how many votes have been cast, and whether the vote is complete yet.
  • Anybody should be able to retrieve the result of the vote.

Initializer:

  • Vote subject, Voters and Deadline are all permanent attributes of the vote, and so should be set in the initializer.

Contracts' state and actions must be declared in an Contract ABI file; a concise description of the contract's interface and internal state representation, that must be uploaded together with the contract code when initializing the contract. Without an ABI file, it might be impossible for the dashboard and other contracts to interact with your contract.

The PBC compiler is capable of automatically producing an ABI for your contract, along with state and RPC serialization code for your actions.

Macros

Smart contract elements can be declared using these macros:

  • #[state] declares how the contract represents its state.
  • #[action] declares an endpoint that the contract can be interacted with by.
  • #[init] declares the code to run when the contract is initialized.
  • #[callback] declares the code to run after a corresponding action has been called.

#[state]

Declares that the annotated struct is the top level of the contract state. This macro must occur exactly once in any given contract.

Example:

#[state]
pub struct VotingContractState {
    proposal_id: u64,
    mp_addresses: Vec<Address>,
    votes: SortedVecMap<Address, u8>,
    closed: u8,
}

This macro implicitly derives the ReadWriteState trait for the struct. The ReadWriteState derive may fail if any of the state struct's fields aren't impl ReadWriteState.

Further reading: state macro documentation

#[action]

Declares that the annotated function is a contract action that can be called from other contracts and dashboard. Must have a signature of the following format:

#[action]
pub fn action_internal_name(
  context: ContractContext,
  state: ContractState,
  ... // RPC arguments.
) -> (ContractState, Vec<EventGroup>)

The action receives the previous state, along with a context, and the declared arguments, and must return the new state, along with a vector of EventGroup; a list of interactions with other contracts.

Example:

#[action]
pub fn vote(
    context: ContractContext,
    state: VotingContractState,
    vote: u8,
) -> (VotingContractState, Vec<EventGroup>) {
  // Code...
}

Further reading: action macro documentation

A note on functional contracts

Contracts are functional: Each interaction point, whether init or action take some input, and return some output. Interactions cannot produce side effects, visible or not. The state will thus not be changed should a transaction fail while running the contract code, whether due to panics or insufficient gas.

Of further interest here, is that the entire contract is essentially "reset" after every interaction. Any static mut items will possess their initial value, once again. The only state your contract can possess is the state returned from interactions.

#[init]

Similar to #[action] macro, but declares how the contract can be initialized.

#[init]
pub fn initialize(
    context: ContractContext,
    ... // Initialization arguments
) -> (VotingContractState, Vec<EventGroup>) {
  // Code...
}

Note that there are no previous context when initializing, in contrast to the action macro. All arguments will be required during initialization. This macro must occur exactly once in any given contract. If the initializer fails the contract will not be created.

Example:

#[init]
pub fn initialize(
    context: ContractContext,
    proposal_id: u64,
    mp_addresses: Vec<Address>,
) -> (VotingContractState, Vec<EventGroup>) {
  // Code...
}

Further reading: init macro documentation

#[callback]

Has to be linked with an #[action] macro in order to be called. In practice this is done by combining the implicitly created constant of the corresponding callback-annotated function with a call to an event group builder's with_callback() function.

#[action]
pub fn call_bob(
    context: ContractContext,
    state: ContractState,
    address: Address
) -> (ContractState, Vec<EventGroup>) {
    // 0x42 being the shortname of the action to call at 'contract' Bob
    let SHORTNAME_CALL_BOB: Shortname = Shortname::from_be_bytes(&[0x42]).unwrap();
    let mut event_group_builder = EventGroup::builder();

    event_group_builder.call(address, SHORTNAME_CALL_BOB)
                       .done();

    // Ask for callback. Notice that the constant provided has not been explicitly created.
    event_group_builder.with_callback(SHORTNAME_CALL_BOB_CALLBACK)
                       .argument(some_argument)
                       .with_cost(10000)
                       .done()

    (state, vec![event_group_builder.build()])
}

#[callback(shortname = 13)]
pub fn call_bob_callback(
  contract_context: ContractContext,
  callback_context: CallbackContext,
  state: ContractState,
  // ... RPC arguments
) -> (ContractState, Vec<EventGroup>) {
  assert!(callback_context.success, "Bob failed to reply");

  // Code to run after executing the external action 0x42...
}

Further reading: callback macro documentation

Traits

The compiler exposes traits that provides serialization methods. These traits are important for the operation of PBC contracts, but should rarely be implemented manually; prefer using the built-in derive methods.

Data Structures

Address

Address represents an address on the blockchain; it has a subfield indicating the type of the address (account, system contract, public contract or zk contract).

ContractContext

ContractContext is available from every action, and contains some useful context information for the current transaction:

  • Address of the contract itself and the caller; the person or contract that caused the interaction.
  • The current block number, and time since some epoch.
  • Hashes of both the current transaction and the previous transaction.

Events

Partisia Blockchain's contract interaction model sandboxes each contract, and allows RPC calls as the primary form of interaction. As each transaction is entirely isolated, RPCs can only occur "between" action calls.

For example:

  • Zeno calls Alice in transaction 1: Alice determines she needs some information from Bob, and exits while telling the blockchain: "Call Bob for me, I want a reply, and let me pay for the reply"
  • Alice calls Bob in transaction 2: Bob performs it's computation, and exists with "Here's the reply to Alice, she said she'd pay for this reply".
  • Bob calls Alice in transaction 3: Alice finally got the necessary information to perform her own computation...

To accommodate this model, the compiler requires each action annotated function to return a (possibly empty) Vec of EventGroups, which represents the "Call X for me" information.

Each EventGroup consists of one or more interactions (representing "Call X for me",) with the possibility of callbacks (representing "I want a reply") or return data (representing the optional data to be passed along with a reply) but never both at the same time. All interactions in an EventGroup shares gas costs uniformly.

To see how Bob might pass along some return data, see the following example:

#[action(shortname=0x42)]
pub fn provide_information_for_alice(
  context: ContractContext,
  state: ContractState
) -> (ContractState, Vec<EventGroup>) {
  let mut event_group_builder = EventGroup::builder();
  event_group_builder.return_data(state.information);

  (state, vec![event_group_builder.build()])
}

The information in state.information will be serialized and passed along to Alice's CallbackContext, where she may retrieve it for whatever use-case she has with it:

#[callback(shortname = 13)]
pub fn call_bob_callback(
  contract_context: ContractContext,
  callback_context: CallbackContext,
  state: ContractState,
  // ... RPC arguments
) -> (ContractState, Vec<EventGroup>) {
  assert!(callback_context.success, "Bob failed to reply");

  // Index into the information related to the first event
  let first_event = callback_context.results[0];

  // Provide the expected type of the returned data and return it.
  let bobs_information = first_event.get_return_data::<expected_type>();

  // Do something with Bob's information...
}

Further reading: events module documentation

CallbackContext

The additional context struct that all callback-annotated functions receive as a parameter. It holds information about all events in the EventGroup linked to the callback. This information denotes whether all events successfully executed, the individual execution success-status of each event and optional return data linked to the event.

Further reading: CallbackContext struct documentation

State serialization gas considerations

SortedVecMap<T> and SortedVecSet<T> (specifically for CopySerializable T) allow for efficiently (de)serializable map and set structures, both in terms of gas and computation time. Remember that (de)serialization gas costs must be paid for every action, even ones that never handle state.

Ensure you depend upon and link against the pbc_lib crate. This should automatically lower gas costs.

The compiler exposes cheaper (in terms of gas) versions of the memcpy and memmove functions. These functions are commonly used for copying bytes around directly, but are (thankfully) rarely manually used in Rust, though they may still occur in compiled programs due to lower-level libraries and compiler optimizations. Your compiled contracts will be using memcpy for (de)serialization, hence why the compiler defines these alternatives.

The pbc_lib crate overwrites these functions with versions that directly interact with the PBC WASM Interpreter to trigger the interpreter's built-in support for quickly copying data around.

Further reading: pbc_lib crate documentation