SNIP-24 - Query permits for SNIP-20 tokens
This document describes a querying method for SNIP-20 tokens that is superior to using viewing keys. Contracts that support the existing SNIP-20 standard are still considered compliant, and clients should be written to benefit from these features when available, but provide fallback for when these features are not available.
The feature specified in this document is an improved UX for the Allowance
, Balance
, TransferHistory
& TransactionHistory
queries, aimed at enriching SNIP-20 (and SNIP-721) tokens usage and onset for new users.
- SNIP-24 - Query permits for SNIP-20 tokens
Viewing keys are passwords meant to validate users at times when the blockchain cannot. Specifically in queries, the query sender isn't authenticated and the contract doesn't know who is the querier. Therefore viewing keys were invented to provide a way of access control for users:
- Alice sends a transaction
set_viewing_key(password)
- The contract stores
(alice,password)
- Later on, a query is sent to the contract
query("balance",alice,password)
- If
(alice,password)
matches what's in storage, the contract returns Alice's balance to the querier.
- If
The main disadvantage of this method is that Alice must send a transaction before she can query her balance. This is bad UX for users new to Secret Network - Also because they have to pay SCRT gas to get a basic piece of information, but especially when traffic is high and nodes lag behind, queried nodes might have the query("balance")
answer but can't authenticate the querier because the node still didn't catch up with the set_viewing_key()
transaction.
Query permits are an alternative way of authenticating the querier. Instead of storing a password in the contract's state, the users signs a piece of data with their private keys, and then sends this data and the signature to the contract along with the query. The contract then validates the signature against the data, and returns an answer if the signature is validated.
This way users don't have to send a transaction before they can access their data.
Also note that the querier doesn't send the account's address to the contract, as the contract derives it from the public key attached to the signature.
The data structure for query permits was chosen to accommodate existing tools in the ecosystem, namely Keplr & secretd which already know how to sign this and don't require extra code and support.
The data being signed is a cosmos-sdk StdSignDoc
with some constraints.
// StdSignDoc is replay-prevention structure.
// It includes the result of msg.GetSignBytes(),
// as well as the ChainID (prevent cross chain replay)
// and the Sequence numbers for each signature (prevent
// inchain replay and enforce tx ordering per account).
type StdSignDoc struct {
AccountNumber uint64 `json:"account_number" yaml:"account_number"`
ChainID string `json:"chain_id" yaml:"chain_id"`
Fee json.RawMessage `json:"fee" yaml:"fee"`
Memo string `json:"memo" yaml:"memo"`
Msgs []json.RawMessage `json:"msgs" yaml:"msgs"`
Sequence uint64 `json:"sequence" yaml:"sequence"`
}
Field | Comment |
---|---|
AccountNumber |
must be 0 |
ChainID |
free-form, but when signing with Keplr must use the current chain-id |
Fee |
must be 0uscrt with 1 gas |
Memo |
must be an empty string |
Msgs |
an array with only one message of the type PermitMsg |
Sequence |
must be 0 |
Note that ChainID
can be just a free-form string, but Keplr enforces that it's the current chain-id. The contract doesn't care about chain-id and it just checks that the signature is correct. In practice a user can sign a permit on chain-id secret-3
and later on send it on chain-id secret-4
and it will be validated correctly (and that's okay!).
PermitMsg
is the only message allowed in Msgs
.
{
"type": "query_permit",
"value": {
"permit_name": "<free-form string>",
"allowed_tokens": ["<address_token_1>", "<address_token_2>", "..."],
"permissions": ["balance", "history", "allowance"]
}
}
type
is always the stringquery_permit
.value.permit_name
is a free-form string. The user can later revoke this permit using this name.value.allowed_tokens
is a list of token addresses to which this permit applies.value.permissions
is an array that may containbalance
,history
andallowance
.balance
- gives permission to query thebalance
of the permit signer.history
- gives permission to query thetransfer_history
andtransaction_history
of the permit signer.allowance
- gives permission to query theallowance
of the permit signer as anowner
and as aspender
.
{
"chain_id": "secret-4",
"account_number": "0",
"sequence": "0",
"msgs": [
{
"type": "query_permit",
"value": {
"permit_name": "test",
"allowed_tokens": ["secret18vd8fpwxzck93qlwghaj6arh4p7c5n8978vsyg"],
"permissions": ["balance"]
}
}
],
"fee": {
"amount": [
{
"denom": "uscrt",
"amount": "0"
}
],
"gas": "1"
},
"memo": ""
}
Signature is a JSON object of this type:
{
"pub_key": {
"type": "tendermint/PubKeySecp256k1",
"value": "<33 bytes of secp256k1 pubkey as base64>"
},
"signature": "<64 bytes of secp256k1 signature as base64>"
}
It's the output of window.keplr.signAmino()
& secretd tx sign-doc
, and represents a signature on the permit's content with the secp256k1 private key of the account.
Reference implementations for how to create this signature:
A way for users to revoke permits that they signed in the past.
Name | Type | Description | optional |
---|---|---|---|
permit_name | string | The name of the permit | no |
padding | string | Ignored string used to maintain constant-length messages | yes |
{
"revoke_permit": {
"permit_name": "some name"
}
}
{
"revoke_permit": {
"status": "success"
}
}
WithPermit
wraps all the queries that support permits.
{
"with_permit": {
"query": {
"allowance/balance/transfer_history/transaction_history": { "...": "..." }
},
"permit": {
"params": {
"permit_name": "<some name>",
"allowed_tokens": ["<address_token_1>", "<address_token_2>", "..."],
"chain_id": "<some chain-id>",
"permissions": ["balance", "history", "allowance"]
},
"signature": {
"pub_key": {
"type": "tendermint/PubKeySecp256k1",
"value": "<33 bytes of secp256k1 pubkey as base64>"
},
"signature": "<64 bytes of secp256k1 signature as base64>"
}
}
}
}
This returns the available allowance that spender can access from the owner's account, along with the expiration info.
The expiration
field of the response may be either null
or unset if no expiration has been set.
Name | Type | Description | optional |
---|---|---|---|
with_permit.query.allowance.owner | string | Account from which tokens are allowed to be taken | no |
with_permit.query.allowance.spender | string | Account which is allowed to spend tokens on behalf of the owner | no |
{
"allowance": { "owner": "<address>", "spender": "<address>" }
}
{
"allowance": {
"spender": "<address>",
"owner": "<address>",
"allowance": "<current allowance>",
"expiration": 1234
}
}
Returns the balance of the given address. Returns "0" if the address is unknown to the contract.
{
"balance": {}
}
{
"balance": {
"amount": "123"
}
}
See SNIP20/TransferHistory & SNIP21/TransferHistory for full description.
Name | Type | Description | optional |
---|---|---|---|
with_permit.query.transfer_history.page_size | number | Number of transactions to return, starting from the latest. i.e. n=1 will return only the latest transaction | no |
with_permit.query.transfer_history.page | number | Defaults to 0. Specifying a positive number will skip page * page_size txs from the start. |
yes |
{
"transfer_history": {
"page_size": 10,
"page": 0
}
}
{
"transfer_history": {
"txs": [
{
"id": 123,
"from": "secret1xyz",
"sender": "secret1xyz",
"receiver": "secret1xyz",
"coins": {
"denom": "FOOBAR",
"amount": "123000000"
},
"block_time": 12006,
"block_height": 101
}
]
}
}
See SNIP21/TransactionHistory for full description.
Name | Type | Description | optional |
---|---|---|---|
with_permit.query.transaction_history.page_size | number | Number of transactions to return, starting from the latest. i.e. n=1 will return only the latest transaction | no |
with_permit.query.transaction_history.page | number | Defaults to 0. Specifying a positive number will skip page * page_size txs from the start. |
yes |
{
"transaction_history": {
"page_size": 10,
"page": 0
}
}
{
"transaction_history": {
"total": 200,
"txs": [
{
"id": "optional ID",
"block_time": 12000,
"block_height": 100,
"coins": {
"denom": "coin denomination/name",
"amount": "Uint128"
},
"memo": "private message",
"action": {}
}
]
}
}
const permitName = "secretswap.io";
const allowedTokens = ["secret18vd8fpwxzck93qlwghaj6arh4p7c5n8978vsyg"];
const permissions = ["balance" /* , "history", "allowance" */];
const { signature } = await window.keplr.signAmino(
chainId,
myAddress,
{
chain_id: chainId,
account_number: "0", // Must be 0
sequence: "0", // Must be 0
fee: {
amount: [{ denom: "uscrt", amount: "0" }], // Must be 0 uscrt
gas: "1", // Must be 1
},
msgs: [
{
type: "query_permit", // Must be "query_permit"
value: {
permit_name: permitName,
allowed_tokens: allowedTokens,
permissions: permissions,
},
},
],
memo: "", // Must be empty
},
{
preferNoSetFee: true, // Fee must be 0, so hide it from the user
preferNoSetMemo: true, // Memo must be empty, so hide it from the user
}
);
const { balance } = await secretjs.query.compute.queryContract({
contract_address: contract.address,
code_hash: contract.codeHash,
query: {
with_permit: {
query: { balance: {} },
permit: {
params: {
permit_name: permitName,
allowed_tokens: allowedTokens,
chain_id: chainId,
permissions: permissions,
},
signature: signature,
},
},
},
});
console.log(balance.amount);
$ echo '{
"chain_id": "secret-4",
"account_number": "0",
"sequence": "0",
"msgs": [
{
"type": "query_permit",
"value": {
"permit_name": "test",
"allowed_tokens": [
"secret18vd8fpwxzck93qlwghaj6arh4p7c5n8978vsyg"
],
"permissions": ["balance"]
}
}
],
"fee": {
"amount": [
{
"denom": "uscrt",
"amount": "0"
}
],
"gas": "1"
},
"memo": ""
}' > ./permit.json
$ secretd tx sign-doc ./permit.json --from yo > ./sig.json
$ secretd q compute query secret18vd8fpwxzck93qlwghaj6arh4p7c5n8978vsyg '{"with_permit":{"query":{"balance":{}},"permit":{"params":{"permit_name":"test","allowed_tokens":["secret18vd8fpwxzck93qlwghaj6arh4p7c5n8978vsyg"],"chain_id":"secret-4","permissions":["balance"]},"signature":'"$(cat ./sig.json)"'}}}'