Skip to content

Instantly share code, notes, and snippets.

@sdbondi
Last active Nov 9, 2021
Embed
What would you like to do?
Covenants on Tari

[DRAFT] Covenants on Tari

Composable covenant rules

convenants field on TransactionOutput containing an ordered list of additional dynamic consensus rules. These rules act as filters for outputs in the aggregate body (either transaction or block). With each rule filtering the output validation set for the next rule. For the transaction/block to be valid, there MUST be at least one output that passes all rules. For a covenant to be secure, it should include rules that either require some secret to produce a passing output, or leverages existing consensus.

NOTE: it may be preferable (could simplify the implementation somewhat) to compose rules and use a single rule field rather than a list (e.g. all([or(A, B), C])).

For example, the uniqueness rule for <parent_pk, unique_id>.

struct Covenant {
  rule: CovenantRule, 
  args: Vec<CoveanantArg>,
}

#[repr(u8)]
enum CovenantRule {
   None = 0,
   Or,
   And,
   Xor,
   // etc.
}

enum CovenantArg {
  Hash([u8; 32]),
  PublicKey(PublicKey),
  Commitment(Commitment),
  
  Script(TariScript),
  Covenant(Covenant),
  
  // Maybe
  Number(u64),
  ByteLiteral(Vec<u8>),
  String(String)
}
// These are not what the actual functions would look like, but represent the simplified signature of the 
// rule without detailing the implementation details of the rule (like passing in an output validation set etc) 

// All outputs pass, implicit for an empty (0 byte) covenant
fn identity() -> Result<(), CoventantFailure>;
/// Passes if all covenants pass, otherwise fails
fn all(covenants: Vec<Covenant>) -> Result<(), CovenantFailure>;
/// Passes if both covenants pass on the same output, otherwise fails
fn every(covenants: Vec<Covenant>) -> Result<(), CovenantFailure>;
/// Passes if either or both covenants pass, otherwise fails
fn and(covenant1: Covenant, covenant2: Covenant) -> Result<(), CovenantFailure>;
/// Passes if either covenant passes, otherwise fails
fn or(covenant1: Covenant, covenant2: Covenant) -> Result<(), CovenantFailure>;
/// Passes if either covenant passes but fails if both or neither do
fn xor(covenant1: Covenant, covenant2: Covenant) -> Result<(), CovenantFailure>;

/// Passes if both covenants match a _different_ output/set of outputs, otherwise fails
/// A ∆ B (symmetric difference)
fn exclusively(covenant1: Covenant, covenant2: Covenant) -> Result<(), CovenantFailure>;

/// Passes if at least one output opens the given signature, otherwise fails
fn check_features_sig(sig: Signature) -> Result<(), CovenantFailure>;
/// Passes if each field is equal to the same field on the input, otherwise fails
fn check_field_preserved(field: OutputField) -> Result<(), CovenantFailure>;
/// Encodes the given field and passes if the bytes are equal to the given bytes
fn check_field_eq(field: OutputField, value: &[u8]) -> Result<(), CovenantFailure>;
// and/or maybe include less generic but slightly more compact:
// fn check_script_eq(script: TariScript) -> Result<(), CovenantFailure>; 
// fn check_covenant_eq(covenant: Covenant) -> Result<(), CovenantFailure>; 
// fn check_commitment_eq(commitment: Commitment) -> Result<(), CovenantFailure>; 
// etc.
/// Concatenates the given output fields, hashes them and passes if they match the given hash, otherwise fails
fn check_fields_hashed(fields: Vec<OutputField>, hash: Hash) -> Result<(), CovenantFailure>;

Execution

It is assumed that the transaction has already been validated for spending (signatures, tari script etc). Each covenant rule is executed in the order it is given by the covenant field of the transaction input (i.e. the UTXO to be spent).

The scope of data that each rule has access to is:

  • The validation set (i.e. the outputs that need to be checked)
  • The arguments given to that rule in the input

Initially, all outputs in the aggregate body (excl. coinbase) are included in the validation set. As each rule is executed in order, and filters for outputs that match the rule. The covenant is not upheld if the output set is empty, resulting in transaction or block rejection.

Examples

  • The output must contain the same script as it currently does check_fields_preserved([fields::script]);

  • checkpoint

all([
    check_field_preserved(fields::covenant),
    check_field_preserved(fields::script),
    check_field_preserved(fields::features::all),
])
  • transfer with royalties:
exclusively(
    // Transfer UTXO
    check_fields_hashed([fields::script, fields::covenant, fields::features::all], Hash(xxxx)),
    // could be a one-sided payment script, with a None covenent (cannot force a UTXO that is actually spendable)
    check_fields_hashed([fields::script, fields::covenant, fields::features::all], Hash(xxxx)),
    // or could be an commitment decided out-of-band
    // check_fields_hashed([fields::commitment, fields::script, fields::features::all], Hash(xxxx)),
)

Notes

  • A single covenant rule composed of other rules (and(A, or(B, C))) over a list ([A, or(B, C)]) is debatably more elegant.
  • It is up to the owner of the UTXO to create a secure covenant (i.e one that cannot be bypassed by another matching output)
  • The scope of the state a covenant can see is limited to the output validation set and the input
  • The gram weighting for covenants would need to be higher to account for the added processing and storage.
  • Validation is worst-case O(n*m) where n is number of inputs, m is number of outputs
  • Each covenant acts like a set operation, narrowing the validation set until either zero (fail) or non-zero (pass) outputs remain.
  • Operations like or(A, B) may need to create a shallow copy of the validation set to run A and B with the full set. Even if A passes, B will need to be run to obtain a union of the resulting sets.
  • De/serialization code may be fairly complex due to being able to pass Covenants as arguments to other Covenants.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment