Skip to content

Generalized accounts - explained

Generalized accounts are motivated and described from a protocol perspective here. However, there are several pitfalls and important aspects that deserve a further discussion. We aim to provide further guidance when it comes to generalized accounts here.

Table of Contents

Transaction integrity

For plain old accounts (POA) transaction integrity comes down to two things; (1) the origin of the transaction - only the owner of an account may produce transactions originating from the account, and (2) a transaction can only be included once - i.e. it is not possible to include the same SpendTx twice to get double payment. (1) is achieved by cryptographic signing, æternity uses EdDSA signatures with (Curve25519). (2) is normally achieved by adding a (sequential) nonce to the transaction, and then the consensus algorithm only allows an account to "use" a nonce once.

In the æternity blockchain when a transaction is prepared for inclusion a suitable nonce is selected and included in the transaction, then the transaction (TX) is serialized (SerTX) and hashed. The resulting hash is then signed yielding a signature (SigTX); finally the serialized transaction and the signature ({SerTX, SigTX}) is posted to the transaction mempool for inclusion on chain.

GA authentication

As described in Generalized accounts, GAs are mainly a way to give more flexibility when it comes to transaction integrity, in particular when it comes to signing. This is done by moving both the nonce handling and signature checking to a smart contract that is attached to the account.

This means that the workflow when preparing a transaction is altered. Here we describe it in general terms, a concrete example can be found further down. The underlying transactions (SpendTX, OracleRegisterTX, ...) are not changed, but since the nonce-handling is moved to the GA authentication contract the basic TX is required to have nonce = 0 (to indicate that it isn't used). As with POAs the transaction is serialized and hashed. Then comes the different part - now the transaction hash, and a nonce (to avoid replay attacks) has to be combined and cryptographically secured in some way. This is where generic comes in, anything that can be produced and then later checked in the authentication contract (in a safe way!) can be used. The resulting artifact should be a piece of contract Calldata. Finally, in order to post the GA signed transaction to the transaction mempool, we have to prepare a GAMetaTX, containing the serialized transaction and the calldata (and gas, gas price, etc.)

ECDSA Example

A concrete example of a GA authentication contract might provide further insight. Here we use EcDSA (and curve_secp256k1) for signing, and we have the same nonce handling as for POA, i.e. nonces have to be sequential.

contract ECDSAAuth =
  record state = { nonce : int, owner : bytes(20) }

  entrypoint init(owner' : bytes(20)) = { nonce = 1, owner = owner' }

  stateful entrypoint authorize(n : int, s : bytes(65)) : bool =
    require(n >= state.nonce, "Nonce too low")
    require(n =< state.nonce, "Nonce too high")
    put(state{ nonce = n + 1 })
    switch(Auth.tx_hash)
      None          => abort("Not in Auth context")
      Some(tx_hash) => Crypto.ecverify_secp256k1(to_sign(tx_hash, n), state.owner, s)

  function to_sign(h : hash, n : int) : hash =
    Crypto.blake2b((h, n))

Contract state/initialization

The contract state contains the public key used for signing (owner) and the current nonce. The public key is provided to the init function (called at contract creation) and nonce is initialized to 1.

Authorization

The authorization function takes two parameters, the nonce and the signature. The authorization function checks that the nonce is correct, and then proceeds to fetch the TX hash from the contract environment using Auth.tx_hash. In this example the signature is for the value blake2b(tx_hash, nonce) (i.e. Blake2b hash of the tuple of the transaction hash and the nonce). The authorization finally checks that the private key corresponding to owner was used to sign this hash.

Caveat - producing the right hash

It is trivial to produce the hash in the contract, Crypto.blake2b((h, n)) - but it is not as straightforward to do it outside the contract. After all it is the Sophia ABI encoded tuple of a hash and an integer that is hashed. It isn't impossible to figure this out, but there is an easier way... Protip: Using the dry-run functionality we can call the contract off-chain and use the to_sign function to produce exactly the right hash!

PayingFor Example

One potential use-case for Generalized accounts is to be able to pay for transactions in a more decentralized way. Using PayingForTx it is easy for part A to get a transaction from part B, put it inside a PayingForTx and sign it. And hence part A pays for the transaction of part B. But it requires that A and B communicate directly, and that part A produces a signature. Imagine a case where part A would like to (blindly) pay for all calls to a particular contract given that they are made by a known account (perhaps a user of some kind or something similar).

A very simplistic authentication contract that achieve this is listed here:

contract PayForExample =
  type set('a) = map('a, unit)
  record state = { the_contract : address, allowed_users : set(address) }

  entrypoint init(c : address, us : set(address)) =
    { the_contract = c, allowed_users = us }

  entrypoint authenticate() =
    switch(Auth.tx)
      None => abort("Not in Auth context")
      Some({tx = Chain.ContractCallTx(ct_addr, _),
            actor = who, paying_for = Some(_)}) =>
        require(ct_addr == state.the_contract, "Bad transaction - wrong contract")
        require(Map.member(who, state.allowed_users), "Bad transaction - not allowed account")
        true
      _ => abort("Bad transaction")

Note: in a realistic use-case it is probably advisable to have an admin interface so that you can add (and remove) allowed users later on, etc.