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:
- Their wallet gets a nice, readable version of the data (See section: Integration considerations)
- The wallet signs the final encoded hash
- SRC-16 handles all the encoding, hashing under the hood on the Sway side
- 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!