Allows clients to receive notifications when certain events within a smart contract affect them. For example: token transfers, direct messaging, turn-based gaming, and so on.
Revision: #2
Revised on: 2024-03-12
- SNIP-52 - Private Push Notifications
- Introduction
- Channel Modes
- Concepts
- Queries and Methods
- Algorithms
Wallets and dApps currently resort to a polling-based approach in order to notice changes to a user's private state within a contract.
For example, a dApp might periodically query a set of SNIP-2x token contracts to discover a new incoming transfer.
However, this approach of querying contracts every so often is inefficient and can create unwanted load on query nodes. Additionally, there is no clear best practice for determining an optimal polling rate.
This document presents a technique that allows clients to receive notifications for specific future events (e.g., "next incoming transfer of token X", or "once it is my turn in chess") such that all information about the notification (e.g., its recipient, supplemental data, and whether or not an event actually ocurred) remains private, encrypted, and unaccessible to outside observers.
Keep in mind, the only time smart contracts mutate state is during execution, which can only occur during a network transaction. For example, Alice's "token X" balance can only ever change in response to a transaction that executes some method on that contract (e.g., "Bob transfers 100 token X to Alice"). It is only in response to these executions that a contract would emit a notification to notify some recipient(s) of an event that affected them (e.g., "hey Alice, someone just transferred tokens to your account").
First, let's review the basic components that make this possible:
-
The existing Tendermint event stack is a publish-subscribe model that allows nodes to transmit network events directly to subscribed clients. It does so using JSONRPC over WebSockets.
-
Leveraging the
add_attribute_plaintext(...)
API method, Secret Contracts can add custom key-value attributes to the transaction's log. -
Clients coordinate with smart contracts to determine globally unique, single-use "Notification IDs" which represent specific future events. When one of those specific events occur, its Notication ID is added to the transaction log as a custom attribute. Since the contract and recipient are the only parties aware of a Notification ID's significance, the event in that transaction's log is what discreetly notifies the recipient, effectively creating a private push-based notification service.
Let's walk through a simple example, where client Alice wants to be notified next time she receives a transfer of token X into her account.
NOTE: Example uses fake base64 data for brevity
-
Alice queries the token X contract to get the unique Notification ID for her next incoming transfer:
{ "channel_info": { "channels": ["transfers"] } }
-
The contract responds:
{ "channel_info": { "as_of_block": "1131420", "seed": "ecc7f60418aa", "channels": [ { "channel": "transfers", "mode": "counter", "counter": "3", "next_id": "ZjZjYzVhYjU4", "cddl": "transfers=[amount:biguint,sender:bstr]" } ] } }
Alice now has a globally unique Notification ID the contract will use next time someone transfers tokens to her account.
Furthermore, Alice now has a seed she can use to derive future Notification IDs offline for subsequent transfer events (i.e., without having to query this contract again)
-
Alice subscribes to execution events on the token contract:
{ "jsonrpc": "2.0", "id": "0", "method": "subscribe", "params": { "query": "wasm.contract_address='secret1ku936rhzqynw6w2gqp4e5hsdtnvwajj6r7mc5z'" } }
Alice will now receive a JSONRPC message for each new execution of the contract, from which she can search for her unique Notification ID.
If operating in counter mode, and Alice trusts that the WebSocket server won't record her activity, she can optionally use a filter in the
query
field of her subscription message, e.g.,wasm.snip52:ZjZjYzVhYjU4 EXISTS
. -
Some time later, Bob executes a SNIP-20 transfer of token X to Alice's account:
{ "transfer": { "recipient": "Alice", "amount": "100", } }
-
The contract derives the next transfer Notification ID for the recipient (Alice) and adds it as a custom attribute to the transaction log.
{ "...": {}, "events": { "...": ["..."], "wasm.snip52:ZjZjYzVhYjU4": ["aW8yMTN1MTJp"] } }
-
The WebSocket server transmits the transaction event JSONRPC message to Alice. Alice finds the expected attribute key
"wasm.snip52:ZjZjYzVhYjU4"
and the notification has been received.
Once Alice has obtained her Notification Seed for the desired channel, she no longer needs to query the contract and steps 4-6 can repeat ad infinitum.
Each channel can operate in one of several modes: Counter Mode, TxHash Mode, or Bloom Mode. A channel's mode should either be hardcoded or set during contract initialization. It should never change.
NOTE: Additional channel modes are reserved for future revisions. Implementations should not assume that only the given modes exist. Therefore, implementations should NOT use a default case or else branch to select the remaining condition.
Counter Mode and TxHash Mode are for channels that deliver each notification to a single recipient, for example, a direct token transfer, direct messaging, or a two-player game.
There is a trade-off between these two modes. Counter Mode is easier on clients but less secure.
In Counter Mode:
- ✅ clients only have to recompute a channel's notification ID each time they receive a notification from that channel
- ✅ clients are able to use node APIs such as
tx_search
to search back through history and find a missed notification - ✅ clients are able to query the contract to obtain their next notification ID, allowing them to bypass much of the SNIP-52 client-side implementation
- ❌ an attacker could, in theory, de-anonymize notification IDs using a sophisticated side-chain attack
In TxHash Mode:
- ✅ notifications are immune to side-chain attacks
- ❌ clients must recompute their notification ID for every execution tx witnessed on the given contract
In summary, TxHash Mode is more secure than Counter Mode, but comes at the cost of more work for the client (although the processing heft is likely neglible). On the other hand, with Counter Mode, clients are able to easily search tx history for missed notifications, and clients can bypass computing Notification IDs altogether by querying the contract.
Bloom Mode allows contracts to deliver notifications to multiple users at once, for example, batch transfers, group messaging, or in a multi-player game.
Unlike single recipient channels, the attribute key for Bloom mode chanels is constant across events. For example, a channel with the ID group_message
will always output to the attribute key "wasm.snip52:#group_message"
.
The attribute value is then made up of two major parts, the bloom filter bytes followed by the notification data bytes: ${BLOOM_FILTER}${NOTIFICATION_DATA}
. Both parts are constant length, and the notification data is optional (i.e., it may have length 0). If you would like to deliver notification data to each of the recipients while maintaining privacy, see below in Attaching private data.
The bloom filter is a data structure that allows for set membership to be encoded into a small space. Since false positive matches are possible (but ideally rare, depending on the parameters), it is only used by clients as a first step in determining whether a given notification affects them or not. In other words, if a client gets a hit on the bloom filter then they know to check the notification data and/or query the contract state to test whether they actually received a notification.
Contract developers need to choose parameters m, k and h for each channel's bloom filter, where m MUST be divisible by 8. Larger values of m are needed for larger group sizes, but consume more space in the logs, whereas k should be chosen to be optimal over the range of the expected number of recipients. Finally, h is the hash function from which k adjacent chunks, each of length
This tool can assist with picking m and k parameters depending on the use case: https://hur.st/bloomfilter/?n=16&p=&m=512&k=15 .
Choosing hash function h should be (a) cryptographically secure to prevent preimage attacks from revealing recipients' notification IDs, (b) uniformly random to ensure that the bloom filter is effective for all potential clients listening to the notification feed, and (c) produce at least
The bloom filter MUST be set by extracting k adjacent values, each
For example, assuming a filter using params m = 512 and k = 15, we can select the cryptographic hash function h = sha256, since [k_0..k_14]
would be derived as follows:
let bloomFilter := bytes(64)
for recipientAddr in recipients:
let notificationId := notificationIdFor(recipientAddr, channelId, {txHash})
let bloomHash := sha256(notificationId)
for i in 0..15:
let k_i := sliceBits(bloomHash, i*9, (i+1)*9)
let toggle := uintToBytes(1 << bitsToUintBe(k_i))
bloomFilter := orBytes(bloomFilter, toggle)
Contract developers are free to choose a data scheme to complement their bloom filter, however contracts SHOULD design and return a machine-readable representation of its schema in responses to the ChannelInfo Query.
The format for expressing a schema is made up of datatypes that resemble those from EVM's textual ABI, namely bool
, uint<M>
, address
, bytes<M>
, and <type>[M]
, with the additional custom types struct
, and the special packet[M]
.
All uint<M>
datatypes SHOULD be assumed to be clamped. If the client observes the max value for a uint<M>
, then the contract ran out of bits and the client should query the contract for the actual value.
The following typings formally describe it in TypeScript:
type Uint = `${bigint}`; // 0, 1, 2, ...
type UintSizes = '8' | '16' | '24' | '32' | '40' | '48' /* ... */ | '248' | '256';
type List<Datatype> = `${Datatype}[${Uint}]`;
type Primitive = `uint${UintSizes}` | 'address' | `bytes${Uint}`;
type FlatDescriptor = {
type: Primitive | List<Primitive> | List<List<Primitive>>;
label: string;
description?: string;
};
type StructDescriptor = {
type: 'struct' | List<'struct'>;
members: Descriptor[];
label: string;
description?: string;
};
type DataDescriptor = FlatDescriptor | StructDescriptor;
export type Descriptor = DataDescriptor | {
type: `packet[${Uint}]`;
version: number;
packetSize: number;
data: DataDescriptor;
};
{
"type": "packet[16]",
"version": "1",
"packetSize": 108,
"data": {
"type": "struct",
"label": "transfer",
"members": [
{
"type": "uint64",
"label": "amount"
},
{
"type": "address",
"label": "sender"
},
{
"type": "bytes80",
"label": "memo",
"description": "UTF8-encoded memo string"
}
]
}
}
The packet[M]
datatype is a special case that makes it convenient to encrypt different notification data for each recipient (i.e., only the intended recipient will be able to decrypt a packet's contents), up to a maximum of M
recipients.
Each packet must be fixed width so that clients can search for their packet at predictable offsets (also, a variable-width encoding scheme would leak data to other recipients) so CBOR encoding does not make sense here. This width is specified in bytes as the packetSize
.
The version
specifies the revision of this datatype, and at the time of this writing should be set to 1
. Clients should assert that this version matches in their implementation.
Each packet is composed of two major parts: ${PACKET_ID}${CIPHERTEXT}
.
The packet ID is simply the 8 leftmost bytes of the notification ID for the intended recipient in the given channel.
Clients that found a hit on the bloom filter will then scan the packets searching for a packet ID matching the 8 leftmost bytes of their notification ID. Each next packet ID will start at exactly packetSize
bytes after the end of the previous one.
The ciphertext is packetSize
bytes long, the same length as its plaintext equivalent. When encrypting/decrypting packet data, the remaining 24 bytes of the notification ID are used as the key material, referred to here as the packet IKM. In other words, the packet IKM is the 24 bytes immediately following the 8 leftmost bytes of the notification ID.
Encrypting and decrypting packet data is performed using a one-time pad by XOR'ing the plaintext/ciphertext with the packet key. The packet key is derived using one of two techniques, depending on packetSize
:
- If
packetSize
is less than or equal to 24 bytes, the packet key is simply the leftmost bytes of the packet IKM, up topacketSize
bytes. - If
packetSize
is greater than or equal to 25 bytes, then HKDF is performed on the 192-bit packet IKM with 256 bits of zero-valued salt, empty info and the SHA512 hash function, deriving exactlypacketSize * 8
bits. Those derived bits become the packet key.
fun encryptPacket(packetPlaintext, notificationId) {
let packetSize := length(packetPlaintext)
let packetId := slice(notificationId, 0, 8)
let packetIkm := slice(notificationId, 8, 32)
let packetKey
if packetSize <= 24:
packetKey := slice(packetIkm, 0, packetSize)
else:
packetKey := hkdf_sha512(ikm=packetIkm, salt=bytes(64), info="", length=packetSize)
let packetCiphertext := xorBytes(packetPlaintext, packetKey)
let packetBytes = concat(packetId, packetCiphertext)
return packetBytes
}
The packets structure has a finite capacity for notification data. If there are too many notifications and this capacity is reached, extra notifications are simply omitted. Any client that gets a hit on the bloom filter must know to query the contract to check for notification data. This approach of having the client query the contract also accounts for false positives caused by collisions in the bloom filter.
Coversely, if there are fewer notifications than the structure's capacity, then the contract MUST fill the empty slots with decoy packets. For these decoys, canonical addresses should be generated from secure random entropy (i.e., Secret VRF), and implementors should choose constant values for their packet data that would be "harmless" for a client to receive in the unlikely case that a collision were to occur.
For example, to create decoys with packet data consisting of all zero-valued bytes, we can initialize the data bytestream with decoys (before populating the actual notification data):
let bloomData := bytes(packetsCapacity*(packetSize+8))
let decoyAddresses := hkdf_sha512(ikm=env.random, salt=bytes(64), info=concat(channelId, ":decoys"), length=packetsCapacity*20)
for i in 0..packetsCapacity:
let decoy := slice(decoyAddresses, i*20, (i+1)*20)
addAddressToBloomFilter(bloomFilter, decoy)
let notificationId = notificationIdFor(decoy, channelId, env)
let packetBytes := encryptPacket(decoyPacketPlaintext, notificationId)
setBytesAtOffset(bloomData, i*(packetSize+8), packetBytes)
Finally, extra precaution should be taken if an action allows the same recipient to receive multiple notifications in a single channel, for example, Alice can use batch_transfer
to send Bob multiple token transfers in a single execution. In such cases, implementors SHOULD omit those recipients' packets from the bloom data entirely, allowing the bloom filter to instruct those clients towards querying the contract for their actual notification. This is done to prevent the same packet ID from appearing multiple times in the bloom data (which would leak that those packets are not decoys and that some recipient received more than one notification). Since a client will not need to query the contract if they find their packet in the data, embedding some but not all of a recipient's packets would lead to the loss of information.
To put it concisely, if any recipient has more than one notification in an execution for a given channel, then all of their packets for that event SHOULD be omitted from the bloom data.
Notifications work by adding attributes to the Tendermint Event log. Since these originate from the contract, they are always prefixed by "wasm."
. Additionally, all SNIP-52 notifications MUST insert the "snip52:"
attribute key prefix, which helps identify SNIP-52 events in downstream services and on the client.
An event attribute can take on one of the following forms:
-
"wasm.snip52:${NOTIFICATION_ID}": "${ENCRYPTED_NOTIFICATION_DATA}"
for single-recipient notifications operating in Counter Mode or TxHash Mode. -
"wasm.snip52:#${CHANNEL_ID}": "${BLOOM_FILTER}${ARBITRARY_NOTIFICATION_DATA}"
for multi-recipient notifications operating in Bloom Mode.
A globally unique, single-use identifier that is deterministically generated using a cryptographic hash function. Notification IDs can be generated by both contract and client.
An event's Notification ID is used for the custom attribute's key in the transaction log when the channel notifies a single recipient, i.e., "wasm.snip52:${NOTIFICATION_ID}"
, and is used to compute bloom filter hashes when the channel notifies multiple recipients.
Allows a contract to distinguish between different types of events affecting a recipient. For example, an NFT trading contract might have separate channels for "bids" and "buys".
A shared secret between client and contract, used as input key material when deriving Notification IDs. It is required to have high entropy. Therefore, clients are only allowed to modify seeds using digital signatures. See UpdateSeed for more info.
By default, contracts derive a client's Notification Seed using an internal secret not known to ANY party (including admins). This allows clients to obtain their Notification IDs without having to execute a transaction. For an increased privacy guarantee, clients can execute a transaction to set a new Notification Seed.
In order to generate a unique Notification ID for each subsequent event, clients and contracts MUST produce a number only used once (i.e., a nonce) as input to the hash function. They must share an understanding for how/when to increment the nonce so that clients can continue to derive new Notification IDs offline.
For channels operating in Counter Mode, a simple counter scheme is used to derive new nonce values, meaning that the nonce MUST increment by exactly one for each new notification. That way, clients and contracts are able to keep their nonce values in sync.
Contracts MAY optionally provide supplemental data with each notification. For example, a transfer notification may include the sender's address and token amount, encrypted in the attribute's value, i.e., "wasm.snip52:${NOTIFICATION_ID}": "${ENCRYPED_NOTIFICATION_DATA}"
.
Developers looking to take advantage of this option should understand that ALL notifications (including decoys) will need to pad notification data to some predetermined maximum length in order to avoid privacy leaks. It is generally advised to design such payloads to be as short as possible.
Contracts SHOULD encode notification data using CBOR, where the top-level element is always a tuple. Furthermore, contracts SHOULD provide a Concise Data Definition Language (CDDL) definition string in the ChannelInfo Query response (under the "cddl"
key) which describes the payload.
For maximum interoperability:
- Top-level CBOR value should be a tuple
- CDDL type definition name should match its channel ID
- All elements should be annotated with human-readable names in the CDDL
Walking through the basic SNIP-2x "transfers" example, a notification would want to include the amount received and the sender. Since the token amount could possibly exceed the range of uint64
, we use biguint
instead. To keep the payload as short as possible, we transmit the sender's address in canonical byte form. An appropriate CDDL for its channel would look like this:
transfers = [
amount: biguint, ; number of indivisible token units
sender: bstr, ; byte sequence of sender's canonical address
]
Evaluating this against the rubric above:
- The top-level value is a CBOR tuple ✔
- The CDDL type definition "transfers" matches the channel ID ✔
- All elements in the tuple ("amount" and "sender") are named for human-readability ✔
Now imagine Bob transfers 1.25 token X to Alice. The corresponding information would be as follows:
amount: 1250000 micro TKN
sender: secret1dg4gt6fc2avp2ywgvrxxmptc670av0372u2gv5
Encoding this information in CBOR according the above "transfers" schema results in the following payload (shown here in diagnostic notation):
[1250000, h'6a2a85e93857581511c860cc6d8578d79fd63e3e']
For more information about CBOR and CDDL:
Public query to list all notification channels.
Query:
{
"list_channels": {},
}
Show TypeScript equivalent
type ListChannelsQueryMsg = {
list_channels: {};
};
Response:
{
"list_channels": {
"channels": [
"<id of channel 1>",
"...",
"<id of channel N>"
]
}
}
Show TypeScript equivalent
type ListChannelsQueryResponse = {
list_channels: {
channels: string[];
};
};
Authenticated query allows clients to obtain the seed, counter, and Notification ID of a future event, for a specific set of channels.
Query:
{
"channel_info": {
"channels": ["<id of channel>", "<...optional list of additional channel ids>"],
"txhash": "<optional 64 hex characters of a transaction hash>",
"viewer": {
"address": "<address of the querier>",
"viewing_key": "<viewer's key>"
}
}
}
Show TypeScript equivalent
type ChannelInfoQueryMsg = {
channels: string[];
txhash?: string;
viewer: {
address: string; // bech32
viewing_key: string;
};
};
Name | Type | Description | Optional |
---|---|---|---|
channels | array of strings | A list of channel IDs | no |
txhash | string | A transaction hash to compute the Notification ID | yes |
viewer | ViewerInfo | The address and viewing key performing this query | no |
Response:
NOTE: The shape of each item in the
channels
array depends on itsmode
value (either"txhash"
,"counter"
or"bloom"
). See below for more details.
{
"channel_info": {
"as_of_block": "<scopes validity of this response>",
"seed": "<shared secret in base64>",
"channels": [
{
"channel": "<channel id, corresponds to query input>",
"mode": "txhash",
"cddl": "<optional CDDL schema definition string for the CBOR-encoded notification data>",
"answer_id": "<if txhash argument was given, this will be its computed Notification ID>"
},
{
"channel": "<channel id, corresponds to query input>",
"mode": "counter",
"cddl": "<optional CDDL schema definition string for the CBOR-encoded notification data>",
"counter": "<current counter value>",
"next_id": "<the next Notification ID>"
},
{
"channel": "<channel id, corresponds to query input>",
"mode": "bloom",
"parameters": {
"m": 512,
"k": 15,
"h": "sha256"
},
"data": {
"...": "<data schema>",
},
"answer_id": "<if txhash argument was given, this will be its computed Notification ID>"
}
{ "...": "..." }
]
}
}
Show TypeScript equivalent
type ChannelInfoQueryResponse = {
as_of_block: string; // uint64
channels: ChannelInfo[];
};
type ChannelInfo = {
channel: string;
seed: string; // base64
} & ({
mode: "counter";
cddl?: string; // cddl
counter: string; // uint64
next_id: string;
} | {
mode: "txhash";
cddl?: string; // cddl
answer_id?: string;
} | {
mode: "bloom";
parameters: {
m: number;
k: number;
h: string;
};
data: Descriptor; // see "Attaching private data" section
answer_id?: string;
});
If a channel is operating in Counter Mode, given by "mode": "counter"
, then its response row includes the current counter value (under the counter
key) and the next Notification ID (under the next_id
key) corresponding to the given channel affecting the current viewer (who was specified in the query authentication data, depending on whether a query permit or ViewerInfo was used).
If a channel is operating in TxHash Mode or Bloom Mode and the client provides a value for txhash
, then its response SHOULD include an answer_id
field containing its computed notification ID for the given tx hash.
The response also provides the viewer's current seed for each given channel, allowing the client to derive future Notification IDs for this channel offline (i.e., without having to query the contract again).
SNIP-52 contracts may optionally implement query permits as specified in SNIP-24.
WithPermit wraps permit queries in the same manner as SNIP-24.
Query:
{
"with_permit": {
"permit": {
"params": {
"permit_name": "some_name",
"allowed_tokens": ["addr_1", "addr_2", "..."],
"chain_id": "some_chain_id",
"permissions": ["owner"]
},
"signature": {
"pub_key": {
"type": "tendermint/PubKeySecp256k1",
"value": "33_bytes_of_secp256k1_pubkey_as_base64"
},
"signature": "64_bytes_of_secp256k1_signature_as_base64"
}
},
"query": {
"QueryWithPermit_variant_defined_below": { "...": "..." }
}
}
}
Show TypeScript equivalent
type WithPermitQuery = {
permit: {
params: {
permit_name: string;
allowed_tokens: string[]; // bech32s
chain_id: string;
permissions: string[];
};
signature: {
type: "tendermint/PubKeySecp256k1";
value: string; // base64
};
};
query: Omit<AuthenticatedQueries, "viewer">;
};
type AuthenticatedQueries = ChannelInfoQueryMsg;
Name | Type | Description | Optional |
---|---|---|---|
permit | Permit | A permit following SNIP-24 standard | no |
query | QueryWithPermit (see below) | The query to perform and its input parameters | no |
QueryWithPermit is an enum whose single variant correlates with the SNIP-52 query that requires authentication (ChannelInfo). The input parameters are the same as the corresponding query other than the absence of ViewerInfo because the permit supplied with the WithPermit
query provides both the address and authentication.
- ChannelInfo (corresponding query)
{
"query": {
"channel_info": {
"channels": ["<id of channel>", "<...optional list of additional channel ids>"],
}
}
}
Allows clients to set a new shared secret. In order to guarantee the provided secret has high entropy, clients must submit a signed document params and signature to be verified before the new shared secret (a SHA-256 hash of the signature) is accepted.
See the Updating Seed Algorithm for details on how the contract should handle this message.
The signed document follows the same format as query permits, but with type
"notification_seed"
and value
containing the two fields contract
and previous_seed
, both of which the contract will auto-populate when verifying the permit:
{
"chain_id": "secret-4",
"account_number": "0",
"sequence": "0",
"msgs": [
{
"type": "notification_seed",
"value": {
"contract": "<bech32 address of contract>",
"previous_seed": "<base64-encoded value of previous seed>"
}
}
],
"fee": {
"amount": [
{
"denom": "uscrt",
"amount": "0"
}
],
"gas": "1"
},
"memo": ""
}
Request:
{
"update_seed": {
"signed_doc": {
"params": {
"chain_id": "secret-4",
},
"signature": {
"pub_key": {
"type": "tendermint/PubKeySecp256k1",
"value": "<33 bytes of secp256k1 pubkey as base64>"
},
"signature": "<64 bytes of secp256k1 signature as base64>"
}
}
}
}
Show TypeScript equivalent
type UpdateSeedExecMsg = {
update_seed: {
signed_doc: {
params: {
chain_id: string;
};
};
signature: {
pub_key: {
type: "tendermint/PubKeySecp256k1";
value: string; // base64
};
signature: string; // base64
};
};
};
Response:
{
"update_seed": {
"seed": "<shared secret in base64>"
}
}
Show TypeScript equivalent
type UpdateSeedExecResponse = {
update_seed: {
seed: string; // base64
};
};
Contracts should strive to create an internal secret such that even the creator cannot predict or extract its contents.
Typically, such secrets are generated upon initialization. A suitably strong and robust method for generating this secret combines user-provided entropy and Secret Network's verifiable randomness API. Pseudocode for reference only:
fun initializeContract(msg, env) {
// gather entropy from sender
let userEntropy := msg.entropy
// extend entropy with environmental information
let entropy := concat(
env.blockHeight,
env.blockTime,
env.senderAddress,
userEntropy
)
// the crux: obtain a unique, cryptographically-strong random value associated with this execution
let seed := env.random()
// also very important: derive the contract's internal secret using HKDF
let internalSecret := hkdf_sha256(ikm=seed, salt=sha256(entropy), info="contract_internal_secret", length=32)
// save to storage
saveInternalSecretToStorage(internalSecret);
// ...
}
NOTE: The above approach would still allow a malicious contract deployer to hypothetically deduce the contract's internal secret. In order to achieve greater levels of trust, the process of deriving the internal secret would require several subsequent executions where multiple third parties provide additional (secret) entropy. However, such an approach is complex and outside the scope of this document.
Pseudocode for settling on a seed to use (contract):
fun getSeedFor(recipientAddr) {
// recipient has a shared secret with contract
let seed := sharedSecretsTable[recipientAddr]
// no explicit shared secret; derive seed using contract's internal secret
if NOT exists(seed):
seed := hkdf_sha256(ikm=contractInternalSecret, info=canonical(recipientAddr))
return seed
}
Pseudocode for verifying update_seed
arguments and storing new seed (contract):
fun updateSeed(recipientAddr, signedDoc, env) {
// check that the params are accurate
assert(signedDoc.params.contract == env.contractAddr)
assert(signedDoc.params.previous_seed == sharedSecretsTable[recipientAddr])
// verify that the signature belongs to the sender and is for the given signed document
verifySecp256k1Signature(env.senderPubKey, signedDoc.signature, {
"chain_id": signedDoc.params.chain_id,
"account_number": "0",
"sequence": "0",
"msgs": [
{
"type": "notification_seed",
"value": {
"contract": signedDoc.params.contract,
"previous_seed": signedDoc.params.previous_seed
}
}
],
"fee": {
"amount": [
{
"denom": "uscrt",
"amount": "0"
}
],
"gas": "1"
},
"memo": ""
})
// hash the signature to get the 32 byte shared secret
let sharedSecret := sha256(signedDoc.signature.signature)
// save the shared secret to storage associated with the given recipient
saveToSharedSecretsTable(recipientAddr, sharedSecret)
}
Pseudocode for generating Notification IDs (both contract & client):
fun notificationIdFor(contractOrRecipientAddr, channelId, env) {
let salt := nil
// depending on which mode the channel operates in
if inCounterMode(channelId):
// counter reflects the nth notification for the given contract/recipient in the given channel
let counter := getCounterFor(contractOrRecipientAddr, channelId)
salt := uintToDecimalString(counter)
// otherwise, channel is in TxHash Mode or Bloom Mode
else:
salt := env.txHash
// compute notification ID for this event
let seed := getSeedFor(contractOrRecipientAddr)
let material := concatStrings(channelId, ":", salt)
let notificationId := hmac_sha256(key=seed, message=utf8ToBytes(material))
return notificationId
}
Contracts are encouraged to use CBOR to encode/decode information in the notification data.
Pseudocode for encrypting data into single-recipient notifications (contract):
fun encryptNotificationData(recipientAddr, channelId, plaintext, env) {
// ChaCha20 expects a 96-bit (12 bytes) nonce. we will combine two 12 byte buffers to create nonce
let saltBytes := nil
// depending on which mode the channel operates in
if inCounterMode(channelId):
// counter reflects the nth notification for the given recipient in the given channel
let counter := getCounterFor(recipientAddr, channelId)
// encode uint64 counter in BE and left-pad with 4 bytes of 0x00 to make 12 bytes
saltBytes := concat(zeros(4), uint64BigEndian(counter))
// otherwise, channel is in TxHash Mode
else:
// take first 12 bytes of tx hash (make sure to decode the hex string)
saltBytes := slice(hexToBytes(env.txHash), 0, 12)
// take the first 12 bytes of the channel id's sha256 hash
let channelIdBytes := slice(sha256(utf8ToBytes(channelId)), 0, 12)
// produce the nonce by XOR'ing the two previous 12-byte results
let nonce := xorBytes(channelIdBytes, saltBytes)
// right-pad the plaintext with 0x00 bytes until it is of the desired length (keep in mind, payload adds 16 bytes for tag)
let message := concat(plaintext, zeros(DATA_LEN - len(plaintext)))
// construct the additional authenticated data
let aad := concatStrings(env.blockHeight, ":", env.txHash)
// encrypt notification data for this event
let seed := getSeedFor(recipientAddr)
let [ciphertext, tag] := chacha20poly1305_encrypt(key=seed, nonce=nonce, message=message, aad=aad)
// concatenate ciphertext and 16 bytes of tag (note: crypto libs typically default to doing it this way in `seal`)
let payload := concat(ciphertext, tag)
return payload
}
Pseudocode for decrypting data from single-recipient notifications (client):
fun decryptNotificationData(contractAddr, channelId, payload, env) {
// depending on which mode the channel operates in
if inCounterMode(channelId):
// counter reflects the nth notification for the given recipient in the given channel
let counter := getCounterFor(recipientAddr, channelId)
// encode uint64 counter in BE and left-pad with 4 bytes of 0x00 to make 12 bytes
saltBytes := concat(zeros(4), uint64BigEndian(counter))
// otherwise, channel is in TxHash Mode
else:
// take first 12 bytes of tx hash (make sure to decode the hex string)
saltBytes := slice(hexToBytes(env.txHash), 0, 12)
// ChaCha20 expects a 96-bit (12 bytes) nonce
// take the first 12 bytes of the channel id's sha256 hash
let channelIdBytes := slice(sha256(utf8ToBytes(channelId)), 0, 12)
// produce the nonce by XOR'ing the two previous 12-byte results
let nonce := xorBytes(channelIdBytes, counterBytes)
// construct the additional authenticated data
let aad := concatStrings(env.blockHeight, ":", env.txHash)
// split payload
let ciphertext := slice(payload, 0, len(payload) - 16)
let tag := slice(payload, len(ciphertext))
// decrypt notification data
let seed := getSeedFor(contractAddr)
let message := chacha20poly1305_decrypt(key=seed, nonce=nonce, message=ciphertext, tag=tag, aad=aad)
// do not trim trailing zeros because there is no END marker in CBOR. just decode plaintext as-is
let plaintext := message
return plaintext
}
Pseudocode for dispatching a notification (contract):
fun dispatchNotification(recipientAddr, channelId, plaintext, env) {
// obtain the current notification ID
let notificationId := notificationIdFor(recipientAddr, channelId)
// construct the notification data payload
let payload := encryptNotificationData(recipientAddr, channelId, plaintext, env);
// increment the counter
incrementCounterFor(contractAddr, channelId)
// emit the notification
addAttributeToEventLog(notificationId, payload)
}
A quick overview of the involved cryptographic features:
The contract must derive an internal secret upon initialization.
Subsequently, the contract uses its internal secret and a recipient's address to derive a unique key for that recipient without them having to execute a tx (it gets shared when they make an authenticated query for it).
Recipients can optionally establish a new shared secret with the contract to provide better security against hypothetical side-chain attacks. The contract enforces determinism and high entropy for new shared secrets by requiring users to submit a signed document that references the previous seed.
Used to generate Notification IDs.
This AEAD (authenticated encryption with additional data) algorithm was selected to encrypt and authenticate notification data based on its low-cost performance profile, making very efficient use of gas, and its widespread adoption, simplifying both contract and client-side implementations.
Within the contract, implementations are advised to use RustCrypto's AEADs (docs, crate, GitHub), which has been audited for usage inside SGX enclaves (report).
Contracts SHOULD pad the value of the encrypted attribute added to the event log (i.e., the Notification Data) to some constant length per channel.
For example, consider a "direct_message" channel with the following CDDL:
direct_message = [
id: uint,
replying_to: uint,
contents: text,
]
In CBOR, the maximum value of uint
is 64 bits (8 bytes), and text
(or tstr
) does not have an inherent limit. In order to achieve constant-length notification data, we need to enforce a maximum size for the contents
member of this tuple.
Assuming we set a maximum byte length of 128 bytes per direct message contents, we can then calculate the maximum size of a notification data's plaintext as follows:
+ 1 byte ; cbor array length < 24
+ 1 byte ; uint type
+ 8 bytes ; id value
+ 1 bytes ; uint type
+ 8 bytes ; replying_to value
+ 2 bytes ; text string length >= 24 and < 255
+ 128 bytes ; contents value
= 149 bytes ; plaintext size
Finally, in the Notification Data Algorithms pseudocode, we would set DATA_LEN
to 149
bytes. This ensures that the "direct_message" channel always emits a constant-length attribute value.
NOTE: even if a channel is only using
uint
, padding still applies since CBOR will use the shortest possible encoding.
Contracts SHOULD emit a constant number of attributes to the event log on every execution in order to conceal which transactions emitted notification(s) versus those that didn't.
For example, if a contract employs three distinct notification channels, then every transaction should result in three key-value attributes being added to the event log (e.g., by calling add_attribute_plaintext(...)
three times) no matter what the execution message was (and therefore no matter how many actual notifications were emitted).
When emitting decoy notifications, it is recommended to use all NULL bytes (up to DATA_LEN
) as the notification data and the contract's own address as the recipient.
Furthermore, it is advised to emit decoy notifications as if the channel was operating in TxHash Mode, so that an attacker cannot possibly deduce that a notification is a decoy. Emitting decoy notifications in TxHash mode also has the added benefit of not needing to store/update a counter variable.
The following section describes a hypothetical side-chain attack for channels operating in Counter Mode in which the attacker performs a replay attack. However, channels operating in TxHash Mode are completely immune to this type of attack and don't require any counter-measures. Contracts should still strive to emit a consistent number of events that appear as notifications in order to mask actual events with noise.
If a contract action allows any sender to trigger a notification for some recipient, then there is a risk that an attacker could perform a side-chain attack to precompute a victim's next Notification ID for a specific channel within a contract.
For example, Mallory has a balance of 0 IBC TKN on chain. She broadcasts a transaction with two messages: deposit 10 IBC TKN to the contract and then transfer 10 wrapped TKN to Alice. The execution fails since her IBC TKN balance is insufficient. Mallory then forks the chain, Cosmos Bank transfers 10 IBC TKN from another account, then replays the failed transaction on her side chain. This time, the deposit and subsequent wrapped TKN transfer succeeds since she has a sufficient balance. Mallory then records the emitted Notification ID (which belongs to Alice) and waits to observe that same Notification ID on the actual chain. At that point, Mallory would be able to deduce that someone transferred some amount of TKN to Alice.
Notice that the threat model looks very different if the contract only allowed friends of Alice to transfer tokens to her account (i.e., no longer any sender). In that case, only a friend of Alice would be able to perform the attack.
Also notice that if Alice executes the UpdateSeed method after Mallory forks the chain and before Bob transfers tokens, then Mallory's attack fails.
Again, TxHash Mode prevents this type attack, but sacrifices some of the benefits that come with predictable Notification IDs. Contract developers should consider the privacy requirements of their application when choosing which mode to use for a given channel.