Coinweb L1 Writer
Introduction
The L1 writer writes arbitrary data (e.g. byte arrays) into any of the supported blockchains (e.g., Bitcoin, BitcoinCash, Ethereum, Solana, Elrond …).
The writer must satisfy:
- A transaction is written at most once.
- A transaction is written at least once.
Although intuitive, both conditions are hard to satisfy. This L1 Writer implementation is specifically designed to satisfy both conditions while keeping the implementation simple.
The key idea is to implement the L1 writer as a stateless component. We will see that keeping the L1 Writer stateless has several advantages:
- Exceptions and errors can happen anytime not affecting the integrity of the system.
- The complexity of the implementation is kept at a minimum.
- The stateful logic is as simple as storing a token and is general for all blockchains.
- The implementation can be ported to WASM.
- The system is easier to test.
Description
The writing happens in two steps:
-
The user queries the endpoint
prepare_transaction
with the data to embed into the given blockchain. The user receives a token (calledPreparedTransaction
) that can be used to:- Write the given data into the selected blockchain.
- Query the status of the operation:
- Not found, try to write again.
- Transaction in the mempool.
- Transaction confirmed and the number of confirmations.
Notice, that this method does not write, it only prepares the token for writing/querying.
-
The user calls
query_or_write_transaction
with the token (fromprepare_transaction
) to execute the writing operation. -
At any time, the user can call
query_or_write_transaction
to query the status of the operation:- Not found, try to write again.
- Transaction in the mempool.
- Transaction confirmed and the number of confirmations.
First, a transaction must be written once and only once. In the happy path, a user will call:
prepare_transaction
- get a tokenquery_or_write_transaction
- write the transaction with the data onto the blockchain.
Finally, the user can query the status of the transaction calling
query_or_write_transaction
as many times as they want, eventually, the
transaction will be mined in a block on a blockchain.
Let's analyze what happens when an error occurs:
-
prepare_transaction
: this is an atomic operation i.e. the operation returns a valid token or it fails somewhere. In case of an error, the user only needs to call it again. -
query_or_write_transaction
- If the error happens before the writing: the user will be notified with an error and the user only needs to call the endpoint again.
- If the error happens after the writing, but before returning an ok: the user will be notified with an error, and when the user tries to write again, the writing will be not executed twice, instead, the user will notified that the transaction is confirmed in the mempool.
- If the error happens after returning an ok (node down before writing, reorg, …): when the user queries the status of the transaction (all users must query the status, you cannot forget about a transaction until it is confirmed), then the writing will happen.
A token is composed of two parts:
- A reference to a future transaction (called a
txid
) that may or may not exist. - The recipe to deterministically create the transaction that will hold the future reference.
The key idea about this token is that it allows to check if the given reference
exists i.e. the write has already been executed and query the status of the
reference, but in case the reference for it does not exist, it allows to create
and execute the transaction that will hold the future reference. This is
possible because the token carries all the information needed to build the exact
same transaction as the one built in prepared_transaction
.
One important security aspect of the token is that we need to prevent any kind of manipulation. A user could accidentally change part of the recipe and write the transaction several times. But, an attacker could intentionally change, for example, the change address, to redirect the change to one of their accounts. See the next section for more details on this.
User Responsibility
Any user of this API, e.g. the Coinweb node or an external client, must:
- Store the token in a persistent environment.
- Request the status of the transaction
query_or_write_transaction
until the required number of confirmations is met.
Implementation
The following snippet corresponds to the current (23 Feb 2021) of the L1 Writer in Rust:
pub trait L1Writer {
/// Computes a [PreparedTransaction] that can be later used on [L1Writer::write_transaction].
///
/// The user must store the resulting `PreparedTransaction` to:
/// - Write the request data into the network.
/// - Query the status of the transaction after writing.
fn prepare_transaction(
&self,
network: &NetworkName,
input: Vec<u8>,
config: Config,
) -> Result<PreparedTransaction, PrepareTransactionError>;
/// This method allows to:
/// - Send a transaction to an L1 Blockchain given the specification given by [PreparedTransaction].
/// - Query the status of a previously sent transaction for the given [PreparedTransaction].
///
/// This method **must** validate that the input `PreparedTransaction` has not been modified.
/// For example, on bitcoin we return the signed transactions on the `PreparedTransaction`,
/// and before writing the transaction we check that the current transaction is equal to the given transaction.
fn query_or_write_transaction(
&self,
prepared_transaction: PreparedTransaction,
) -> Result<L1WriteResult, L1WriteTransactionError>;
}
/// Description of how to build a transaction deterministically.
/// There should be a one to one correspondance between `PreparedTransaction` and `L1 Transaction`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum PreparedTransaction {
Bitcoin(BitcoinPreparedTransaction),
Ethereum(EthereumPreparedTransaction),
}
/// Subtype of [PreparedTransaction].
/// This https://github.com/rust-lang/rust/issues/1679 would allow to remove this.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BitcoinPreparedTransaction {
network: NetworkName,
data: Vec<u8>,
change_address: Address,
spent_utxos: Vec<UTXO>,
fee_rate: FeeRate,
salt: [u8; 6],
first_txid: bitcoin::Txid,
second_txid: bitcoin::Txid,
}
/// Subtype of [PreparedTransaction].
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EthereumPreparedTransaction {
network: NetworkName,
// TODO fill the rest of attributes
}
/// Type alias for the accounts.
pub type Accounts = HashMap<Address, L1AccountInfo>;
/// Information associated to each of ours account.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct L1AccountInfo {
// TODO change to AmountGQL
balance: u64,
confirmed_utxos: usize,
}
/// Writing result
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct L1TransactionResult {
l1_network: NetworkName,
l1_txid: Txid,
fees_paid_as_satoshis: u64,
raw_input: Vec<u8>,
accounts: Accounts,
}
/// Query result
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct L1TransactionStatus {
l1_network: NetworkName,
block_hash: Option<bitcoin::BlockHash>, /* None when on the mempool*/
confirmations: Option<u32>, /* None or 0 when on the mempool*/
accounts: Accounts,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum L1WriteResult {
/// Transaction sent to the node to be minted.
Sent { result: L1TransactionResult },
/// Transaction confirmed in the mempool, but not minted.
/// The user must wait to be minted or rejected by other nodes.
Mempool { status: L1TransactionStatus },
/// Transaction confirmed in the blockchain.
Confirmed { status: L1TransactionStatus },
}
enum Config {
BitcoinConfig {
fee_per_byte: FeeRate
},
EthereumConfig {
}
}
Securing the token
/// Validates that the [BitcoinPreparedTransaction] has been not modified by the user.
///
/// In order to achieve that, we re-create the transaction and we check that the given txid matches the computed txid.
/// Only a user with our privates keys could create a modified `PreparedTransaction` that matches the given txid.
fn validate_prepared_transaction(
pt: BitcoinPreparedTransaction,
private_keys: &PrivateKeysStore,
) -> Result<(bitcoin::Transaction, bitcoin::Transaction, embed::Fee), L1WriteTransactionError> {
let BitcoinPreparedTransaction {
network,
data,
change_address,
spent_utxos: utxos,
fee_rate,
salt,
first_txid,
second_txid,
} = pt;
let network = Network::try_from(network).map_err(|_| {
error!(
"Network::try_from failed! Network {:?} not implemented",
network
);
L1WriteTransactionError::InternalServerError
})?;
let (fst_tx, snd_tx, _, fee_spent) = embed::new_p2shdd(
&data[..],
network,
&change_address,
&private_keys,
utxos,
fee_rate,
salt,
)
.map_err(|e| {
// # Why this is not an actual error
// This method cannot fail if the `PreparedTransaction` hasn't been modified.
// Otherwise, it would have not been possible to create the `PreparedTransaction` in the first place.
warn!("Prepared transaction validation failed: {}", e);
L1WriteTransactionError::InvalidPreparedTransaction
})?;
// Validate that the PreparedTransaction has not been modified.
if first_txid != fst_tx.txid() || second_txid != snd_tx.txid() {
warn!("PreparedTransaction has been manipulated: txid do not match");
Err(L1WriteTransactionError::InvalidPreparedTransaction)
} else {
Ok((fst_tx, snd_tx, fee_spent))
}
}