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

[RFC] Example-driven proposal for Move-based programmability #4

Merged
merged 1 commit into from
Nov 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Best-effort syntax highlighting for Move: just use Rust
*.move linguist-language=Rust
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Rust build directory
/target

# Move build directory
build

.DS_Store

# Thumbnails
Expand Down
16 changes: 16 additions & 0 deletions fastx_programmability/Move.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "FastX"
version = "0.0.1"

[dependencies]
MoveStdlib = { git = "https://github.com/diem/diem.git", subdir="language/move-stdlib", rev = "56ab033cc403b489e891424a629e76f643d4fb6b" }

[addresses]
sblackshear marked this conversation as resolved.
Show resolved Hide resolved
Std = "0x1"
FastX = "0x2"
Examples = "0x3"

[dev-addresses]
Std = "0x1"
FastX = "0x2"
Examples = "0x3"
21 changes: 21 additions & 0 deletions fastx_programmability/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# FastX Programmability with Move

This is a proof-of-concept Move standard library for FastX (`sources/`), along with several examples of programs that FastX users might want to write (`examples`). `CustomObjectTemplate.move` is a good starting point for understanding the proposed model.

### Setup

```
# install Move CLI
cargo install --git https://github.com/diem/diem move-cli --branch main
# put it in your PATH
export PATH="$PATH:~/.cargo/bin"
```

