Skip to content

Instantly share code, notes, and snippets.

@phyro
Last active January 26, 2022 21:25
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save phyro/b9298eff2dead1e371de2d266f56c005 to your computer and use it in GitHub Desktop.
Save phyro/b9298eff2dead1e371de2d266f56c005 to your computer and use it in GitHub Desktop.
Grin TX construction

Grin TX construction

Introduction

Below is a description of a MW transaction construction that unifies invoice/payment flows to simplify the reasoning about the transaction building process without giving up on features (I think).

Mimblewimble has a very distinct feature in that every single transaction is a multi signature. There seems to be no way around it. This begs the question whether we should think about transactions the same way as in Bitcoin. I believe the answer to this is yes, but only if we're talking about the transaction as a mathematical construct that can be validated and published on the chain. They both have inputs, outputs and some signatures so this construct is not much different from Bitcoin. However, Mimblewimble has a very different way of producing such a construct because of its multisig nature and this is where things get confusing for the users.

It might be good to clearly separate the two by giving them a bit more descriptive names. It seems important the users understand signing is happening behind the scenes as putting a signature first means the other party could at any point finish and produce a valid transaction while the user might be left with the impression that the transaction does not exist because the process never finished. Double-spending attacks of social nature can happen because of this e.g. Play attacks. This is why I believe having signatures as a visible object of a transaction is important. I also believe it makes everything simpler/clearer to think about and avoid mistakes. I propose we think of Slatepack as one of "transaction contract" or "transaction offer". They all provide us with an intuition that signing is involved from which, once all the parties have signed, a valid transaction can be derived/created. This contract can be checked if it was notarized/carried out by verifying the payment proof which is always a part of it against the chain state. This allows for a simple UI Contract element which can be read, agreed on and signed. I believe this may be a simpler concept to grasp for regular people than "constructing a transaction" and having partial transactions laying around. I expand on the difference about the possible names for the construction object at the end of the document, but will use transaction contract when discussing ideas.

Contract structure

Let's first assume a slight difference in slatepack structure. Notably the contract_outcome and participant_data structures. The main addition is contract_outcome which describes the outcome of each participant e.g. Alice receives 5 Grin, Bob pays 5 Grin.

/// Slatepack is a transaction contract waiting to be agreed on and signed by the parties involved.
/// When the contract has been agreed on by everyone and collected all the signatures, we can derive a valid
/// transaction from it. The payment proof is a part of the contract and can be verified after the contract
/// was agreed on by everyone - we can derive the kernel excess and the receivers provided the payment proof data.
struct Slatepack {
   pub version: VersionCompatInfo,
   pub identifier: UUID,
   // Hashmaps to avoid potential duplicate entries. A participant can only have one outcome
   contract_outcome: HashMap<GrinAddress, int>,  // int describes the amount "net change" for an address
   participant_data: HashMap<GrinAddress, ParticipantData>, // contains partial excess, nonce, partial sig
   tx: Option<Transaction>,  // our transaction that we end signing in the end
   pub payment_proof: Option<PaymentInfo>, // payment proof structure
   pub kernel_features: Option<KernelFeaturesArgs>, // probably not needed in most cases, uses default
}

impl Slatepack {
   pub fn blank() {
     Slatepack{
       version: SlateVersion4,
       id: Uuid::new_v4(),
       contract_outcome: HashMap<GrinAddress, int>::new(),
       participant_data: HashMap<GrinAddress, ParticipantData>::new(),
       tx: Some(Slate::empty_transaction()),
     }
   }
   
   // Setters - the only way to modify the data should be through these methods. This
   // is to make it easy to find all the consumers that may modify critical stuff.
   pub fn add_participant_outcome(&self, participant_addr: GrinAddress, net_change: int) {
     self.participant_outcome.insert(participant_addr, net_change);
   }
   
   pub fn add_participant_data(&self, participant_addr: GrinAddress, participant_data: ParticipantData) {
     // we don't add to transaction. Transaction kernel can be derived on demand I believe.
     self.participant_data.insert(participant_addr, participant_data);
   }
   
