Safe-word CosmWasm Authenticator
Build a CosmWasm authenticator contract that authorizes transactions containing a pre-registered safe-word — custom authentication logic with key rotation
Safe-word CosmWasm Authenticator
This example demonstrates a custom CosmWasm authenticator that uses a pre-registered safe-word to authorize a key rotation. When a transaction includes the safe-word and a new public key, the authenticator approves the authentication and updates the expected key for future transactions.
How It Works
- Setup — The contract stores a SHA-256 hash of a safe-word chosen by the user
- Registration — The user registers the contract as a
CosmwasmAuthenticatorV1on their account, passing the safe-word hash as params - Authentication — When a transaction arrives containing
{ "safeword": "<the_word>", "pubkey": "<new_key>" }in its memo, the contract:- Checks the revealed safe-word against the stored hash
- If it matches, accepts authentication and stores the new expected pubkey
- Future authentications check against the stored pubkey instead
- Completion — The account's effective authentication has been rotated to the new key
User stores: sha256("my_secret_word")
↓
Tx arrives with memo: {"safeword": "my_secret_word", "pubkey": "A7YH7..."}
↓
Contract: sha256("my_secret_word") == stored_hash? → YES
↓
Contract stores new pubkey as expected key
↓
Authentication succeeds → key is now rotatedPrerequisites
- Rust toolchain with
wasm32-unknown-unknowntarget cosmwasm-optimizerfor reproducible builds- Terp Network testnet or localnet for deployment
terpdCLI or a wallet with test tokens
Contract Implementation
1. Messages and State
use cosmwasm_std::{
entry_point, Binary, DepsMut, Env, HexBinary, MessageInfo, Response, StdError, StdResult,
};
use cw_storage_plus::Item;
use sha2::{Digest, Sha256};
use serde::{Deserialize, Serialize};
// --- State ---
/// The SHA-256 hash of the safe-word
const SAFEWORD_HASH: Item<HexBinary> = Item::new("safeword_hash");
/// The public key that will be authorized after safe-word auth succeeds
const AUTHORIZED_PUBKEY: Item<HexBinary> = Item::new("authorized_pubkey");
// --- Sudo messages called by the smart-account module ---
#[derive(Serialize, Deserialize)]
pub struct OnAuthenticatorAddedRequest {
pub account: String,
pub authenticator_params: Option<Binary>,
pub authenticator_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct OnAuthenticatorRemovedRequest {
pub account: String,
pub authenticator_params: Option<Binary>,
pub authenticator_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthenticationRequest {
pub authenticator_id: String,
pub account: String,
pub fee_payer: String,
pub fee: Vec<Coin>,
pub msg: Binary,
pub msg_index: u64,
pub signature: Option<Binary>,
pub authenticator_params: Option<Binary>,
}
#[derive(Serialize, Deserialize)]
pub struct TrackRequest {
pub authenticator_id: String,
pub account: String,
pub fee_payer: String,
pub fee: Vec<Coin>,
pub msg: Binary,
pub msg_index: u64,
pub authenticator_params: Option<Binary>,
}
#[derive(Serialize, Deserialize)]
pub struct ConfirmExecutionRequest {
pub authenticator_id: String,
pub account: String,
pub fee_payer: String,
pub fee: Vec<Coin>,
pub msg: Binary,
pub msg_index: u64,
pub authenticator_params: Option<Binary>,
}
// The sudo entrypoint receives one of these
#[derive(Serialize, Deserialize)]
pub enum SudoMsg {
OnAuthenticatorAdded(OnAuthenticatorAddedRequest),
OnAuthenticatorRemoved(OnAuthenticatorRemovedRequest),
Authenticate(AuthenticationRequest),
Track(TrackRequest),
ConfirmExecution(ConfirmExecutionRequest),
}2. Contract Entrypoints
/// Instantiate sets up the safe-word hash.
/// In this design, the safe-word hash is passed as authenticator_params
/// when registering the authenticator, so instantiate is minimal.
#[entry_point]
pub fn instantiate(
_deps: DepsMut,
_env: Env,
_info: MessageInfo,
_msg: Empty,
) -> StdResult<Response> {
Ok(Response::new().add_attribute("method", "instantiate"))
}
/// Sudo receives calls from the smart-account module.
#[entry_point]
pub fn sudo(deps: DepsMut, _env: Env, msg: SudoMsg) -> StdResult<Response> {
match msg {
SudoMsg::OnAuthenticatorAdded(req) => on_authenticator_added(deps, req),
SudoMsg::OnAuthenticatorRemoved(req) => on_authenticator_removed(deps, req),
SudoMsg::Authenticate(req) => authenticate(deps, req),
SudoMsg::Track(_req) => Ok(Response::new().add_attribute("method", "track")),
SudoMsg::ConfirmExecution(_req) => Ok(Response::new().add_attribute("method", "confirm_execution")),
}
}3. OnAuthenticatorAdded — Store the Safe-word Hash
When the user registers this contract as an authenticator, the authenticator_params contains the SHA-256 hash of their safe-word:
pub fn on_authenticator_added(
deps: DepsMut,
req: OnAuthenticatorAddedRequest,
) -> StdResult<Response> {
let params = req.authenticator_params
.ok_or_else(|| StdError::generic_err("missing authenticator_params: expected sha256 safe-word hash"))?;
// Params must be 32 bytes (SHA-256 output)
let hash = HexBinary::from_hex(¶ms.to_string())
.map_err(|_| StdError::generic_err("params must be hex-encoded sha256 hash (64 hex chars)"))?;
if hash.len() != 32 {
return Err(StdError::generic_err("sha256 hash must be exactly 32 bytes"));
}
SAFEWORD_HASH.save(deps.storage, &hash)?;
Ok(Response::new()
.add_attribute("method", "on_authenticator_added")
.add_attribute("account", &req.account)
.add_attribute("authenticator_id", &req.authenticator_id))
}4. Authenticate — Verify the Safe-word and Rotate Key
This is the core logic. The contract checks the signature field for the safe-word. If it matches the stored hash, the authentication succeeds and the pubkey from the signature is stored for future checks:
/// The authentication data is encoded in the `signature` field as JSON:
/// {"safeword": "my_secret_word", "pubkey": "A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y="}
///
/// On first use (safe-word auth): the safe-word is checked against the stored hash.
/// If correct, the pubkey is saved and authentication succeeds.
/// On subsequent use (pubkey auth): the tx signature is checked against the stored pubkey.
pub fn authenticate(deps: DepsMut, req: AuthenticationRequest) -> StdResult<Response> {
use base64::Engine;
// Check if we already have an authorized pubkey (second phase)
if let Ok(stored_pubkey) = AUTHORIZED_PUBKEY.load(deps.storage) {
// Second phase: verify the tx signature against the stored pubkey
let signature = req.signature
.ok_or_else(|| StdError::generic_err("signature required for pubkey authentication"))?;
// The msg bytes are the signed data
let msg_bytes = req.msg.to_vec();
// Verify secp256k1 signature (using cosmwasm_crypto)
let verified = deps.api.secp256k1_verify(
&msg_bytes,
&signature,
&stored_pubkey.to_vec(),
)?;
if !verified {
return Err(StdError::generic_err("signature verification failed against authorized pubkey"));
}
return Ok(Response::new()
.add_attribute("method", "authenticate")
.add_attribute("mode", "pubkey")
.add_attribute("authenticator_id", &req.authenticator_id));
}
// First phase: safe-word authentication
let auth_data = req.signature
.ok_or_else(|| StdError::generic_err("missing auth data"))?;
// Parse {"safeword": "...", "pubkey": "..."}
#[derive(Deserialize)]
struct AuthPayload {
safeword: String,
pubkey: String,
}
let payload: AuthPayload = cosmwasm_std::from_json(&auth_data)
.map_err(|_| StdError::generic_err("auth data must be JSON: {\"safeword\":\"...\",\"pubkey\":\"...\"}"))?;
// Hash the revealed safe-word
let mut hasher = Sha256::new();
hasher.update(payload.safeword.as_bytes());
let revealed_hash = HexBinary::from(hasher.finalize().to_vec());
// Compare against stored hash
let stored_hash = SAFEWORD_HASH.load(deps.storage)?;
if revealed_hash != stored_hash {
return Err(StdError::generic_err("safe-word hash mismatch: authentication denied"));
}
// Decode and store the new pubkey for future authentications
let pubkey_bytes = HexBinary::from_hex(&payload.pubkey)
.or_else(|_| {
// also accept base64
let decoded = base64::engine::general_purpose::STANDARD
.decode(&payload.pubkey)
.map_err(|_| StdError::generic_err("pubkey must be hex or base64"))?;
Ok::<_, StdError>(HexBinary::from(decoded))
})?;
AUTHORIZED_PUBKEY.save(deps.storage, &pubkey_bytes)?;
// Clear the safe-word hash (one-time use)
SAFEWORD_HASH.remove(deps.storage);
Ok(Response::new()
.add_attribute("method", "authenticate")
.add_attribute("mode", "safeword")
.add_attribute("new_pubkey_stored", "true")
.add_attribute("authenticator_id", &req.authenticator_id))
}5. OnAuthenticatorRemoved — Cleanup
pub fn on_authenticator_removed(
deps: DepsMut,
req: OnAuthenticatorRemovedRequest,
) -> StdResult<Response> {
SAFEWORD_HASH.remove(deps.storage);
AUTHORIZED_PUBKEY.remove(deps.storage);
Ok(Response::new()
.add_attribute("method", "on_authenticator_removed")
.add_attribute("account", &req.account))
}Deployment
Step 1: Compile the Contract
# Add wasm target
rustup target add wasm32-unknown-unknown
# Build with optimizer for reproducible output
docker run --rm -v "$(pwd)":/code \
--platform linux/amd64 \
cosmwasm/optimizer:0.16.0Output: artifacts/safeword_authenticator.wasm
Step 2: Store and Instantiate on Terp Network
# Store the contract
RESP=$(terpd tx wasm store artifacts/safeword_authenticator.wasm \
--from my-account --gas auto --fees 50000uthiol \
--chain-id 90u-4 --node https://testnet-rpc.terp.network:443 -y -o json)
CODE_ID=$(echo $RESP | jq -r '.logs[0].events[] | select(.type == "store_code") | .attributes[] | select(.key == "code_id") | .value')
echo "Code ID: $CODE_ID"
# Instantiate (no special init needed — state is set up when authenticator is registered)
terpd tx wasm instantiate $CODE_ID '{}' \
--label "safeword-authenticator" \
--from my-account --fees 10000uthiol \
--chain-id 90u-4 --admin $(terpd keys show my-account -a) \
-y
# Get the contract address
CONTRACT_ADDR=$(terpd query wasm list-contract-by-code $CODE_ID -o json | jq -r '.contracts[0]')
echo "Contract: $CONTRACT_ADDR"import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { calculateFee } from '@cosmjs/stargate';
const client = await SigningCosmWasmClient.connectWithSigner(
'https://testnet-rpc.terp.network:443',
wallet,
);
// Store the compiled wasm
const storeResult = await client.upload(
sender,
safewordWasmBytes,
calculateFee(1000000, '0.05uthiol'),
);
const codeId = storeResult.codeId;
console.log('Code ID:', codeId);
// Instantiate
const instantiateResult = await client.instantiate(
sender,
codeId,
{},
'safeword-authenticator',
calculateFee(200000, '0.05uthiol'),
);
const contractAddress = instantiateResult.contractAddress;
console.log('Contract:', contractAddress);use cw_orch::prelude::*;
use safeword_authenticator::msg::InstantiateMsg;
let chain = DaemonBuilder::default()
.chain(Networks::TESTNET_90U_4)
.build()?;
let contract = SafewordAuthenticator::new("safeword", chain.clone());
contract.upload()?;
contract.instantiate(&InstantiateMsg {}, None, None)?;
let address = contract.address()?;
println!("Contract: {}", address);import (
"github.com/CosmWasm/wasmd/x/wasm/types"
"github.com/cosmos/cosmos-sdk/client/tx"
)
// Store the contract
storeMsg := &types.MsgStoreCode{
Sender: senderAddr.String(),
WASMByteCode: safewordWasmBytes,
}
// Build, sign, broadcast store tx
txBuilder := txFactory.BuildUnsignedTx(storeMsg)
// ... sign and broadcast
codeId := uint64(1) // from tx response
// Instantiate
instantiateMsg := []byte("{}")
instantiate := &types.MsgInstantiateContract{
Sender: senderAddr.String(),
Admin: senderAddr.String(),
CodeID: codeId,
Label: "safeword-authenticator",
Msg: instantiateMsg,
Funds: sdk.NewCoins(),
}
txBuilder = txFactory.BuildUnsignedTx(instantiate)
// ... sign and broadcast
contractAddr := "terp1..." // from tx response
fmt.Println("Contract:", contractAddr)from cosmos_sdk.cosmwasm import CosmWasmClient
client = CosmWasmClient(
rpc="https://testnet-rpc.terp.network:443",
mnemonic=mnemonic,
prefix="terp",
)
# Store the compiled wasm
with open("artifacts/safeword_authenticator.wasm", "rb") as f:
wasm_bytes = f.read()
store_result = client.store_code(wasm_bytes, fee={"amount": "50000uthiol", "gas": "1000000"})
code_id = store_result.code_id
print(f"Code ID: {code_id}")
# Instantiate
instantiate_result = client.instantiate(
code_id,
{},
label="safeword-authenticator",
fee={"amount": "10000uthiol", "gas": "200000"},
)
contract_addr = instantiate_result.contract_address
print(f"Contract: {contract_addr}")Registering as an Authenticator
Now register the contract as a CosmwasmAuthenticatorV1 on your account, passing the safe-word hash as params:
# Compute the sha256 hash of your safe-word
echo -n "my_secret_phrase_123" | sha256sum
# Output: a1b2c3d4e5f6... (64 hex chars)
# Register the contract as an authenticator
# The data field is JSON: {"contract":"<addr>","params":"<hex_hash>"}
terpd tx smart-account add-authenticator \
CosmwasmAuthenticatorV1 \
'{"contract":"terp1contract...","params":"a1b2c3d4e5f6..."}' \
--from my-account \
--chain-id 90u-4 \
--fees 5000uthiol \
--gas autoimport { Sha256 } from '@cosmjs/crypto';
import { toHex } from '@cosmjs/encoding';
// Compute safe-word hash
const safeword = 'my_secret_phrase_123';
const hash = new Sha256(new TextEncoder().encode(safeword)).digest();
const hashHex = toHex(hash);
// Build the authenticator config
const config = {
contract: contractAddress,
params: hashHex, // hex-encoded sha256 hash
};
const msgAdd = {
typeUrl: '/terp.smartaccount.v1beta1.MsgAddAuthenticator',
value: {
sender: sender,
authenticatorType: 'CosmwasmAuthenticatorV1',
data: new TextEncoder().encode(JSON.stringify(config)),
},
};
const result = await client.signAndBroadcast(sender, [msgAdd], fee);
const authId = result.events
.find(e => e.type === 'message')
.attributes.find(a => a.key === 'authenticator_id').value;
console.log('Authenticator ID:', authId);use sha2::{Sha256, Digest};
let safeword = b"my_secret_phrase_123";
let hash = Sha256::digest(safeword);
let config = serde_json::json!({
"contract": contract_addr.to_string(),
"params": hex::encode(hash),
});
let msg_add = MsgAddAuthenticator {
sender: sender.to_string(),
authenticator_type: "CosmwasmAuthenticatorV1".to_string(),
data: Binary::from(serde_json::to_vec(&config).unwrap()),
};import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
hash := sha256.Sum256([]byte("my_secret_phrase_123"))
config := map[string]interface{}{
"contract": contractAddr.String(),
"params": hex.EncodeToString(hash[:]),
}
configBytes, _ := json.Marshal(config)
msg := &types.MsgAddAuthenticator{
Sender: senderAddr.String(),
AuthenticatorType: "CosmwasmAuthenticatorV1",
Data: configBytes,
}import hashlib, json
safeword = "my_secret_phrase_123"
hash_hex = hashlib.sha256(safeword.encode()).hexdigest()
config = {
"contract": contract_addr,
"params": hash_hex,
}
msg = MsgAddAuthenticator(
sender=client.address,
authenticator_type="CosmwasmAuthenticatorV1",
data=json.dumps(config).encode(),
)Phase 1: Authenticate with the Safe-word
Send a transaction where the signature field in the authentication request contains {"safeword":"my_secret_phrase_123","pubkey":"<new_pubkey_hex>"}. The authenticator contract will:
- Hash the revealed safe-word
- Compare it to the stored hash
- If it matches, store the new pubkey and authorize the transaction
For custom authenticator data like the safe-word payload, use a direct transaction submission with the proper TxExtension. The auth payload {"safeword":"my_secret_phrase_123","pubkey":"<new_pubkey>"} is set as the signature bytes sent to the contract's Authenticate sudo handler.
# Build a raw tx with authenticator extension
# The auth data is a base64-encoded JSON payload
AUTH_DATA=$(echo -n '{"safeword":"my_secret_phrase_123","pubkey":"A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y="}' | base64 -w0)
# Submit via gRPC with TxExtension selecting authenticator ID 5
# and the auth data as the signature field
grpcurl -plaintext \
-d '{
"sender": "terp1sender...",
"authenticator_id": "5",
"auth_data": "'"$AUTH_DATA"'"
}' \
localhost:9090 terp.smartaccount.v1beta1.Msg/SubmitAuthenticatorTx// Phase 1: Send a tx with the safe-word authentication payload
const sendMsg = {
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
value: {
fromAddress: sender,
toAddress: 'terp1recipient...',
amount: [{ denom: 'uthiol', amount: '1000' }],
},
};
// The authenticator signature data containing the safe-word and new pubkey
const authPayload = JSON.stringify({
safeword: 'my_secret_phrase_123',
pubkey: 'A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y=',
});
// With CosmJS, authenticator signatures are passed as the `signature` field
// in the authentication extension. The smart-account module sends this to
// the contract's Authenticate sudo handler.
const extension = {
selectedAuthenticators: [authId],
// Custom authenticator data is injected into the signature field
};
const result = await client.signAndBroadcast(
sender,
[sendMsg],
fee,
authPayload, // memo carries the authenticator data
extension,
);// In cw-orchestrator, the TxExtension with authenticator data
// is set on the Daemon builder before broadcasting
use cw_orch::daemon::TxExtension;
let auth_payload = serde_json::json!({
"safeword": "my_secret_phrase_123",
"pubkey": "A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y=",
});
// The Daemon handles injecting the authenticator data into the tx
let tx_response = chain
.with_extension(TxExtension {
selected_authenticators: vec![auth_id],
})
.broadcast_tx(cosmos_msg)?;import (
"encoding/json"
smartaccounttypes "github.com/terpnetwork/terp-core/v5/x/smart-account/types"
)
authPayload, _ := json.Marshal(map[string]string{
"safeword": "my_secret_phrase_123",
"pubkey": "A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y=",
})
ext := &smartaccounttypes.TxExtension{
SelectedAuthenticators: []uint64{authId},
}
// Build tx with the extension and the authenticator data
// as the signature bytes
txBuilder := txFactory.BuildUnsignedTx(sendMsg)
txBuilder.SetExtension(ext)
// Set the signature bytes to the auth payload
// so the contract's Authenticate receives themimport json, base64
# Phase 1: craft the auth payload with safe-word and new pubkey
auth_payload = json.dumps({
"safeword": "my_secret_phrase_123",
"pubkey": "A7YH7q2Xs0mF3G5GhHFX5BgV5KJkWf3Vn9V6P4z2C7Y=",
})
# Build the bank send
send = BankMsg.send(
sender=client.address,
to="terp1recipient...",
amount=[("uthiol", 1000)],
)
# Submit with the authenticator extension
extension = TxExtension(selected_authenticators=[auth_id])
tx = client.broadcast(
send,
fee={"amount": "5000uthiol", "gas": "200000"},
memo=auth_payload,
extension=extension,
)
print(f"Phase 1 auth tx: {tx.txhash}")Phase 2: Authenticate with the New Key
After the safe-word has been consumed and the new pubkey stored, subsequent authentications use standard secp256k1 signature verification against the stored pubkey:
# Phase 2: just send a normal transaction signed by the new key
# The contract will verify the signature against the stored pubkey
terpd tx bank send \
terp1sender... terp1recipient... \
1000uthiol \
--from new-auth-key \
--chain-id 90u-4 \
--fees 5000uthiol// Phase 2: sign with the new key that was stored during Phase 1
// The wallet must have access to the new key's private key
const newWallet = await DirectSecp256k1HdWallet.fromMnemonic(newMnemonic, {
prefix: 'terp',
});
const newClient = await SigningStargateClient.connectWithSigner(
'https://testnet-rpc.terp.network:443',
newWallet,
);
const sendMsg = {
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
value: {
fromAddress: sender,
toAddress: 'terp1recipient...',
amount: [{ denom: 'uthiol', amount: '1000' }],
},
};
// The contract's Authenticate will verify the tx signature
// against the stored pubkey from Phase 1
const extension = { selectedAuthenticators: [authId] };
const result = await newClient.signAndBroadcast(
sender,
[sendMsg],
fee,
'',
extension,
);
console.log('Phase 2 tx:', result.transactionHash);// Phase 2: use the new key with cw-orchestrator
// The contract verifies the signature against its stored pubkey
let new_wallet = Wallet::from_mnemonic(&chain, new_mnemonic)?;
let send_msg = BankMsg::Send {
to_address: "terp1recipient...".to_string(),
amount: vec![Coin::new(1000, "uthiol")],
};
let tx = chain
.with_signer(new_wallet)
.with_extension(TxExtension {
selected_authenticators: vec![auth_id],
})
.broadcast_tx(CosmosMsg::Bank(send_msg))?;
println!("Phase 2 tx: {}", tx.txhash);// Phase 2: sign with the new key using its private key
import (
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
"github.com/cosmos/cosmos-sdk/client/tx"
)
newPrivKey := secp256k1.PrivKey{Key: newPrivateKeyBytes}
newAddr := sdk.AccAddress(newPrivKey.PubKey().Address())
sendMsg := &banktypes.MsgSend{
FromAddress: newAddr.String(),
ToAddress: "terp1recipient...",
Amount: sdk.NewCoins(sdk.NewInt64Coin("uthiol", 1000)),
}
ext := &smartaccounttypes.TxExtension{
SelectedAuthenticators: []uint64{authId},
}
// Build, sign with newPrivKey, broadcast
txBuilder := txFactory.WithSigner(newPrivKey).BuildUnsignedTx(sendMsg)
txBuilder.SetExtension(ext)
txBuilder.SetSignatures(signing.SignatureV2{...})# Phase 2: use the new private key for signing
from cosmos.crypto.secp256k1 import PrivateKey
new_pk = PrivateKey(new_private_key_hex)
new_address = new_pk.public_key.address("terp")
send = BankMsg.send(
sender=new_address,
to="terp1recipient...",
amount=[("uthiol", 1000)],
)
# Sign with the new key and include authenticator selection
extension = TxExtension(selected_authenticators=[auth_id])
tx = client_with_key(new_pk).broadcast(
send,
fee={"amount": "5000uthiol", "gas": "200000"},
extension=extension,
)
print(f"Phase 2 tx: {tx.txhash}")Testing the Full Flow
Here's a complete test sequence using the contract:
1. Deploy safeword_authenticator.wasm → code_id
2. Instantiate → contract_address
3. Register CosmwasmAuthenticatorV1 on account with safe-word hash
→ authenticator_id = 5
4. Send tx with auth data: {"safeword":"my_secret_phrase_123","pubkey":"<new_key>"}
→ Authentication succeeds, contract stores new pubkey
5. Send another tx signed by the new private key
→ Authentication via stored pubkey succeeds
6. (Optional) Repeat step 4 with a different key to rotate againFull Contract Source
The complete contract source is available at terp-core/crates/cosmwasm/contracts/crypto-verify/.
Related Concepts
- Authenticator overview — architecture and available types
- CosmWasm contract development — contract development patterns
- Composite authenticators — combine with other authenticators
- SignatureVerification — standard key-based authenticator