essential-contributions / essential-integration

Integration of Pint and the Essential protocol.
Apache License 2.0
3 stars 1 forks source link

Simplifying authentication #60

Open freesig opened 2 months ago

freesig commented 2 months ago

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:

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.