   // Getters - to easily find the consumers
   pub fn get_net_change_for(&self, participant_addr: GrinAddress) -> Result<int, Error> {
     self.participant_data.get(participant_addr)?
   }
   
   pub fn compute_total_excess(&self) -> Excess {
     // loop through participant data and sum the excess values
   }
   
   pub fn compute_total_nonce(&self) -> PubKey {
     // loop through participant data and sum the public nonces
   }
 
   pub fn compute_total_sig(&self) -> SchnorrSignature {
     // loop through participant data and sum up the partial signatures and nonces
     // TODO: is this safe for multiple people?
   }
   
   pub fn compute_total_offset(&self) -> BlindingFactor {
     // loop through participant data and sum up the offset values
   }
   
   // Adds the partial signature for wallet.address to ParticipantData entry
   pub fn add_signature(&self, wallet: WalletInstance) {
     // loop through participant data, find the entry and set the signature field
   }
   
   pub fn compute_kernel(&self) -> Result<TxKernel, Error> {
     let mut kernel = TxKernel::empty();
     kernel.excess = self.compute_total_excess();
     kernel.excess_sig = self.compute_total_sig();
     Ok(kernel)
   }
   
   // derives the transaction from slatepack, verifies it and returns it.
   pub fn to_tx_or_err(&self) -> Result<Transaction, Error> {
     let complete_tx = self.tx.clone();
     complete_tx.body.kernels = vec![self.compute_tx_kernel()?];
     complete_tx.offset = self.compute_total_offset();
     complete_tx.verify()?;
     Ok(complete_tx)
   }
   
   pub fn verify_payment_proof_sig(&self, participant_addr: GrinAddress) -> Result<(), Error> {
     // if we're the receiver, we don't verify the proof
     if (self.contract_outcome.get(participant_addr) > 0) { return Ok(()); }
     // verify sig as described in early-payment-proofs
   }
}

Kernel is constructed on demand from all the participant data when constructing an actual transaction. I believe this is what happens also today in the code, at least when we're constructing partial signature.

Contract states

Every participant has to perform two functions on a contract:

  1. Agree - A participant reviews the contract_outcome and agrees on it. At this point the participant contributes partial excess, public nonce, partial offset and payment proof data at if they're the receiver.
  2. Sign - To put a signature on the contract, we must have first agreed on it. Signing can be done after agreement and is the point at which a participant contributes inputs, outputs, partial signature and payment proof data if they're the receiver. Because at this point the payment proof data must have been provided, they also verify it.

Every contract must be agreed on before the participant can sign it. If this needs to be automated (e.g. exchanges), they can automate this by doing the init_contract call which constructs the contract_outcome and hence the agreement is assumed automatically. The other option they have is to pass the expected_net_change to the sign function through a flag which automates the agreement process.

The contract initiator describes the contract_outcome the for all the participants involved which is the source of truth about the logical amount changes as it defines the net change the transaction introduces for the participants from which we can derive if they're on the receiving or the sending end. The initiator automatically agrees with the contract when they create it so they contribute the participant data for themselves. The slates/contracts are also marked as agreed on once the agreement is reached. This state is a user local information and has a simple state transition graph

? -> Agreed -> Signed -> Confirmed

When a participant first sees the contract, it has no state for it set. They can either move it to Agreed or leave it in the unknown state. After it has been agreed on, it can be signed. This means that if we have two participants A and B there is always the following pattern of actions AgreeA, AgreeB, SignB, SignA.

(pseudo) Implementation unifying the flows

NOTE: The code below is not valid Rust nor does it use completely correct API. The point is more to present how we might unify the two flows.

Suppose we have the following functions:

