-
Notifications
You must be signed in to change notification settings - Fork 4
Description
The feedback is that the current authentication example is too complex.
I have come up with the following options for how to simplify this but there are some challenges.
I will use the transfer token as an example because it is the most complex.
Transfer
A token transfer can either be authenticated because:
- A predicate that owns the key
- Currently implemented as the
hash(PredicateAddress) == key
- Currently implemented as the
- A signature is produced that locks in the
keyand some other data.- Ownership is
hash(recover(hash(data))) == key. - The simplest version of this is where
data == { key, to, amount, token_address, nonce' }. - This means that the user is locking in the
keythat is being transferred from, the address the transfer is goingto, theamountbeing transferred, whichtoken_addressis being transferred and thenonce'for this transfer. - If the user wants to pay a solver or builder for solving or inclusion then they probably won't know the
toaddress so they will only sign overdata == { key, amount, token_address, nonce' }. - If the user is setting the
amountbased off some other constraints likeamount == received / fractionthen they must sign overdata == { key, additional_constraints, token_address, nonce' }whereadditional_constraintsis the address of the predicate that contains the amount constraint. - If the user is trying to do a swap they will not know the exact
amountortoaddress for the outbound transfer but they will have additional constraints so they will sign overdata == { key, additional_constraints, token_address, nonce' }
- Ownership is
It's tempting to ignore all this complexity for an example token but swaps and paying solvers / builders is actually pretty common / trivial use cases that people are going to want to know how to do pretty early on. I think if we leave this out then we are just going to get feedback that our protocol can't handle this basic functionality.
There are three main options inline or transient, multiple predicates that I will detail here.
Inline
Inline means we include the signing functionality inline with the token source (probably by providing a library).
One downside is that this code needs to be deployed redundantly with every token which wastes space but isn't the biggest deal for an example.
This is easy enough to do. We simple provide an @auth() macro that the token predicate can call. The complexity comes in how to pass in the inputs to that macro.
The types we need to express this are:
// Types of authentication.
enum AuthType = Predicate | Signed;
// What is being signed
enum SignType = All | Key | KeyTo | KeyAmount;
// Address of an individual predicate
type PredicateAddress = { contract: b256, addr: b256 };
// Signature
type Secp256k1Signature = { b256, b256, int };Product types
This relies on using sentinel values to emulate sum types within product types. So the enum "tags" say which value is "set" while the other "unset" values are set to some form of default / zeroed out value.
type Auth = {
// Tags
auth_type: AuthType,
sign_type: SignType,
require_additional_constraints: bool,
// Data
owning_predicate: PredicateAddress,
sign: Secp256k1Signature,
additional_constraints: PredicateAddress,
}
// In the transfer predicate
var auth: Auth;
@auth(auth);One advantage is that this can be done in current pint however I think it's a little confusing and makes Pint look pretty bad. It might turn devs off using our system.
It's cleaner from the predicate POV but does waste bandwidth.
Separate dec vars
Similar to product type but each field is in it's own dec var slot.
var auth_type: AuthType;
var owning_predicate: PredicateAddress;
var sign_type: SignType;
var sig: Secp256k1Signature;
var require_additional_constraints: bool;
var additional_constraints: PredicateAddress;
@auth(auth_type; owning_predicate; sign_type; sig; require_additional_constraints; additional_constraints);This really makes the transfer predicate messy and hard to understand. The advantage vs Product types is that you don't actually have to set the "unset" vars to a sentinel value and can leave them empty instead. This saves on bandwidth. It will probably confuse beginners and also makes Pint look bad.
Sum types
This uses actual sum types like tagged unions. This doesn't exist in current Pint and could be pretty hard to add.
enum Auth = Predicate(PredicateAddress) | Sign(Sign);
type Sign = { sign_type: SignType, signature: Secp256k1Signature, additional_constraints: AdditionalConstraints };
enum AdditionalConstraints = Predicate(PredicateAddress) | None;
// In the transfer predicate
var auth: Auth;
@auth(auth);This saves data and is the easiest to understand. It's also cleaner as the transfer predicate only needs to know that there's this one Auth type and @auth macro from a library. The main disadvantage is that it's not possible to do in current Pint and I'm not sure if we want to add it or how hard it will be to add.
Transient
Abstracting the signing complexity to another contract means the token doesn't have to know about all these options and can just provide a list of which signing options the token supports.
interface Auth {
predicate Predicate {
pub var addr: PredicateAddress;
}
}
var authorization_predicate: PredicateAddress;
interface AuthI = Auth(predicate_address.contract);
predicate A = AuthI::Predicate(authorization_predicate.addr);
constraint (@is_owning_predicate(key; authorization_predicate) || @is_in_set(authorization_predicate; auth::@transfer; auth::@transfer_with)) && A::addr.contract == __this_contract_address() && A::addr.addr == __this_address();This avoids bringing any authorization logic into the token and just requires that the authorization_predicate is solved and is pointing at this token.
Predicates for each case
Another option is to have separate predicates for each option.
predicate TransferFromPredicate {
// Somehow reuse common code
var owning_predicate: PredicateAddress;
constraint @is_owning_predicate(key; authorization_predicate);
}
predicate TransferFromSigned {
// Somehow reuse common code
var sig: Secp256k1Signature;
constraint @check_sig({key, to, amount, nonce'}; sig);
}
predicate TransferFromSignedExtra {
var sign_type: SignType;
var sig: Secp256k1Signature;
var additional_constraints: PredicateAddress;
constraint @check_sig_with_additional(sign_type; sig; additional_constraints);
}This avoids the need fro sum types (or fake sum types) but heavily infects the transfer predicate with authentication logic. It's also not easy to reuse the code that is the same between each predicate because you can't declare var or pub var within macros.