For reading/editing Move, your best bet is vscode + this [plugin](https://marketplace.visualstudio.com/items?itemName=move.move-analyzer).

### Building

```
# Inside the fastx_programmability/ dir
move package -d build
```
59 changes: 59 additions & 0 deletions fastx_programmability/examples/CombinableObjects.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/// Example of objects that can be combined to create
/// new objects
module Examples::CombinableObjects {
use Examples::TrustedCoin::EXAMPLE;
use FastX::Authenticator::{Self, Authenticator};
use FastX::Coin::{Self, Coin};
use FastX::ID::ID;
use FastX::Transfer;
use FastX::TxContext::{Self, TxContext};

struct Ham has key {
id: ID
}

struct Bread has key {
id: ID
}

struct Sandwich has key {
id: ID
}

/// Address selling ham, bread, etc
const GROCERY: vector<u8> = b"";
/// Price for ham
const HAM_PRICE: u64 = 10;
/// Price for bread
const BREAD_PRICE: u64 = 2;

/// Not enough funds to pay for the good in question
const EINSUFFICIENT_FUNDS: u64 = 0;

/// Exchange `c` for some ham
public fun buy_ham(c: Coin<EXAMPLE>, ctx: &mut TxContext): Ham {
assert!(Coin::value(&c) == HAM_PRICE, EINSUFFICIENT_FUNDS);
Transfer::transfer(c, admin());
Ham { id: TxContext::new_id(ctx) }
}

/// Exchange `c` for some bread
public fun buy_bread(c: Coin<EXAMPLE>, ctx: &mut TxContext): Bread {
assert!(Coin::value(&c) == BREAD_PRICE, EINSUFFICIENT_FUNDS);
Transfer::transfer(c, admin());
Bread { id: TxContext::new_id(ctx) }
}

/// Combine the `ham` and `bread` into a delicious sandwich
public fun make_sandwich(
ham: Ham, bread: Bread, ctx: &mut TxContext
): Sandwich {
let Ham { id: _ } = ham;
let Bread { id: _ } = bread;
Sandwich { id: TxContext::new_id(ctx) }
}

fun admin(): Authenticator {
Authenticator::new(GROCERY)
}
}
107 changes: 107 additions & 0 deletions fastx_programmability/examples/CustomObjectTemplate.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/// An example of a custom object with comments explaining the relevant bits
sblackshear marked this conversation as resolved.
Show resolved Hide resolved
module Examples::CustomObjectTemplate {
use FastX::Authenticator::{Self, Authenticator};
use FastX::ID::ID;
use FastX::Transfer;
use FastX::TxContext::{Self, TxContext};

/// A custom fastX object. Every object must have the `key` attribute
/// (indicating that it is allowed to be a key in the fastX global object
/// pool), and must have a field `id: ID` corresponding to its fastX ObjId.
sblackshear marked this conversation as resolved.
Show resolved Hide resolved
/// Other object attributes present at the protocol level (authenticator,
/// sequence number, TxDigest, ...) are intentionally not exposed here.
struct Object has key {
id: ID,
/// Custom objects can have fields of arbitrary type...
custom_field: u64,
/// ... including other objects
child_obj: ChildObject,
/// ... and other global objects
nested_obj: AnotherObject,
}

/// An object that can be stored inside global objects or other child
/// objects, but cannot be placed in the global object pool on its own.
/// Note that it doesn't need an ID field
struct ChildObject has store {
a_field: bool,
}

/// An object that can live either in the global object pool or as a nested
/// object.
struct AnotherObject has key, store {
id: ID,
}

/// Example of updating an object. All Move fields are private, so the
/// fields of `Object` can only be (directly) updated by code in this
/// module.
public fun write_field(o: &mut Object, v: u64) {
if (some_conditional_logic()) {
o.custom_field = v
}
}

sblackshear marked this conversation as resolved.
Show resolved Hide resolved
/// Example of transferring an object to a a new owner. A struct can only
/// be transferred by the module that declares it.
public fun transfer(o: Object, recipient: Authenticator) {
assert!(some_conditional_logic(), 0);
Transfer::transfer(o, recipient)
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Right now the input(.) function on a transaction should return all objects that are mutable and read-only. I wonder if there is value in returning a flag if we can detect that a mutable object is only read to help with execution scheduling? Unclear.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure I understand the idea here--is it:

  • Transaction declares object as mutable, but it is bound to a & reference in the Move main
  • The object is bound to a &mut reference in the Move main, but is never read

I think we can potentially do something about each of these if desired.

Copy link
Collaborator

@gdanezis gdanezis Nov 19, 2021

Choose a reason for hiding this comment

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

I guess what I am saying is that for the moment I am not assuming the executor knows the difference between & and &mut inputs. Maybe it should.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah, I see. I don't think it needs to know anything about this beyond the read_only flag for objects. But perhaps knowing more could help.

Copy link
Contributor

@huitseeker huitseeker Nov 23, 2021

Choose a reason for hiding this comment

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

Rust definitely implements this in clippy warnings, under the unnecessary_mut_passed warning.
One issue with silently doing the "upcast" to & is it makes it harder to reason about the contract from source, and that ability will be important for smart contract reviewers.

What I guess I'm saying is: perhaps we should deal with this in the earlier phases of the UX (through tooling or early interpreter checks doing source validation), and make sure that by the time the user gets an &mut in, we're pretty darn sure it cannot be an &?

/// Simple getter
public fun read_field(o: &Object): u64 {
o.custom_field
}

/// Example of creating a object by deriving a unique ID from the current
/// transaction and returning it to the caller (who may call functions
/// from this module to read/write it, package it into another object, ...)
public fun create(tx: &mut TxContext): Object {
Object {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this the id as it will be known in fastX, or simply a sequence number for the output that will be used to derive the object ID along with the Txdigest? My question is I supose: are the Ids known to the move code and need to be kept the same, or can we change the object_id as part of the runtime? Because the runtime instead of checking the id is correct can simply re-derive it in a safe way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Very good question!

  • Yes, this is the ID as it will be known in fastX
  • I didn't think of using sequential ID's in Move land and then re-deriving them in the runtime. That is a nice trick + potentially a useful optimization.
  • After thinking about it a bit, I think that we probably want the real ID's in Move land, at least for now. It keeps things a bit simpler (no need to implement the re-derivation), and I can imagine a module to serialize the ID of an object (e.g., to check a signature on it), which would be tricky if the ID is a runtime-internal one instead of what the user can see.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with the pros but what about supporting transactions in non-move (eg native rust). I think we can keep the id logic in sync.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fully on board with supporting transactions in native Rust. My mental model is (and curious to know if it matches yours):

  • We should have high-performance native transactions for common use-cases (maybe mint, burn, transfer, batch transfer). You don't need a VM for everything.
  • We should have arbitrary Move transactions for everything else. Anyone can publish a Move module or use an existing one in a transaction.
  • You should be able to do everything a native transaction can do inside Move. It will just be slower and (accordingly) less gas efficient.
  • Native transactions can operate on (some? all?) custom objects, but they shouldn't be able to subvert the policies for those objects encoded in Move. E.g., if an object has conditional transfers, you shouldn't be able to use a native API that does unconditional transfers

I'm a bit concerned about how we will pull off the last point, but it will become clearer once we understand the native operations + Move programming model.

id: TxContext::new_id(tx),
custom_field: 0,
child_obj: ChildObject { a_field: false },
nested_obj: AnotherObject { id: TxContext::new_id(tx) }
}
}

/// Example of an entrypoint function to be embedded in a FastX
/// transaction. The first argument of an entrypoint is always a
/// special `TxContext` created by the runtime that is useful for deriving
/// new id's or determining the sender of the transaction.
/// After the `TxContext`, entrypoints can take struct types with the `key`
/// attribute as input, as well as primitive types like ints, bools, ...
///
/// A FastX transaction must declare the ID's of each object it will
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to check my understanding: by declare you mean here the caller has to pass a reference (which happens to be in the form (object_id, seq)) to the inputs, and mutation happens by passing a &mut reference. I think this is fine.

Note here we are not actually doing parallel execution, so our needs are a little more loose (although we can chose to be more strict to make parallel exec easier):

  • The input(tx) function can actually run the tx to derive the set of input objects, and even read a (somewhat consistent) snapshot of the DB, as long as it is deterministic. We are not trying to make it cheaper than running the tx.
  • The property we need is that all objects touched can only be touched by the same user. So if we can determine that some object if touched by this Tx cannot be touched by another Tx (eg. because for example it is owned by an account we use here, or some other weird reason) then we do not need to even check anything.
  • Another example: it is OK to do dynamic lookups! So I give an index i and the function adds x to the i^th object in a list by loading it. As long as input(tx) tells me which object that is, I am happy -- and this can happen by executing the thing. (But we need a hint about the version though to ensure consistency, so more thinking required here).

What I am trying to say is that what we need here is a little different from the write set prediction, and should be easier to get as it can be dynamic. Big win of course if it is cheap and static.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great thoughts, a ton to discuss here.

t;dlr: my primary motivation for asking the user to specify the input objects statically is actually better UX (though enabling more efficient execution). This may sound counterintuitive (asking to explicitly specify inputs sounds like terrible UX!), but hear me out.

  • In my first attempt at creating a Move subset, I actually started off with something quite a bit more dynamic (described in some more detail here): a main where you only get a signer + some primitive params as input (like normal Move). Then, you can dynamically read/write/transfer whatever objects you want, as long as they belong to the input signer.
  • The issue I ran into with this is that it's not very natural to talk about which object(s) you want to get a hold of. The only unambiguous way to do this is to request the object ID's, and the only logical way to get them is to ask the user to pass them in as inputs to the transaction.
  • Thus, all my Move code ended up looking lime (1) a setup phase where you first grab all the objects you want via dynamic lookups by ID, then (2) pass them to a function resembling the main here that takes objects as input and then does stuff to them. The code for (1) is not very nice to write (especially in Move), and the user already had to do the nasty step of specifying the ID's, so we might as well let the runtime do (1) for them--UX win.
  • With all that said, I'm not at all opposed to supporting dynamic lookups in addition to the model outlined here. I think dynamic lookups make a ton of sense for immutable objects (because the ID is stable, a module can hardcode it instead of asking the user to pass it in). And as your third bullet indicates, there are useful design patterns leveraging a "secondary index" (e.g., an ID stored in another object) that neeed dynamic lookups to work. The only thing I feel strongly about is that the more static object-centric model based on object id's provided by the tx sender should be the common case (since we can't relieve them of that burden, as argued above), and we use dynamism only when we need its power.
  • Now, I think we can (and should) do lots of things in offline tooling for building txes to help a user discover the relevant object ID's. For example: if the user wants to run main(o: &mut Obj1), their wallet can look for values of type Obj1 owned by the user, show them to the user, and prompt the user to select the appropriate one (without ever showing the user the ID or asking them to type it). If the user only has one object of that type, no need to ask them anything at all. I think this sort of thing is much easier to do offline with the benefit of DB queries, interactive feedback, etc instead of trying to do it in Move-land.

Copy link
Contributor

Choose a reason for hiding this comment

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

Now, I think we can (and should) do lots of things in offline tooling for building txes to help a user discover the relevant object ID's. For example: if the user wants to run main(o: &mut Obj1), their wallet can look for values of type Obj1 owned by the user, [...] I think this sort of thing is much easier to do offline with the benefit of DB queries, interactive feedback, etc instead of trying to do it in Move-land.

I do not think those things are easy to engineer at all, and in particular, I shudder at the thought of making sure what is reported to the user (from outside the chain) is authentic (&, to a minor degree, fresh). The potential for abuse in pointing the user at the wrong external object is worrisome.

Copy link
Collaborator Author

@sblackshear sblackshear Nov 19, 2021

Choose a reason for hiding this comment

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

I hear these concerns, though am much more optimistic about how hard it is to build this (but let me know if/where I'm being naive):

what is reported to the user (from outside the chain) is authentic

If our wallet software can't do authenticated reads, I agree that we're in a lot of trouble...

(&, to a minor degree, fresh)

I think the fastX model helps us a lot here. No one else can touch the objects that the user owns, so if a user has an authenticated read of an object and hasn't send a transaction to spend it, the user can be fully confident about its value/owner.

In addition, the only alternatives to better wallets/tooling I can think are:

  • Asking the users to memorize object ID's and key them in manually (seems like a non-starter)
  • Imposing more limitations (e.g., user can have at most one object of a given type, a user selects by type rather than by ID).

At the end of the day, someone has to provide the ground truth for what object(s) a transactions will act on. Wallet software assisted by user feedback (when needed) is the only answer I can think of.

Copy link
Contributor

@huitseeker huitseeker Nov 23, 2021

Choose a reason for hiding this comment

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

I think the fastX model helps us a lot here. No one else can touch the objects that the user owns, so if a user has an authenticated read of an object and hasn't send a transaction to spend it, the user can be fully confident about its value/owner.

Yes, but that only applies to himself. Any object owned by others could have been sent away unbeknownst to our user.

You're focused on how nobody can send away the things I've received. That's the easy part. The hard part is : how can I be sure I've received anything without trying to send or move it (or create a send that depends on it) ?

And that's the core of the issue: on a normal (consensus-driven) blockchain, you can have a block height and a converged view of the whole world at that particular block height. That, plus an estimate of how fresh the view is (which is directly comparable to the latest block height) is enough to have reasonable heuristics on what's going on. You can know what you own without a TX. You can know what others own. You know, for instance, if you're "too late behind" (as a Full node, you should sync before deciding on anything) and you can quantify (based on block frequency) how risky it is to postulate things about the world.

In FastNFT:

  • there's definite finality form the PoV of the sender, but not from that of the receiver,
  • there's no global view of the world, that could correspond to any notion of a Merkle root,
  • there's no notions of successive views / indexation of the world, which would correspond to versioning by block height,
  • there's no notion of "lateness", because there's no indexation of state,
  • there's no notion of "delay", because the global throughput does not inform you if the portion of state you're interested in mutates as fast as that global speed.

Don't get me wrong, I think there are ways around this, that you can construct indexations of the state of the blockchain, and get to notions of "partial view", "partial indexation for a fragment of things", and "speed of update of a partial view", I just want to underscore that this is a significant design and engineering challenge.

/// access + any primitive inputs. The runtime that processes the
/// transaction fetches the values associated with the ID's, type-checks
/// the values + primitive inputs against the function signature of the
/// `main`, then calls the function with these values.
///
/// If the script terminates successfully, the runtime collects changes to
/// input objects + created objects + emitted events, increments the
/// sequence number each object, creates a hash that commits to the
/// outputs, etc.
public fun main(
ctx: &mut TxContext,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I just love how you use the rust types to indicate read, write and delete.

to_read: &Object,
to_write: &mut Object,
to_consume: Object,
// ... end objects, begin primitive type inputs
int_input: u64,
bytes_input: vector<u8>
) {
let v = read_field(to_read);
write_field(to_write, v + int_input);
transfer(to_consume, Authenticator::new(bytes_input));
Copy link
Collaborator

Choose a reason for hiding this comment

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

This surprised me: I was expecting to just drop the object here (delete) but instead you transfer it. Now I understand from a pure type theory why you need to pass it by val -- transfer takes a value. But why is transfer not taking a &mut? To prevent the called from mutating the object after the transfer I suppose?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the idea here I think is to represent object ownership (in the pecuniary sense) by object ownership (in the Rust capability sense).

Even If a &mut foo sufficed at runtime to transfer foo (by, say a —hypothetical— mutation of an owner field in foo), then any smart contract you'd give this &mut foo to could either give your foo a new sword, or transfer it away, at its discretion.

One language trick to reconcile your expectation of drop and your read of "taking a value" is to call it "consuming" a value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Basically, what Francois said. But to add a few things:

This proposed programming model (and Move's more broadly) is that &mut T conveys the authority to mutate the contents of T, whereas T conveys the authority to both mutate the contents of T and transfer it to another user (to "move" it, if you will :) ). This is a useful distinction to be able to express--e.g., you might want to give someone else permission to write to a field of an object you own, but not to take it away from you.

But why is transfer not taking a &mut? To prevent the called from mutating the object after the transfer I suppose?

Yes, this is also a concern. For example: imagine Alice transfers a &mut Coin to Bob and emits an event that records a transfer of 10 coins, but then (in the remainder of Alice's code) she uses Coin::split on the mutable reference she still has to siphon off some of the value. Bob will see the event and expect to see 10 coins in his account, but he will have less.

// demonstrate creating a new object for the sender
let sender = TxContext::get_authenticator(ctx);
Transfer::transfer(create(ctx), sender)
}

fun some_conditional_logic(): bool {
// placeholder for checks implemented in arbitrary Move code
true
}
}
92 changes: 92 additions & 0 deletions fastx_programmability/examples/EconMod.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/// Mod of the economics of the SeaScape game. In the game, a `Hero` can onAly
/// slay a `SeaMonster` if they have sufficient strength. This mod allows a
/// player with a weak `Hero` to ask a player with a stronger `Hero` to slay
/// the monster for them in exchange for some of the reward.
module Examples::EconMod {
use Examples::HeroMod::{Self, SeaMonster, RUM};
use Examples::Hero::Hero;
use FastX::Authenticator::Authenticator;
use FastX::Coin::{Self, Coin};
use FastX::ID::ID;
use FastX::Transfer;
use FastX::TxContext::{Self, TxContext};

/// Created by `monster_owner`, a player with a monster that's too strong
/// for them to slay + transferred to a player who can slay the monster.
/// The two players split the reward for slaying the monster according to
/// the `helper_reward` parameter.
struct HelpMeSlayThisMonster has key {
id: ID,
/// Monster to be slay by the owner of this object
monster: SeaMonster,
/// Identity of the user that originally owned the monster
monster_owner: Authenticator,
/// Number of tokens that will go to the helper. The owner will get
/// the `monster` reward - `helper_reward` tokens
helper_reward: u64,
}

// TODO: proper error codes
/// The specified helper reward is too large
const EINVALID_HELPER_REWARD: u64 = 0;

/// Create an offer for `helper` to slay the monster in exchange for
/// some of the reward
public fun create(
monster: SeaMonster,
helper_reward: u64,
helper: Authenticator,
ctx: &mut TxContext,
) {
// make sure the advertised reward is not too large + that the owner
// gets a nonzero reward
assert!(
HeroMod::monster_reward(&monster) > helper_reward,
EINVALID_HELPER_REWARD
);
Transfer::transfer(
HelpMeSlayThisMonster {
id: TxContext::new_id(ctx),
monster,
monster_owner: TxContext::get_authenticator(ctx),
helper_reward
},
helper
)
}

/// Helper should call this if they are willing to help out and slay the
/// monster.
public fun slay(
hero: &Hero, wrapper: HelpMeSlayThisMonster, ctx: &mut TxContext,
): Coin<RUM> {
let HelpMeSlayThisMonster {
id: _,
monster,
monster_owner,
helper_reward
} = wrapper;
let owner_reward = HeroMod::slay(hero, monster);
let helper_reward = Coin::withdraw(&mut owner_reward, helper_reward, ctx);
Transfer::transfer(owner_reward, monster_owner);
helper_reward
}

/// Helper can call this if they can't help slay the monster or don't want
/// to, and are willing to kindly return the monster to its owner.
public fun return_to_owner(wrapper: HelpMeSlayThisMonster) {
let HelpMeSlayThisMonster {
id: _,
monster,
monster_owner,
helper_reward: _
} = wrapper;
HeroMod::transfer_monster(monster, monster_owner)
}

/// Return the number of coins that `wrapper.owner` will earn if the
/// the helper slays the monster in `wrapper.
public fun owner_reward(wrapper: &HelpMeSlayThisMonster): u64 {
HeroMod::monster_reward(&wrapper.monster) - wrapper.helper_reward
}
}
Loading