examples/move/transfer-to-object/README.md
This document explores various methods of implementing a cash register that can accept and process payments on Sui. We'll focus on highlighting the trade-offs of each approach. Through these examples, you'll gain insights into the new transfer-to-object functionality and understand some of its applications and the types of issues it can address.
Before getting started, we need to define a common method for making payments.
Each payment is an object that consists of a payment_id, which is a unique
identifier for the payment (i.e., a way of tracking what the payment was for),
along with the actual coin for the payment.
/// A unique payment for a good or service that can be uniquely identified by
/// `payment_id`.
struct IdentifiedPayment has key, store {
/// Object ID
id: UID,
/// The unique id for the good/service being paid for
payment_id: u64,
/// The payment
coin: Coin<SUI>,
}
Using this, customers can make a payment with a unique payment ID to an address
using the function fun make_payment(payment_id: u64, coin: Coin<SUI>, to: address).
This function creates an IdentifiedPayment, sends it to the to
address, and emits an event with the payment's ID, the recipient, the amount
paid, and the payer.
Once the receiver of the payment has the IdentifiedPayment object, they can
unpack the identified payment into the coin that was sent. This will then
emit a separate event that marks the payment ID within the IdentifiedPayment
as processed.
You can see the Move code for this section (along with EarmarkedPayments,
which we'll use later on) here.
Now that we have established how we'll represent payments, let's examine a couple of different ways you could represent a cash register or perform customer-to-business type transactions on-chain.
Imagine you run a restaurant. Your business will have an
address A on-chain. When an order is taken, the customer will simply make a
payment by transferring an IdentifiedPayment object with the payment ID you provided
to them to your address A.
Whenever an IdentifiedPayment is sent, you'll be able to track it and mark
the bill as paid when you see the SentPaymentEvent with the given payment ID
that you provided and match it against the amount owed.
Later on (either asynchronously or in a batch at the end of the day), you can
process the payments you've received by iterating over the set of
IdentifiedPayment objects under your account, unpacking them, and then
using the unpacked SUI coin.
Overall, this is a very simple representation for on-chain payments and relatively easy to set up. However, it has some issues:
A are compromised, you would need to change to
a different address. This could cause issues for customers still using the
older address for the business.You can see the Move implementation for this section here.
To address some of the issues mentioned above, you could instead have your restaurant use a shared "cash register" object for payments and have customers pay into this shared object. In particular:
owner field of the shared Register object can be
set to the new address.Register's authorized_employees
list. If an employee departs or is hired, they can easily be removed from or
added to this list without changing the object ID of the shared Register
object that customers interact with.However, with the shared Register, payments must be made differently than
simply transferring the coins to the shared object. In
particular, without transfer-to-object, a payment to the Register object
would involve taking the shared Register object for the restaurant and adding
the payment as a dynamic object field under it:
public fun make_shared_payment(
register_uid: &mut UID,
payment_id: u64,
coin: Coin<SUI>,
ctx: &mut TxContext
) {
let identified_payment = IdentifiedPayment {
id: object::new(ctx),
payment_id,
coin,
};
// Add the payment as a dynamic field under the register object
dynamic_field::add(register_uid, payment_id, identified_payment)
}
Because of this, if your restaurant becomes incredibly popular across multiple
locations and you need to serve hundreds or thousands of customers at once,
those customers' payments must all be processed serially since they would all
be using the same shared object. This could lead to contention over the
Register object and delays in payment processing. In contrast, with
Implementation 1, since it uses only owned objects, all payments across all of
your restaurant locations could be processed in parallel and not effect each
other.
Luckily, transfer-to-object can help parallelize the payment process to the
Register object while also keeping the benefits of dynamic authorization and
stable interaction IDs that we saw in this implementation. Let's examine
exactly how it does this in the next example.
You can see the Move implementation for this section here.
With transfer-to-object, we can combine the benefits of the two previous implementations:
Register object in case of key
compromise (or e.g., selling the business) by changing the owner field.identified_payment::make_payment
function that uses sui::transfer::transfer under the hood, so payments can
happen in parallel across all restaurant locations without needing to be
sequenced against the shared Register object.You can see the entire implementation for the shared object register using transfer-to-object here.
Let's go through this implementation in more detail and compare it to the above two implementations.
To make a payment, nothing changes from Implementation 1. In particular,
customers will still use identified_payment::make_payment and simply set the
address they want to send to be the object ID of the restaurant's Register
object. If the restaurant changes the ownership of the Register object, this
will be opaque to the customers – they will always send their payment to the
same Register object.
At a high level, handling payments after they have been made using transfer-to-object resides somewhere between both Implementation 1 and Implementation 2. In particular:
To understand what's going on here, it's best to go through the implementation
of handle_payment:
/// We take the `Register` shared object mutably, along with a "ticket"
// `handle_payment` that we can exchange for the actual `IdentifiedPayment` object
// that it is associated with.
public fun handle_payment(
register: &mut Register,
handle_payment: Receiving<IdentifiedPayment>,
ctx: &TxContext
): IdentifiedPayment {
// If the sender of the transaction that wants to handle this payment is in
// the list of authorized employees in the `Register` object then we will
// permit them to withdraw the `IdentifiedPayment` object.
assert!(
vector::contains(®ister.authorized_employees, tx_context::sender(ctx)),
ENotAuthorized
);
// Authorization check successful -- exchange the `handle_payment` ticket
// for the `IdentifiedPayment` object and return it.
transfer::public_receive(&mut register.id, handle_payment)
}
Receive RuleOne additional benefit of transfer-to-object is that, in addition to being able
to specify custom transfer rules for key-only objects, you can also specify
custom receiving rules for key-only objects in a very similar manner: if an
object is key-only, then the sui::transfer::receive function can be called
in the module that defines the object, but not elsewhere. Elsewhere, the
sui::transfer::public_receive function must be called and can only be used on
objects that also have the store ability.
With this information, we can define a wrapper around IdentifiedPayments
where we can earmark that payment for a specific address, e.g., the address of
our server at the restaurant. We can then use the custom receive rule to ensure
that only our server can access their tip and no one else can.
struct EarmarkedPayment has key {
id: UID,
payment: IdentifiedPayment,
for: address,
}
Since EarmarkedPayment is key only, we can then define a custom receiving
rule for it so that only the address that we specified for it can receive the
payment:
public fun receive(
parent: &mut UID,
ticket: Receiving<EarmarkedPayment>,
ctx: &TxContext
): IdentifiedPayment {
let EarmarkedPayment { id, payment, for } = transfer::receive(parent, ticket);
// If the sender isn't the address we specified, the transaction will abort.
assert!(tx_context::sender(ctx) == for, ENotEarmarkedForSender);
object::delete(id);
payment
}
You can see the implementations for EarmarkedPayments, the custom receiving
rules, and functions at the bottom of the file
here.