Athena blockchain design and integration

The initial core VM implementation is complete and we’re now moving into the fun part: the blockchain integration. Athena can run RISC-V programs compiled to rv32im and rv32em but those programs can’t yet do anything blockchain-related. While there are many possible host functions that we may want to explore, I think it makes sense to start with a few basic ones for the purpose of the end-to-end prototype. Now is the time to finalize their design, as well as the design of a few related items (e.g., account state).

@talm previously proposed an initial set of host functions; this seems like a good place to start, although I might leave out templates for now, for simplicity:

The desired outcome here is both the actual Athena semantics for performing these actions, as well as what the athena<>host interface looks like (sample).

Read state object

Keep it simple and use a key-value store. For now, as Tal initially proposed, assume that a contract can only read/write its own state. Use a 32-bit word as the key and a 32-bit word as the value. Possible errors include permission denied (probably not relevant in the simplistic case of a contract reading its own state), key not found, and not enough gas. Don’t do any fancy serialization/deserialization in the runtime; the contract is responsible for doing this work, for now.

fn main() {
    // calculate the key as the first 4 bytes of the hash of the key name
    let keyname = "my_number";
    let mut hasher = Sha256::new();
    hasher.update(keyname);
    let result = hasher.finalize();
    let mut key = [0u8; 4];
    key.copy_from_slice(&result[0..4]);

    let value: u32 = 42; // Example integer value

    // needs error handling
    athena_vm::chain::store(&key, value.to_le_bytes())?;
}

Obviously this is a lot of work to calculate the key, but we do this work automatically using helper functions.

Write state object

As above. 32-bit word key, 32-bit word value. Writing to an existing key just overwrites whatever value was already there. Possible errors include permission denied (same caveat), and not enough gas.

fn main() {
    // calculate the key as the first 4 bytes of the hash of the key name
    let keyname = "my_number";
    let mut hasher = Sha256::new();
    hasher.update(keyname);
    let result = hasher.finalize();
    let mut key = [0u8; 4];
    key.copy_from_slice(&result[0..4]);

    // needs error handling
    let value: u32 = athena_vm::chain::read(&key)?;
}

Deploy a new contract (creating a new account)

Host function takes contract code, initialization parameters (immutable state), and an endowment (number of coins). If successful, it returns the newly-deployed contract address, calculated deterministically on the basis of the code, the initialization params, the sender address and nonce. Errors include not enough coins (for endowment), not enough gas, and deployment failed (e.g., code malformed, or init method failed with provided init params).

fn main() {
    let init_params = InitParams {
        param1: 42,
        param2: "Hello, world!".to_string(),
        param3: true,
    };
    let serialized_params = bincode::serialize(&init_params).expect("Serialization failed");

    let endowment: u64 = 42; // Using u64 to represent 42
    let mut endowment_bytes = [0u8; 32];
    let endowment_array = endowment.to_le_bytes();
    endowment_bytes[..endowment_array.len()].copy_from_slice(&endowment_array);

    // Call the create function; needs error handling
    let contract_address = athena_vm::chain::create(&bytecode, &serialized_params, value_bytes);
    println!("Newly deployed contract address: {:?}", contract_address);
}

Call account method

This needs some thought and design. I’m not sure whether we want to allow multiple callable methods per contract, in which case the runtime needs to handle calls, or whether we instead want to allow a single callable “entrypoint” per program (as Solana does) where each entrypoint method has the same generic signature, e.g.:

pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64;

In the latter case, it’s the responsibility of the contract program to handle dispatch on the basis of the input.

Thanks @lane !

Here are some of my not-fully-baked thoughts

State objects

My gut feeling is that 32-bit keys are not enough, and 32-bit values are definitely not enough. I think things will be much easier for developers (and reduce potential security bugs) if we can guarantee a collision-resistant key space, so we’d probably need at least 160-bit keys.

I’m thinking we’ll want to store complex objects as values (even if the serialization/deserialization is done by the client code), so the size of a value should also be larger — maybe even a byte array that has a dynamic size.

For efficiency, maybe we could have a “default object” (e.g., key 0) that is automatically loaded when a contract method is called, so for simple contracts there will only be a fixed loading cost and every additional access to the object is just local memory.

Move-like assets

Here’s an idea of how we could implement assets with (very simplified) move-like semantics, enforced by the VM rather than a static prover.

An asset is a special type of state object whose ownership can be transferred between accounts, combined with asset-specific methods that control access.

An asset type is created when a contract declares the asset type and defines the following methods (please forgive errors in rust syntax, I’m just using it to be more-or-less consistent with your notation):

    // Move the asset to a target account
    fn move_to(id: AssetID, target: AccountID); 

    // "Burn" the asset
    fn drop(id: AssetID);

The contract also declares the asset’s “abilities” (inspired by move):
store: This asset can be held by an account
write: The persistent data for this asset can be written by its owner.
(I’m not sure we need copy, drop or keycopy is a non-asset type, drop is handled by the drop permission method, and for now we don’t have nested data types – but we should look at various use-cases and check)

The id of an asset is the tuple (accountid,assettype,localid), where accountid is an ID of an instance of the contract that declared the asset.

The VM stores, for every asset object, an additional owner field.
The asset-specific methods aren’t callable directly – they require a corresponding host call. When move_to is called for an asset, the VM first checks that the caller actually owns the asset, then calls asset’s move_to method. If it returned without error, it updates the owner field for the asset.
(Note: for assets that require rent, we need to also call an accept_asset method for the receiving account)

Read/write data

Assets can be loaded into memory like other state objects, with the restriction that only the owner of an asset can interact with it. In order to write asset data, either the asset must have the write ability, or the method trying to write must belong to the contract that declared the asset.

Storing and dropping assets

After the transaction execution, the VM checks if any assets that don’t have the store ability are still in existence, and if so fails the transaction.

Dropping an asset works differently depending on whether an asset has the store ability. If it does, then a drop host call will always succeed (we need to work out exactly how to ensure this). Otherwise, you can be stuck paying rent for an asset with no way to get rid of it.
For assets that don’t have the store ability, you’ll never be stuck with the rent, so it’s ok to make drop fail (or even to remove the explicit drop method, and leave only custom contract methods that drop the asset if certain conditions are met).

Fungible Assets

I think these are so common and useful that they probably merit special support, even if they could be implemented as a special case of a more general asset.

A fungible asset has a fixed structure: the only data it holds is a “balance” (we could make this a fixed 64-bit value, although I’m not certain this is the best size). Fungible assets support a transfer host call rather than move_to, where transfer accepts amount instead of a unique asset id.

Fungible assets never have a write ability.

Ideally, we could implement Athena SMH as a special case of a fungible asset.

Parallelizable assets

If we restrict the move_to access-control method so that it’s only allowed to look at data in the asset itself (as opposed to arbitrary account data from the asset-defining contract account), then calls to transfer assets between disjoint accounts won’t conflict, and can be processed in parallel. We might want to have this as an initial restriction, unless there are very compelling use-cases that require the common state (even if there are, we might want fungible assets to have this restriction, or just to allow assets to declare that they are parallelizable and this will let them charge less gas for transfers).

Cross-contract calls and lending assets

The asset constructions above really require at least some cross-contract call capability, since every asset transfer requires a permission check call to another contract.

I think it would be helpful to allow multiple entry points per contract, since the contract also defines a natural “trust boundary” – methods within the contract can trust other methods in the contract. Having every contract include code to do dispatch sounds like it will cause a lot of unnecessary duplication (and we also want the VM to recognize special methods, such as the access-control methods, and verification methods for account abstraction, etc.).

One thing that could be interesting is to allow a read-only “lending” of an asset to another account for the duration of a cross-contract call. This would allow us to easily prove asset ownership and contents, while maintaining the “only owner can access” rule.

For example, suppose I have an asset that holds my “contributor status” for some project . The project wants to allow contributors to claim an NFT based on their contribution level. By “lending” my status asset to the NFT-granting contract, I can allow it to verify that I have the required level.

(Alternatively, we might just want to automatically grant read-only access to all assets owned by the originator of a cross-contract call).

For the purpose of the prototype, let’s just pick a reasonable starting point. I agree that 32 bits is probably too small. 256 bits feels large and would result in a lot of wasted space (EVM has this issue). But we could go with 256 bit keys and 256 bit values for now for simplicity. Changing these in future would not be hard, and in any case the compiler and/or runtime can do some optimization here and pack things more efficiently if necessary. Here’s how it’s done in Ethereum: Layout of State Variables in Storage — Solidity 0.8.26 documentation.

Are we sure we don’t want to handle this in the runtime? Yes, having each capability checked by a cross-contract call would be the most flexible way to do it, but it would also be inefficient and expensive. By contrast if a resource had a permission set (e.g., a set of accounts allowed to borrow, mutate, or burn it), the runtime could perform that check without the added overhead of an additional call.

I agree that 256-bit keys feels a little large, but fine for now. I think values that are dynamic byte arrays would be better than a fixed size (e.g., 1-byte length and 1-256 bytes of value).

That’s true. Maybe it’s enough to just have an all-or-nothing approach at first (i.e., either you can transfer to any account willing to accept, or you can only transfer by calling an asset-associated method directly).

We still need a mechanism to check if an account is willing to accept a rent-requiring asset. But maybe the Diem/Aptos method is ok (IIRC, the account needs to explicitly “opt in” to accept an asset type via a separate transaction).