Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

introduce an example ERC20-like token actor #290

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

raulk
Copy link
Member

@raulk raulk commented Jan 21, 2022

This is an extremely rough-and-ugly implementation of an ERC20-like native token actor for the Filecoin network. It uses no SDK sugar (because none exists yet), neither for syscalls nor or error handling.

Supported features

The current code supports token symbols, names, maximum supplies, and simple transfers. It DOES NOT YET support authorizations for delegate spending.

On developer experience

As stated above, this is code is still ugly. The developer experience will improve dramatically as the FVM project advances towards Milestone 2.

The author deliberately didn't use this opportunity to improve the SDK. Instead, he chose to implement things low-level to create a baseline of where we are. As we introduce better DX, this actor will evolve and benefit.

NOTE: There is some pre-existing sugar in the actor/runtime module that could facilitate things slightly easier here, but this actor does not use it to avoid dragging in unnecessary dependencies (related to built-in actors). The upcoming sugar will eventually be provided by the Rust SDK, and not the built-in actors toolkit.

Boilerplate

The following responsiblities are taken care of here, but they are absolutely boilerplate, and should eventually be superseded by clever sugar in the form of proc macro attributes.

  • State loading.
  • State mutations.
  • Method dispatch.
  • Deserialization of parameters.
  • Serialization of return data.

Addressing

For simplicity, this actor uses ActorIDs in the balances and allowances HAMT (map) keys. This poses two problems:

  1. A sender cannot send tokens to an inexistent account actor.
  2. This actor is not reorg-safe (ActorIDs can change in the chain gets reorg-ed).

We could do sophisticated things here to solve for these problems, by associating balances to class-{1,2,3} addresses only, and rejecting calls with class-0 (ID addresses).

However, all of this will change when we introduce class-4 addresses (reorg stable universal addresses), so it's not worth the complexity now, as this actor only exists for illustration purposes.

⚠️ 🐉 🔥 ☠️ NOT MEANT FOR PRODUCTION USAGE

This actor should NEVER be deployed on the Filecoin network. It is purely meant for illustration and test purposes.


TODO

  • Integration hooks in the FVM to load custom code (test mode only).
  • Integration tests that deploy the actor into the FVM and play with it.

This allows custom actors to use a blockstore directly with, for example,
AMT and HAMT data structures.