/// 'init_contract' creates a contract agreed on by the initiator.
/// User specifies the net change of their amount and the key with which to encrypt the slatepack.
fn init_contract(
  wallet: WalletInstance, // Interface with the wallet functions
  b_addr: GrinAddress,    // The address of the participant I'm constructing tx with (for slatepack encryption)
  net_change: int,        // Balance change. Sending 5 Grin is -5, receiving 5 Grin is +5 (does not include fees)
) -> Result<Slatepack, Error> {
  assert(wallet.balance_available(net_change));
  let slatepack = SlatepackV4.blank();  // creates a blank slate with 0*G partial excess and 0 partial offset

  // 1.) Contract agreement - the initiator defined the contract so they automatically agree on it
  
  /// define participant transaction outcome commitment - [(address, net_change)]
  slatepack.add_participant_outcome(wallet.my_address, net_change);
  if (b_addr != wallet.my_address) {  // skip adding another participant if self-spend tx
    slatepack.add_participant_outcome(b_addr, -net_change);
  }
  // Add participant data for myself (need to save the blinding factors somewhere?)
  let partial_excess_priv, nonce_priv, offset = randBlindingFactor(), randBlindingFactor(), randBlindingFactor();
  // add my participant data information
  slatepack.add_participant_data(wallet.address, ParticipantData{
      partial_excess:my_partial_excess_priv*G,
      public_nonce: my_nonce_priv*G,
      offset: offset,
      sig=None,
    });
  slatepack.add_payment_proof_data(wallet.construct_payment_proof_for(slatepack)); // noop if sender role
  wallet.transition_slatepack(slatepack, Slatepack4::State::Agreed);  // the initiator auto-agrees
  
  // save transaction context (slatepack) for validation of slate when signing
  wallet.save_slatepack(slatepack);
  Ok(slatepack)
}

fn sign(
  wallet: WalletInstance,                   // Interface with the wallet functions
  slatepack: Slatepack,                     // The slatepack we will review and sign
  expected_net_change: Option<int>,         // An expected net_change can be passed to automate the agreement
  input_selection_fn: Fn->vec<OutputData>,  // Defaults to wallet.find_inputs
) -> Result<Slatepack, Error> {

  let my_net_change = slatepack.get_net_change_for(wallet.address);
  
  /////// Contract agreement ///////
  
  // Ensure contract agreement - if we have not agreed on this slatepack before we ask the user
  // to review contract and agree on it
  if (!wallet.agreed_on(slatepack)) {
    // Open prompt to review slatepack content (might include memo in the future)
    let expected_net_change_int = match expected_net_change {
      Some(expected_net_change) => expected_net_change,
      None => stdin.readline(),
    };
    assert!(expected_net_change_int == my_net_change);
    // we have agreed to the contract so we add our participand data and mark it as agreed on
    let partial_excess_priv, nonce_priv, offset = randBlindingFactor(), randBlindingFactor(), randBlindingFactor();
    // add myself to participant data
    slatepack.add_participant_data(wallet.address, ParticipantData{
        partial_excess:my_partial_excess_priv*G,
        public_nonce: my_nonce_priv*G,
        sig=None,
      });
    slatepack.add_payment_proof_data(wallet.construct_payment_proof_for(slatepack)); // noop if sender role
    wallet.transition_slatepack(slatepack, Slatepack4::State::Agreed);
  }
  
  /////// Contract signing ///////
  
  // Checks the slatepack has not changed the `contract_outcome` from when we agreed on it (tamper check)
  wallet.validate_agreed_on(slatepack);
  
  // Check the payment proof is valid - we should be able to verify the payment proof when we sign
  slatepack.verify_payment_proof_sig(wallet.address);  // noop for receiver
  
  // At this point we know we have agreed to the transaction contract and it contains the full excess.
  // We now prepare inputs and outputs we by default make a payjoin transactions on the receiver end.

  // the input sum has to be at least my_net_change for the sender and at least 0 for the receiver
  let lower_bound_input_sum = my_net_change if (my_net_change < 0) else 0
  let contributed_inputs = input_selection_fn(lower_bound_input_sum);
  slatepack.add_inputs(contributed_inputs);
  slatepack.add_outputs(vec![wallet.create_output(
    contributed_inputs.sum()
    + my_net_change
    - fees(contributed_inputs.length(), 1, 1/slatepack.contract_outcome.size())  // we pay 1/2 of kernel
  ]);
  slatepack.add_signature(wallet);  // finds our ParticipantData entry and adds the partial signature
  wallet.transition_slatepack(slatepack, Slatepack4::State::Signed);
  
  // save transaction context (slatepack) for validation of slate when signing
  wallet.save_slatepack(slatepack);
  // update db entries, tx log etc.
  wallet.update_state(slatepack);
  
  Ok(slatepack);
}

fn broadcast(slatepack: Slatepack) -> Result<bool, Error> {
  // If the transaction contains our address, we must have agreed on it so we can safely broadcast
  broadcast::post(slatepack.to_tx_or_err()?)
}

Every user goes through two steps. First they agree on a contract (we could even call this a setup phase since they add their partial excess information) and then they sign the contract. When we agree on a contract, we can define a custom fee contribution. Similarly, when we sign, we can add custom inputs to the transaction if we want to (payjoin).

Imagine now we add an option to set custom fees and an option to contribute an input to both the init_contract and sign commands. This would allow us to call the command with the following flags --use-input=<output_id> --fee-rate=N. We can also inspect a contract with ./grin-wallet tx view-contract <contract_id> which returns basic contract information - senders/receivers with amounts, the state of signatures and inputs/outputs. What a GUI wallet can do now is inspect the contract first so the user can decide if they agree on it (automated flows are still possible). At this point, a GUI wallet can ask the user to input the expected amount, contribute any extra inputs they want and a custom fee rate in case they want to prioritize transaction being included in a block. Once the user defined what they want, the GUI could call the sign command with custom input and fee contribution e.g. ./grin-wallet sign --contract-id=<uuid> --receive=5 --use-input=<output_id> --fee-rate=0.00011. This would allow a participant to define how they want their part of the transaction constructed while making payjoins natural as well as adding support to prioritize the transaction through fee contribution.

This leaves us with some nice properties regarding our steps. The party that initiates the contract can in step 1 define if they want any specific input added as well as define the fees. To support late-locking a random input for a payjoin transaction, the wallet could have an option --use-input=any which would contribute an input at the sign step. The party that's at step2 can define fees and inputs in step 2 which is their sign phase. This leaves us with an automated step 3 because both parties have defined what they want by that point. It also makes it possible to completely automate the flow e.g. to simulate the auto-receive, we could inspect the contract to find the information about our receiving amount and automatically call the command with the value that was set, checking only that we are infact going to receive something and that and possibly fee rate ratio to asses if it's financially worth getting this output.

Contract/Transaction cancellation

We no longer have payment or invoice flows, it became a process of agreein on a contract and signing it. We can however deduce whether we should safely cancel a contract. If we have not signed the contract, we never need to create a safe-cancel transaction. If we have signed the contract, we only have to do it if out net_change is negative because we're the sender in this case. There's another case where we might want to cancel a transaction when we're the receiver if we agreed on the contract and signed it, but have changed our mind.

The simplest way for an exchange to deal with transaction cancellation is to simply subtract the balance equal to the fees for another 1-1 transaction (to account for a possible cancel transaction scenario) and thus always perform a safe-cancel transaction when possible.

Payment proofs

Every contract comes with a payment proof for the sender and the payment proof can be verified when the sender does the sign step. To achieve this we use early-payment-proofs RFC (invoice).

Self-spends

A self-spend can be constructed by

fn self_spend_tx(
  wallet: WalletInstance,
  input: OutputData,
) -> Result<Tx, Error> {
  // the net_change is 0, and we are the only participant in the participant list, we pay for all the fees
  contract = init_contract(my_wallet, my_wallet.address, 0);
  tx = sign(
    wallet,
    contract,
    expected_net_change=Some(0),
    input_selection_fn=|| {vec![input]} // custom function for input selection that adds specific input
  );
  Ok(tx)
}

We now have a fully signed transaction because everyone added their excess and signed. Perhaps it could be marked as "Self-spend transaction" due to having 0 net_change.

CLI usage

Sending init
./grin-wallet tx init_contract -d grin1abca12dkc89d6j18 --send=5

> BEGINSLATEPACK. G7XCD2bxKrcNFyC PkX5qGumBJjgGyy EaZ2PGjQ1h7YyQw asx4YSjpWBD3uJ1 nYuYoRzCnGxQf3P
qoeF2Hx1FRvh5RY aSemkADGKpJLef2 4jT6ZbjTJWMh4E1 chvVnBZ7Rg1VP9H n1MnCPUdYV8ZP6f 29a4p. ENDSLATEPACK.

This in turn calls init_contract(my_wallet, GrinAddress.new(grin1abca12dkc89d6j18), -5). Note that it sets the net_change as -5.

Receiving init
./grin-wallet tx init_contract -d grin1abca12dkc89d6j18 --receive=5

> BEGINSLATEPACK. G7XCD2bxKrcNFyC PkX5qGumBJjgGyy EaZ2PGjQ1h7YyQw asx4YSjpWBD3uJ1 nYuYoRzCnGxQf3P
qoeF2Hx1FRvh5RY aSemkADGKpJLef2 4jT6ZbjTJWMh4E1 chvVnBZ7Rg1VP9H n1MnCPUdYV8ZP6f 29a4p. ENDSLATEPACK.

This in turn calls init_contract(my_wallet, GrinAddress.new(grin1abca12dkc89d6j18), +5). Note that it sets the net_change as +5.

NOTE: We could keep keep the 'invoice/payment' subcommands if we wanted and have it be

./grin-wallet tx init_contract --type=payment -d grin1abca12dkc89d6j18 --amount=5

./grin-wallet tx init_contract --type=invoice -d grin1abca12dkc89d6j18 --amount=5
Signing contract
./grin-wallet tx sign --receive=5

> input slatepack: BEGINSLATEPACK. G7XCD2bxKrcNFyC PkX5qGumBJjgGyy EaZ2PGjQ1h7YyQw asx4YSjpWBD3uJ1 nYuYoRzCnGxQf3P
qoeF2Hx1FRvh5RY aSemkADGKpJLef2 4jT6ZbjTJWMh4E1 chvVnBZ7Rg1VP9H n1MnCPUdYV8ZP6f 29a4p. ENDSLATEPACK.

> BEGINSLATEPACK. G7XCD2bxKrcNFyC PkX5qGumBJjgGyy EaZ2PGjQ1h7YyQw asx4YSjpWBD3uJ1 nYuYoRzCnGxQf3P
G7XCD2bxKrcNFyC PkX5qGumBJjgGyy EaZ2PGjQ1h7YyQw asx4YSjpWBD3uJ1 nYuYoRzCnGxQf3P asx4YSjpWBD3uJ1
qoeF2Hx1FRvh5RY aSemkADGKpJLef2 4jT6ZbjTJWMh4E1 chvVnBZ7Rg1VP9H n1MnCPUdYV8ZP6f 29a4p. ENDSLATEPACK.

Which in turn calls sign(my_wallet, slatepack, expected_net_change=+5, input_selection_fn=my_wallet.find_inputs).

We can also use slatepack file as an input and write it to an output file.

./grin-wallet tx sign --receive=5 --slate-input=slate1.gtx --slate-output=slate2.gtx

Open questions

The parties don't really agree on anything when we contribute our partial excess, should we rename that step?

I think yes. It should be a name that describes the cryptographic process rather than the perspective from the user. These concepts can have user friendly words at the wallet level if needed.

Can we know who signed so far and how many signatures are missing?

Yes. We can know who signed from the slatepack.participant_data. If participant has an entry in it, then they have agreed on. If they also have a partial signature set there, then they have also signed.

Are any attacks possible?

The construction seems simpler to me, especially for two parties, but we should double-check we have not opened any new attacks.

What about backwards compatibility?

Not sure. Depends if we end up changing the slatepack. If we do, then the parties would need to understand the same structure or have "on-the-fly" conversion between the versions. But conversions are messy and another point where things can go wrong.

Why do we have two hashmap that both contain the same keys and have participant data?

The first hashmap is commitment to the truth of the transaction which is why I kept it separate. It should be set at contract initization and never change which is why I extracted it out. The slatepack tamper check could hash the contract_outcome data and compare with what it saw before or something. But I'm not sure that's the best way to be honest, I just picked something.

Should we rename Slatepacks to TransactionContract?

Maybe. I'm slightly in favor of doing so. There's the unfortunate reality that the users will at some point encounter the encrypted slatepack which they will have to copy/paste. Perhaps introducing the notion of a transaction contract as mentioned above and renaming BEGINSLATEPACK. ... ENDSLATEPACK. to BEGINCONTRACT. ... ENDCONTRACT. as it would be more intuitive for the user.

Would it make sense to expand to more than 2 parties?

TODO: check if this is even safe to extend to 3 people. There's always the same action pattern. Extending it to 3 parties we have AgreeA, AgreeB, AgreeC, SignC, SignB, SignA. The agreements and signatures can be out of order, but that's the simplest pattern. This means we'd need to have a command that allows us to only 'agree' on the contract data. The calls might look something like this:

# First participant creates the contract metadata and agrees on it separately
./grin-wallet tx init_contract -d grin1abca12dkc89d6j18 --receive=5 --slate-output=slate1.gtx

# Participant B agrees
./grin-wallet tx agree --send=2 --slate-input=slate2.gtx --slate-output=slate3.gtx

# Participant C agrees and signs (sign command does both - we'd need to check everyone before us agreed though)
./grin-wallet tx sign --slate-input=slate3.gtx --slate-output=slate4.gtx

# Participant B signs
./grin-wallet tx sign --slate-input=slate4.gtx --slate-output=slate5.gtx

# Participant C signs and broadcasts (sign broadcasts by default if transaction is complete)
./grin-wallet tx sign --slate-input=slate5.gtx --slate-output=slate6.gtx

# Having design broken down to this level might allow broadcasting a transaction of someone else.
# An alternative to specifying input/output files would be to use unix pipes, but this might not work on Windows

This however complicates the UX quite a bit.

Naming the transaction signing process

Transaction contract

A contract answers the who, what, how, where, how and when of the agreement. It is important that the terms of the agreements be clearly stated. The terms of the contract--the obligations, expectations, and responsibilities of all the parties--must be detailed and without ambiguity. Once all the parties have read and understood the contract, the parties sign and date the contract. The contract is legally binding which means that once signed all parties are legally obligated to do what they have agreed to. Contracts are legally enforceable as well.

We have a very similar situation. All the participants have to read, agree and sign the contract. Once all have done so, we can derive a valid Transaction from the completely signed contract. This makes the signing of the transaction much more intuitive. We confirm we've read and agree with the contract and put a signature on it. Once it's done, we can create a transaction. This should be simple enough for anyone to understand. The only thing that bugs me is that it says real life contracts are legally enforceable if the parties have signed the contract. We can't finish a multisig transaction unless everyone signed or even if everyone signed something may block a transaction from being published on the chain (it's no longer valid for some reason). If this is a non-issue, I'd use that wording.

Transaction offer

I think transaction offer would also suggest signatures take place while not being as legally binding (I think).

Multisig offer

This is a more techy variant and I'm not sure it would be a good choice because of this. It assumes people using cryptocurrencies will know what a multisig is. The benefit here is that it puts forward the fact that only multi-signature transactions exist on Mimblewimble. It's also easy to explain we can derive a multisig transaction from a multisig offer once everyone has signed it.


As a fun thought in the end. We might eventually (in the next decade) have two or more variants of these transaction building processes e.g. a legally binding variant for strong memo commitments and a non-binding variant for regular transactions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment