SRC-16: Typed Structured Data - Request for Comment

SRC-16: Typed Structured Data

The following standard sets out to standardize hashing and signing of typed structured data. This enables secure off-chain message signing with human-readable data structures.

What is it good for?

Ever tried to create a data structure, then obtain the message signature for that data structure with your specific data inside it? Yeah, have you then decided to implement your own encoding scheme or try to hack together an encoding scheme built from other peoples libraries. Once devised you obtain a hash and use that to recover a signature. What about adding an element to your data structure, does that break your encoding scheme?

What is SRC-16?

SRC-16 gives you a standardized way to:

  • Hash structured data (like Sway structs) consistently
  • Present data to users in a readable format when signing
  • Handle signatures without worrying about the encoding details
  • Add or modify your data structures without a large overhead
  • Use the encoding and hashing functions for simple public key recovery in Contracts, Scripts and Predicates.

Let’s see it in action

Here’s a simple example using a Mail struct:

struct Mail {
    from: b256,
    to: b256,
    contents: String,
}

// This is how you'd implement the data encoding and hashing
impl TypedDataHash for Mail {

    fn struct_hash(self) -> b256 {
        // SRC-16 handles all the encoding details for you!
        // You just need to follow the pattern
        let mut encoded = Bytes::new();
        // Add the Mail type hash.
        encoded.append(
            MAIL_TYPE_HASH.to_be_bytes()
        );
        // Use the DataEncoder to encode each field for known types
        encoded.append(
            DataEncoder::encode_bytes32(self.from).to_be_bytes()
        );
        encoded.append(
            DataEncoder::encode_bytes32(self.to).to_be_bytes()
        );
        encoded.append(
            DataEncoder::encode_string(self.contents).to_be_bytes()
        );
				// Final hash of data.
        keccak256(encoded)
    }
}

// Also, this how you would implement the final encoding to obtain the 
// final hash for the domain, Mail type and users data.
impl SRC16Encode<Mail> for Mail {

    fn encode(s: Mail) -> b256 {
        // encodeData hash
        let data_hash = s.struct_hash();
        // setup payload
        let payload = SRC16Payload {
            domain: _get_domain_separator(),
            data_hash: data_hash,
        };
        // Get the final encoded hash
        match payload.encode_hash() {
            Some(hash) => hash,
            None => revert(0),
        }
    }
}

The TypedDataHash is built using prexisting encoding

The Secret Sauce:

Domain Separators

Think of this as your application’s signature context. It prevents someone from taking a signature from one app and using it in another. It includes:

  • Your protocol name
  • Version
  • Chain ID
  • Contract address or Script/Predicate root

Type Encoding

Each struct gets its own unique type hash based on its structure. Want to add a new field? The type hash changes automatically, invalidating old signatures. Pretty neat, right?

Data Encoding

We’ve got standard encoders for all the common types:

  • Strings? Hashed for consistency
  • Addresses/b256? Encoded directly
  • Numbers? Padded and encoded in big-endian
  • Complex structs? Recursively encoded

Wallet Integration & Signatures

When a user needs to sign your structured data:

  1. Their wallet gets a nice, readable version of the data (See section: Integration considerations)
  2. The wallet signs the final encoded hash
  3. SRC-16 handles all the encoding, hashing under the hood on the Sway side
  4. You can recover the signer’s address using standard EC recovery

The best part? You don’t have to worry about the nitty-gritty details of the encoding - SRC-16 handles it all consistently.

What’s Next?

Imagine your users seeing a nicely formatted version of their DeFi swap struct or complex trade interaction before signing, instead of a scary hex string!

We’re working on making this and easy to learn and understand standard tool for developers. If you have a contract or predicate you think could use this reach out!

Want to try it out? Check out the full spec and example in the Standards repository.

Or checkout This R&D Repo for a worked example with tests.

Integration considerations:

Wallet Considerations:

Representing Typed Data in existing Wallet infrastructure:

Unfortunately changing the name of the domain separator type from EIP712Domain to SRC16Domain breaks support of existing EVM style wallets as the type hash changes.

The Keccak256 hash of "SRC16Domain(string name,string version,uint256 chainId,address verifyingContract)"
= 0x3d99520d68918c39d115c0b17ba8454c1723175ecf4b38d25528fe0a117db78e

The Keccak256 hash of "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
= 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f

Also the validating_contract on Ethereum uses 20-Byte Addresses, whereas on Fuel we use 32-Byte Addresses. A Scheme could be devised to only use the last 20-bytes of a Fuel ContrctId when signing with an EVM Wallet.

Possible Solution:

To maintain EVM wallet support while allowing for Fuel-specific functionality, we can implement a flexible domain type system:

/// An enum that can represent either Fuel or Ethereum domain types
pub enum SRC16SuperDomain {
    /// Fuel domain separator
    Fuel: SRC16Domain,
    /// Ethereum domain separator
    Eth: EIP712Domain,
}

impl SRC16SuperDomain {
    /// Returns the domain hash based on the variant
    pub fn domain_hash(self) -> b256 {
        match self {
            SRC16SuperDomain::Fuel(domain) => {
	            // Fuel SRC16 domain hash computation
            },
            SRC16SuperDomain::Eth(domain) => {
                // Ethereum EIP712 domain hash computation
            }
        }
    }

    /// Creates a new Fuel domain
    pub fn new_fuel(
        name: String,
        version: String,
        chain_id: u64,
        verifying_contract: b256,
    ) -> Self {
        Self::Fuel(SRC16Domain::new(
            name,
            version,
            chain_id,
            verifying_contract,
        ))
    }

    /// Creates a new Ethereum domain
    pub fn new_eth(
        name: String,
        version: String,
        chain_id: u256,
        verifying_contract: b256,
    ) -> Self {
        Self::Eth(EIP712Domain {
            name,
            version,
            chain_id,
            verifying_contract,
        })
    }
}

...

Comments welcomed! :fuelpump:

6 Likes

SRC-16’s data hashing and signing standardization is a massive step forward for usability and security. The focus on readable data for users and the clever SRC16SuperDomain approach for EVM compatibility is impressive. I’m thankful for your guide; this explains the topic so well. Keep it up :rocket:

2 Likes

Very well broken down. What I’d like to see added to the proposal is justification for using a new standard rather than adopting EIP712.

Encoding for Fuel specific types and ContractId lengths in the domain seem to be the primary differences.

IMO if a new standard is used backwards compatibility is critical.

2 Likes

Excellent point. I have now added backwards compatibility for EIP712 domain separators. So SRC-16 now utilises superabis in from Sway;

This means that developers can use the SRC16Domain specifically for Fuel SRC16Domain types and signing with fuel-crypto or the EIP712Domain separators for use with EVM wallets. The EIP712 domain separator data encoding uses the rightmost 20 bytes from a 32-byte Fuel ContractId in the address of the verifying_contract in the EIP712Domain separator.

/// Base ABI interface for structured data hashing and signing
///
/// # Additional Information
///
/// This base ABI provides the common hashing functionality that is
/// shared between the Fuel (SRC16) and Ethereum (EIP712) implementations.
abi SRC16Base {

    fn domain_separator_hash() -> b256;

    fn data_type_hash() -> b256;
}

/// Fuel-specific implementation of structured data signing
///
/// # Additional Information
///
/// Extends SRC16Base with Fuel-specific domain separator handling using
/// "SRC16Domain(string name,string version,uint64 chainId,address verifyingContract)"
abi SRC16 : SRC16Base {
    /// Returns the domain separator struct for Fuel
    ///
    /// # Returns
    ///
    /// * [SRC16Domain] - The domain separator containing Fuel-specific parameters
    fn domain_separator() -> SRC16Domain;
}

/// Ethereum-compatible implementation of structured data signing
///
/// # Additional Information
///
/// Extends SRC16Base with Ethereum-compatible domain separator handling using
/// "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
abi EIP712 : SRC16Base {
    /// Returns the domain separator struct for Ethereum compatibility
    ///
    /// # Returns
    ///
    /// * [EIP712Domain] - The domain separator containing Ethereum-compatible parameters
    fn domain_separator() -> EIP712Domain;
}

2 Likes

AFAIK Sway does not have macros – but the TypedDataHash impl would be the perfect use case for a derive macro, like how Serde works in Rust

1 Like