The ActorBlockstore delegates to the SDK blockstore, and translates errors
to exit codes, thus preserving the previous interface.
(Extracted and slightly reworded from the actor's module docs)

This is an extremely rough-and-ugly implementation of an ERC20-like native
token actor for the Filecoin network. It uses no SDK sugar (because none
exists yet), neither for syscalls nor or error handling.

The current code supports token symbols, names, maximum supplies, and
simple transfers. It DOES NOT YET support authorizations for delegate
spending.

As stated above, this is code is still ugly. The developer experience will
improve dramatically as the FVM project advances towards Milestone 2.

The author deliberately didn't use this opportunity to improve the SDK.
Instead, he chose to implement things low-level to create a baseline of
where we are. As we introduce better DX, this actor will evolve and benefit.

NOTE: There is _some_ pre-existing sugar in the actor/runtime module
that could facilitate things slightly easier here, but this actor does not
use it to avoid dragging in unnecessary dependencies (related to built-in
actors). The upcoming sugar will eventually be provided by the Rust SDK,
and not the built-in actors toolkit.

The following responsiblities are taken care of here, but they are
absolutely boilerplate, and should eventually be superseded by clever sugar
in the form of proc macro attributes.

- State loading.
- State mutations.
- Method dispatch.
- Deserialization of parameters.
- Serialization of return data.

For simplicity, this actor uses ActorIDs in the balances and allowances
HAMT (map) keys. This poses two problems:

1. A sender cannot send tokens to an inexistent account actor.
2. This actor is not reorg-safe (ActorIDs can change in the chain gets
   reorg-ed).

We _could_ do sophisticated things here to solve for these problems, by
associating balances to class-{1,2,3} addresses only, and rejecting calls
with class-0 (ID addresses).

However, all of this will change when we introduce [class-4 addresses](https://github.com/filecoin-project/fvm-specs/blob/main/04-evm-mapping.md#proposed-solution-universal-stable-addresses)
(reorg stable universal addresses), so it's not worth the complexity now,
as this actor only exists for illustration purposes.

This actor should NEVER be deployed on the Filecoin network. It is purely
meant for illustration and test purposes.
@raulk raulk force-pushed the raulk/example-token-actor branch 4 times, most recently from 23dc505 to 3a90178 Compare January 21, 2022 20:07
Guarded by the custom_actors Cargo feature.
"failed to set new recipient balance in balances hamt: {:?}",
err
)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will print tokens from thin air if the recipient is the sender. Writing the recipient balance overwrites the reduced sender balance.

An example of a safe sequence is https://github.com/filecoin-project/specs-actors/blob/master/support/vm/vm.go#L546

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well spotted. Fixed in 59cc833 (#290), along with other logic changes.

The goal of this exercise is not to achieve complete safety, but rather to illustrate the current state of deploying custom actors for local testing. But it's good to know that we have a safe sequence others can use as a reference.

@raulk raulk marked this pull request as ready for review January 25, 2022 00:30
use ipld_hamt::Hamt;

/// A macro to abort execution by signalling a non-zero exit code.
macro_rules! abort {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should be in a library.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openzeppelin but make it FVM

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, these are just basic local macros because I'm not convinced this is what would ultimately go in an SDK in this particular shape. So I'm not inclined to "bless it" at this stage.

macro_rules! encode {
($ex:expr) => {
match RawBytes::serialize($ex) {
Ok(ret) => ret,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also should be in a library

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want something nicer than this in a library. I.e., abstractions sends that auto-serialize/deserialize.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, and agree with @Stebalien. The code in this actor is deliberately dumb and low-level, a explained in the PR description, because the SDK needs careful thought.

#[serde(with = "bigint_ser")]
pub max_supply: TokenAmount,
// map[ActorID] => TokenAmount
pub balances: Cid,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we planning to just use tokenamounts here? no specification of decimals? I think it might be smart to make this a little bit more ERC20 compatible, especially if we want a defi ecosystem possibly getting built on FVM... the possibility of floating point issues in conversions down the road haunts me

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah on further thought it's probably smart to have a decimals field added here too, unless I'm misunderstanding something deeply

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. I've been looking into the translation of Eth token standards to FVM. @laudiacay would you like to work with me on defining an ERC20-like in a FIP?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could? this is already exactly that modulo decimals and approved

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK decimals is actually an optional extension to ERC20. And yes, I agree it should go here because it makes the UX token nicer.

// TODO should never use ActorID as allowees, since it's an unstable address
// make sure to check for this
// map[ActorID] => map[Address]TokenAmount
// pub allowances: Cid,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are we planning to go from "we have like 4 kinds of addresses" to "we have one kind of address that we can do lookup tables over"? is there some other speedy solution for affiliating multiple cryptographic identities and related permission delegation that we're thinking about?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coming back to this after some comments further down: is there any design possibility of bonking everything except some curve's pubkeys (which function as the only UIDs in the system) completely out of userland? I need to look at how the syscall boundary is functioning right now to say anything intelligent about this, but it'd probably be really nice for developers if this was a thing we could do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently everything unifies to an "id" address. I'd like to make all syscalls except "resolve_address" take an ID address, but, unfortunately, send also needs to be able to take a public-key address because sending to an account that doesn't exist magically creates that account.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if a failed resolve creates an ID and returns, and you just resolve in userspace before the send?

Copy link
Member Author

@raulk raulk Jan 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID addresses are not the solution because they are reorg-unstable. Because ID assignments can be reorged away, it is not safe to operate with a newly created ID address (e.g. an account actor, a miner, a payment channel) until finality (900 epochs). Otherwise, you could end up sending tokens to an address that gets reorg'ed away from you.´

The solution is to introduce class 4 addresses -- a universal, stable namespace for addresses: https://github.com/filecoin-project/fvm-specs/blob/main/04-evm-mapping.md#proposed-solution-universal-stable-addresses.

But we don't have those yet; that's why the token actor here uses the only other universal address option we have: the ID address, noting the caveat in various places in the code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID addresses are not the solution because they are reorg-unstable. Because ID assignments can be reorged away, it is not safe to operate with a newly created ID address (e.g. an account actor, a miner, a payment channel) until finality (900 epochs). Otherwise, you could end up sending tokens to an address that gets reorg'ed away from you.´

This isn't generally true. You don't want to use an ID address in a message, but state isn't re-org stable anyways. I.e.:

  1. Use some form of stable address in a message.
  2. Resolve to an ID address on-chain.
  3. Use the ID address in on-chain datastructures.


// Conduct method dispatch. Handle input parameters and return data.
let ret: Option<RawBytes> = match sdk::message::method_number() {
1 => Some(name(state)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ought to have some kind of enum here, probably

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise/regardless, possibly ought to make this dispatch table clearer somehow... right now it takes me a second of staring at the code to determine what each of the method numbers is doing

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preferred sugar for dispatching would be attribute macros.

Some(cid) => {
/// The multicodec value for raw data.
const IPLD_CODEC_RAW: u64 = 0x55;
// TODO this is embarrassingly wrong, as hardcoding the actor version doesn't allow evolution.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed yikes

this could be wrapped in some kind of shiny clean "is_an_account" function for developers, or we could not expose types of things that should never have ERC20s sent to them to developers.

or, we can just go totally hands-off, remove this check, and let them send erc20s to whatever they want to. that's how EVM does it, and developers are just expected to not be dummies about it and only send ERC20s to accounts that can handle them. transfer to the zero account or to contracts with no ability to ever withdraw or approve spends is not something you'd ever think to forbid in a basic ERC20 example. here, you aren't blocking sends BURN_FUNDS_ADDRESS, so there's something resembling a case for "why bother blocking sends to miner actors"?


// Update balances.
sender_bal.0 -= &params.amount;
recipient_bal.0 += &params.amount;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do tokenamounts overflow? they're bigints, i guess not?

examples/token/src/lib.rs Show resolved Hide resolved
}

#[test]
pub fn test_fail_transfer_to_non_account_actor() -> Result<()> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these tests look fine to me for the current code... gonna need to add more to them when we add approvals, though

// TODO should never use ActorID as allowees, since it's an unstable address
// make sure to check for this
// map[ActorID] => map[Address]TokenAmount
// pub allowances: Cid,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/edit

this frontrunning attack- eventually do we want to change the approval interface to their suggestion? alternatively, the erc20 token standard has some guidance for how userspace apps should be designed in order to prevent this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just had a similar discussion about a related flow in this FIP draft about authorizing a beneficiary address to receive some quota of miner rewards. The solution there was to record two numbers in state.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-field CAS method appears more state-space-efficient, whereas the two-fields solution has better usability properties in terms of reporting.

@laudiacay I think implementing the CAS method makes sense here when we implement approvals.

macro_rules! encode {
($ex:expr) => {
match RawBytes::serialize($ex) {
Ok(ret) => ret,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want something nicer than this in a library. I.e., abstractions sends that auto-serialize/deserialize.

// map[ActorID] => TokenAmount
pub balances: Cid,
// TODO should never use ActorID as allowees, since it's an unstable address
// make sure to check for this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely use ActorID in state, we just shouldn't use them in messages. That is:

  1. User submits a message with some actor.
  2. We resolve it on-chain.
  3. We then store the ID in state.

If we re-org, we'll re-do this operation and store the new ID. The upside is that actor IDs are usually slightly cheaper to work with.

// TODO should never use ActorID as allowees, since it's an unstable address
// make sure to check for this
// map[ActorID] => map[Address]TokenAmount
// pub allowances: Cid,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently everything unifies to an "id" address. I'd like to make all syscalls except "resolve_address" take an ID address, but, unfortunately, send also needs to be able to take a public-key address because sending to an account that doesn't exist magically creates that account.

4 => {
let params: TransferParams = match params_cbor(params_id) {
Ok(params) => params,
Err(err) => abort!(ErrIllegalArgument, "failed to parse params: {:?}", err),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That and/or we can provide a custom panic handler (https://doc.rust-lang.org/nomicon/panic-handler.html) that exits with the "right" exit code.

/// The actor's WASM entrypoint. It takes the ID of the parameters block,
/// and returns the ID of the return value block.
#[no_mangle]
pub fn invoke(params_id: u32) -> u32 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have a lot of fun with the sugar:

#[derive(Serialize_tuple, Deserialize_tuple)]
type Actor {
    name: String,
    value: u64,
}

#[fvm_actor]
impl Actor {
    // Implicitly reads the state.
    #[fvm_export = 2]
    fn name(&self) -> &str { // takes nothing, returns a string
        &self.name
    }

    // Implicitly reads the state and writes it back
    #[fvm_export = 3]
    fn add(&mut self, value: u32) -> u32 { // takes a u32, returns a u32
        self.value += value
        self.value
    }

    // Explicitly transform the state. We could use Deref... but that sounds dangerous.
    #[fvm_export = 4]
    fn sub(state: &mut Ctx<Self>, value: u32) -> u32 { // takes a u32, returns a u32
        state.write(|st| {
            st.value -= value;
        });
        state.read(|st| st.value)
    }
}

We'll run into questions like:

  1. Do we want some kind of FVM context object? For sending, syscalls, etc.
  2. What about multiple parameters? Should we always encode parameters as a tuple? (I guess we already do).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is EXTREMELY groovy....... i love the idea of pure actors that just do a state monad....... i love that soooooooo much..................

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, there's tons of exploration to do.

abort!(ErrIllegalArgument, "cannot send to self");
}

// Ensure that the recipient is an account actor; otherwise they will never
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-account actors can definitely spend funds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants