diff --git a/Cargo.lock b/Cargo.lock index c77d77bc5d9..2d3b25add4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7942,6 +7942,7 @@ dependencies = [ "bcs 0.1.4", "claims", "hex", + "libsecp256k1", "move-binary-format", "move-core-types", "move-model", diff --git a/aptos-move/e2e-move-tests/Cargo.toml b/aptos-move/e2e-move-tests/Cargo.toml index b7f49974f51..9aaf1229af2 100644 --- a/aptos-move/e2e-move-tests/Cargo.toml +++ b/aptos-move/e2e-move-tests/Cargo.toml @@ -32,6 +32,7 @@ aptos-vm-environment = { workspace = true } bcs = { workspace = true } claims = { workspace = true } hex = { workspace = true } +libsecp256k1 = { workspace = true } move-binary-format = { workspace = true } move-core-types = { workspace = true } move-model = { workspace = true } diff --git a/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/Move.toml b/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/Move.toml new file mode 100644 index 00000000000..2b35206e1b1 --- /dev/null +++ b/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "attestation_gated" +version = "0.0.0" + +[dependencies] +AptosFramework = { local = "../../../../../framework/aptos-framework" } + +[addresses] +gated = "0xcafe" diff --git a/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/sources/vault.move b/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/sources/vault.move new file mode 100644 index 00000000000..3f15a7149f6 --- /dev/null +++ b/aptos-move/e2e-move-tests/src/tests/attestation.data/gated/sources/vault.move @@ -0,0 +1,48 @@ +/// A minimal consumer of `aptos_framework::attestation_policy`: the whole integration surface a +/// business touches is one call at the top of each gated entry function. +module gated::vault { + use std::signer; + use aptos_framework::attestation_policy; + + /// Matches `attestation_policy::ACTION_TRANSFER`. + const ACTION_TRANSFER: u8 = 1; + + /// Counts gated actions that went through, so a test can tell a success from a no-op. + struct Transfers has key { + count: u64, + total: u64 + } + + public entry fun transfer(user: &signer, policy: address, amount: u64) acquires Transfers { + attestation_policy::require(policy, signer::address_of(user), ACTION_TRANSFER, amount); + record(user, amount); + } + + public entry fun transfer_authorized( + user: &signer, policy: address, amount: u64, authorization: vector + ) acquires Transfers { + attestation_policy::require_authorized( + policy, + signer::address_of(user), + ACTION_TRANSFER, + amount, + authorization + ); + record(user, amount); + } + + #[view] + public fun count_of(user: address): u64 acquires Transfers { + if (exists(user)) { Transfers[user].count } else { 0 } + } + + fun record(user: &signer, amount: u64) acquires Transfers { + let addr = signer::address_of(user); + if (!exists(addr)) { + move_to(user, Transfers { count: 0, total: 0 }); + }; + let transfers = &mut Transfers[addr]; + transfers.count += 1; + transfers.total += amount; + } +} diff --git a/aptos-move/e2e-move-tests/src/tests/attestation.rs b/aptos-move/e2e-move-tests/src/tests/attestation.rs new file mode 100644 index 00000000000..14ba4a806ec --- /dev/null +++ b/aptos-move/e2e-move-tests/src/tests/attestation.rs @@ -0,0 +1,2310 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end VM tests for the attestation framework modules: `attestation` (sources and facts), +//! `attestation_policy` (a business's rules), `attestation_authorization` (per-action step-up +//! capabilities) and `zktls` (attestor-signed enrollment). +//! +//! Everything a signer produces off chain is produced here in Rust, with the byte layout rebuilt +//! independently from the module docs and then checked against the module's own published message +//! views, so a drift between the two fails loudly. A small consumer package under +//! `attestation.data/gated` stands in for a business contract that gates an entry function on a +//! policy. + +use crate::{assert_success, tests::common, MoveHarness}; +use aptos_crypto::{ + ed25519::{Ed25519PrivateKey, Ed25519PublicKey}, + SigningKey, +}; +use aptos_framework::{BuildOptions, BuiltPackage}; +use aptos_language_e2e_tests::account::Account; +use aptos_types::{ + account_address::AccountAddress, + transaction::{ExecutionStatus, TransactionStatus}, +}; +use move_core_types::vm_status::AbortLocation; +use once_cell::sync::Lazy; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; + +// ────────────────────────────────────────────────────────────── +// Constants mirrored from the Move modules +// ────────────────────────────────────────────────────────────── + +const ATTESTATION_DOMAIN: &[u8] = b"aptos_framework::attestation::ATTEST"; +const AUTHORIZATION_DOMAIN: &[u8] = b"aptos_framework::attestation_authorization::AUTH"; +const ETH_PREFIX: &[u8] = b"\x19Ethereum Signed Message:\n"; + +const STATE_NONE: u8 = 0; +const STATE_ACTIVE: u8 = 1; +const STATE_SUSPENDED: u8 = 2; +const STATE_REVOKED: u8 = 3; + +const DECISION_ALLOW: u8 = 0; +const DECISION_DENY: u8 = 1; +const DECISION_STEP_UP: u8 = 2; + +const REASON_OK: u16 = 0; +const REASON_CHAIN_DENIED: u16 = 1; +const REASON_SOURCE_DENIED: u16 = 2; +const REASON_MISSING_REQUIRED: u16 = 3; +const REASON_NO_QUALIFYING: u16 = 4; +const REASON_LEVEL_TOO_LOW: u16 = 5; +const REASON_ATTR_FAILED: u16 = 6; +const REASON_AMOUNT_THRESHOLD: u16 = 7; +const REASON_POLICY_PAUSED: u16 = 8; +const REASON_EMPTY_BODY: u16 = 9; + +const OP_IN: u8 = 0; +const OP_GTE: u8 = 3; + +const ACTION_TRANSFER: u8 = 1; +const ATTR_COUNTRY: u16 = 1; +const COUNTRY_PT: [u8; 2] = [0x02, 0x6C]; +const COUNTRY_BR: [u8; 2] = [0x00, 0x4C]; + +const LEVEL_BASIC: u8 = 1; +const LEVEL_ENHANCED: u8 = 2; +const ONE_YEAR: u64 = 31_536_000; + +// Abort codes, as `error::(E...)` = (category << 16) | reason. +// +// attestation +const ATT_ENOT_ADMIN: u64 = 0x50002; +const ATT_ENOT_ISSUER: u64 = 0x50003; +const ATT_ENOT_SENTINEL: u64 = 0x50004; +const ATT_ENOT_REMOVER: u64 = 0x50005; +const ATT_ENOT_GUARDIAN: u64 = 0x50006; +const ATT_EPAUSED: u64 = 0x30007; +const ATT_ELENGTH_MISMATCH: u64 = 0x1000C; +const ATT_EBAD_SIGNATURE: u64 = 0x10010; +const ATT_ESTALE_EPOCH: u64 = 0x30011; +const ATT_ENOT_MONOTONIC: u64 = 0x10012; +const ATT_ESUBJECT_DENIED: u64 = 0x30013; +const ATT_ENULLIFIER_BOUND: u64 = 0x30014; +const ATT_ERECORD_NOT_FOUND: u64 = 0x60015; +const ATT_EINVALID_TRANSITION: u64 = 0x30019; +// attestation_policy +const POL_ENOT_EFFECTIVE: u64 = 0x3000C; +const POL_EDENIED: u64 = 0x5000D; +const POL_ESTEP_UP_REQUIRED: u64 = 0x5000E; +const POL_ETOO_MANY_CHAIN_DENY: u64 = 0x10012; +const POL_EBAD_RULE: u64 = 0x10010; +// attestation_authorization +const AUTH_EBAD_SIGNATURE: u64 = 0x10002; +const AUTH_EEXPIRED: u64 = 0x30003; +const AUTH_ENONCE_USED: u64 = 0x30004; +const AUTH_EAMOUNT_OVER_BUCKET: u64 = 0x10005; +const AUTH_ETTL_TOO_LONG: u64 = 0x10006; +// zktls +const ZK_ETEMPLATE_REVOKED: u64 = 0x30003; +const ZK_EBELOW_THRESHOLD: u64 = 0x10004; +const ZK_EDUPLICATE_SIGNER: u64 = 0x10005; +const ZK_EUNKNOWN_ATTESTOR: u64 = 0x10006; +const ZK_EMALFORMED_CLAIM: u64 = 0x10008; +const ZK_ERETIRED_ATTESTOR_EPOCH: u64 = 0x3000F; +const ZK_ECLAIM_CONSUMED: u64 = 0x30010; + +// ────────────────────────────────────────────────────────────── +// Mirrors of Move structs returned by views +// ────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct Change { + _at_secs: u64, + prev_state: u8, + new_state: u8, + reason: u16, + issuer_id: u16, +} + +#[derive(Debug, Deserialize)] +struct Record { + state: u8, + level: u8, + issuer_id: u16, + issuer_epoch: u64, + issued_at_secs: u64, + expires_at_secs: u64, + revoked_at_secs: u64, + _reason: u16, + attestation_digest: Vec, + // `SimpleMap>` is a vector of key/value elements in BCS. + attrs: Vec<(u16, Vec)>, + history: Vec, +} + +// ────────────────────────────────────────────────────────────── +// Generic helpers +// ────────────────────────────────────────────────────────────── + +fn arg(value: &T) -> Vec { + bcs::to_bytes(value).unwrap() +} + +fn addr(hex: &str) -> AccountAddress { + AccountAddress::from_hex_literal(hex).unwrap() +} + +fn keccak(bytes: &[u8]) -> Vec { + Keccak256::digest(bytes).to_vec() +} + +fn run(h: &mut MoveHarness, sender: &Account, fun: &str, args: Vec>) -> TransactionStatus { + h.run_entry_function(sender, str::parse(fun).unwrap(), vec![], args) +} + +fn view_raw(h: &mut MoveHarness, fun: &str, args: Vec>) -> Vec> { + h.execute_view_function(str::parse(fun).unwrap(), vec![], args) + .values + .unwrap_or_else(|e| panic!("view {fun} failed: {e:?}")) +} + +fn view(h: &mut MoveHarness, fun: &str, args: Vec>) -> T { + bcs::from_bytes(&view_raw(h, fun, args)[0]).unwrap() +} + +fn now(h: &mut MoveHarness) -> u64 { + view(h, "0x1::timestamp::now_seconds", vec![]) +} + +fn chain_id(h: &mut MoveHarness) -> u8 { + view(h, "0x1::chain_id::get", vec![]) +} + +fn advance(h: &mut MoveHarness, secs: u64) { + h.fast_forward(secs); + h.executor.new_block(); +} + +/// Assert a Move abort with the given code raised inside the given module. +#[track_caller] +fn assert_abort(status: &TransactionStatus, module: &str, code: u64) { + match status { + TransactionStatus::Keep(ExecutionStatus::MoveAbort { + location, + code: actual, + .. + }) => { + match location { + AbortLocation::Module(id) => assert_eq!( + id.name().as_str(), + module, + "abort {actual:#x} raised in {id} instead of {module}" + ), + AbortLocation::Script => panic!("abort {actual:#x} raised in a script"), + } + assert_eq!(*actual, code, "expected abort {code:#x}, got {actual:#x}"); + }, + other => panic!("expected MoveAbort({code:#x}) in {module}, got {other:?}"), + } +} + +// ────────────────────────────────────────────────────────────── +// Keys +// ────────────────────────────────────────────────────────────── + +fn ed25519_key(seed: u8) -> (Ed25519PrivateKey, Vec) { + let private = Ed25519PrivateKey::try_from(&[seed; 32][..]).unwrap(); + let public = Ed25519PublicKey::from(&private).to_bytes().to_vec(); + (private, public) +} + +struct Attestor { + secret: libsecp256k1::SecretKey, + /// 20-byte Ethereum-style address: low 20 bytes of keccak256 over the uncompressed key. + address: Vec, +} + +fn attestor(seed: u8) -> Attestor { + let secret = libsecp256k1::SecretKey::parse(&[seed; 32]).unwrap(); + let public = libsecp256k1::PublicKey::from_secret_key(&secret).serialize(); + let address = keccak(&public[1..])[12..].to_vec(); + Attestor { secret, address } +} + +// ────────────────────────────────────────────────────────────── +// World +// ────────────────────────────────────────────────────────────── + +/// One harness plus one source whose five roles are held by five different accounts, which is +/// the configuration that makes role separation observable. +struct World { + h: MoveHarness, + admin: Account, + issuer: Account, + sentinel: Account, + remover: Account, + guardian: Account, + relayer: Account, + alice: Account, + bob: Account, + carol: Account, + source: AccountAddress, + issuer_key: Ed25519PrivateKey, +} + +const ISSUER_ID: u16 = 1; + +impl World { + fn new() -> Self { + let mut h = MoveHarness::new(); + let admin = h.new_account_at(addr("0xad")); + let issuer = h.new_account_at(addr("0x155")); + let sentinel = h.new_account_at(addr("0x5e1")); + let remover = h.new_account_at(addr("0x5e2")); + let guardian = h.new_account_at(addr("0x6a")); + let relayer = h.new_account_at(addr("0x7e")); + let alice = h.new_account_at(addr("0xa11ce")); + let bob = h.new_account_at(addr("0xb0b")); + let carol = h.new_account_at(addr("0xca401")); + let (issuer_key, issuer_pubkey) = ed25519_key(7); + + let source = create_source( + &mut h, + &admin, + vec![*admin.address()], + vec![*issuer.address()], + vec![*sentinel.address()], + vec![*remover.address()], + vec![*guardian.address()], + ); + assert_success!(run( + &mut h, + &admin, + "0x1::attestation::register_issuer", + vec![arg(&source), arg(issuer.address()), arg(&issuer_pubkey),] + )); + + World { + h, + admin, + issuer, + sentinel, + remover, + guardian, + relayer, + alice, + bob, + carol, + source, + issuer_key, + } + } + + fn issue(&mut self, subjects: &[&Account], levels: Vec, ttl: u64) -> TransactionStatus { + let expiry = now(&mut self.h) + ttl; + let subjects: Vec = subjects.iter().map(|a| *a.address()).collect(); + let expiries = vec![expiry; subjects.len()]; + let issuer = self.issuer.clone(); + run(&mut self.h, &issuer, "0x1::attestation::issue_batch", vec![ + arg(&self.source), + arg(&subjects), + arg(&levels), + arg(&expiries), + arg(&0u16), + ]) + } + + fn verified(&mut self, subject: &Account) -> bool { + let source = self.source; + is_verified(&mut self.h, source, *subject.address()) + } + + fn deny(&mut self, actor: &Account, subject: &Account) -> TransactionStatus { + let at = now(&mut self.h); + run(&mut self.h, actor, "0x1::attestation::deny", vec![ + arg(&self.source), + arg(subject.address()), + arg(&42u16), + arg(&at), + ]) + } + + fn undeny(&mut self, actor: &Account, subject: &Account) -> TransactionStatus { + run(&mut self.h, actor, "0x1::attestation::undeny", vec![ + arg(&self.source), + arg(subject.address()), + ]) + } +} + +fn create_source( + h: &mut MoveHarness, + deployer: &Account, + admins: Vec, + issuers: Vec, + sentinels: Vec, + removers: Vec, + guardians: Vec, +) -> AccountAddress { + let predicted: AccountAddress = + view(h, "0x1::attestation::get_next_source_address", vec![arg( + deployer.address(), + )]); + assert_success!(run(h, deployer, "0x1::attestation::create", vec![ + arg(&admins), + arg(&issuers), + arg(&sentinels), + arg(&removers), + arg(&guardians), + ])); + assert!(view::(h, "0x1::attestation::is_source", vec![arg( + &predicted + )])); + predicted +} + +fn is_verified(h: &mut MoveHarness, source: AccountAddress, subject: AccountAddress) -> bool { + view(h, "0x1::attestation::is_verified", vec![ + arg(&source), + arg(&subject), + ]) +} + +fn source_view( + h: &mut MoveHarness, + fun: &str, + source: AccountAddress, + subject: AccountAddress, +) -> T { + view(h, &format!("0x1::attestation::{fun}"), vec![ + arg(&source), + arg(&subject), + ]) +} + +// ────────────────────────────────────────────────────────────── +// Relay attestations +// ────────────────────────────────────────────────────────────── + +struct Attestation { + subject: AccountAddress, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: Vec, +} + +/// Rebuilds `attestation::attestation_message` byte for byte. +fn attestation_message(chain_id: u8, source: AccountAddress, a: &Attestation) -> Vec { + let mut message = ATTESTATION_DOMAIN.to_vec(); + message.extend(arg(&chain_id)); + message.extend(arg(&source)); + message.extend(arg(&a.subject)); + message.extend(arg(&a.issuer_id)); + message.extend(arg(&a.issuer_epoch)); + message.extend(arg(&a.level)); + message.extend(arg(&a.expires_at_secs)); + message.extend(arg(&a.issued_at_secs)); + message.extend(arg(&a.nullifier)); + message +} + +fn attestation_message_view( + h: &mut MoveHarness, + source: AccountAddress, + a: &Attestation, +) -> Vec { + view(h, "0x1::attestation::attestation_message", vec![ + arg(&source), + arg(&a.subject), + arg(&a.issuer_id), + arg(&a.issuer_epoch), + arg(&a.level), + arg(&a.expires_at_secs), + arg(&a.issued_at_secs), + arg(&a.nullifier), + ]) +} + +fn sign_attestation(w: &mut World, a: &Attestation) -> Vec { + let chain = chain_id(&mut w.h); + let message = attestation_message(chain, w.source, a); + let source = w.source; + assert_eq!( + message, + attestation_message_view(&mut w.h, source, a), + "Rust and Move disagree on the attestation message layout" + ); + w.issuer_key + .sign_arbitrary_message(&message) + .to_bytes() + .to_vec() +} + +fn redeem(w: &mut World, a: &Attestation, signature: &[u8]) -> TransactionStatus { + let relayer = w.relayer.clone(); + run( + &mut w.h, + &relayer, + "0x1::attestation::redeem_attestation", + vec![ + arg(&w.source), + arg(&a.subject), + arg(&a.issuer_id), + arg(&a.issuer_epoch), + arg(&a.level), + arg(&a.expires_at_secs), + arg(&a.issued_at_secs), + arg(&a.nullifier), + arg(&signature.to_vec()), + ], + ) +} + +fn fresh_attestation(w: &mut World, subject: &Account, level: u8) -> Attestation { + let t = now(&mut w.h); + Attestation { + subject: *subject.address(), + issuer_id: ISSUER_ID, + issuer_epoch: 0, + level, + expires_at_secs: t + ONE_YEAR, + issued_at_secs: t, + nullifier: vec![], + } +} + +// ────────────────────────────────────────────────────────────── +// Policies +// ────────────────────────────────────────────────────────────── + +fn create_policy( + h: &mut MoveHarness, + deployer: &Account, + guardians: Vec, +) -> AccountAddress { + let predicted: AccountAddress = + view(h, "0x1::attestation_policy::get_next_policy_address", vec![ + arg(deployer.address()), + ]); + assert_success!(run(h, deployer, "0x1::attestation_policy::create", vec![ + arg(&vec![*deployer.address()]), + arg(&guardians), + ])); + predicted +} + +struct BodySpec { + require_any: Vec<(AccountAddress, u8)>, + require_all: Vec<(AccountAddress, u8)>, + deny_any: Vec, + chain_deny: Vec, +} + +impl BodySpec { + fn any(source: AccountAddress, level: u8) -> Self { + BodySpec { + require_any: vec![(source, level)], + require_all: vec![], + deny_any: vec![], + chain_deny: vec![], + } + } +} + +fn stage_body( + h: &mut MoveHarness, + admin: &Account, + policy: AccountAddress, + body: &BodySpec, + effective_at_secs: u64, +) -> TransactionStatus { + let split = |refs: &Vec<(AccountAddress, u8)>| -> (Vec, Vec) { + refs.iter().cloned().unzip() + }; + let (any_sources, any_levels) = split(&body.require_any); + let (all_sources, all_levels) = split(&body.require_all); + run(h, admin, "0x1::attestation_policy::stage_body", vec![ + arg(&policy), + arg(&any_sources), + arg(&any_levels), + arg(&all_sources), + arg(&all_levels), + arg(&body.deny_any), + arg(&body.chain_deny), + arg(&effective_at_secs), + ]) +} + +fn activate(h: &mut MoveHarness, anyone: &Account, policy: AccountAddress) -> TransactionStatus { + run( + h, + anyone, + "0x1::attestation_policy::activate_pending", + vec![arg(&policy)], + ) +} + +/// Stage `body` effective now and push it live straight away. +fn apply_body(h: &mut MoveHarness, admin: &Account, policy: AccountAddress, body: &BodySpec) { + let t = now(h); + assert_success!(stage_body(h, admin, policy, body, t)); + assert_success!(activate(h, admin, policy)); +} + +fn policy_view( + h: &mut MoveHarness, + fun: &str, + policy: AccountAddress, + subject: AccountAddress, + amount: u64, +) -> T { + view(h, &format!("0x1::attestation_policy::{fun}"), vec![ + arg(&policy), + arg(&subject), + arg(&ACTION_TRANSFER), + arg(&amount), + ]) +} + +fn decision( + h: &mut MoveHarness, + policy: AccountAddress, + subject: &Account, + amount: u64, +) -> (u8, u16) { + let values = view_raw(h, "0x1::attestation_policy::evaluate", vec![ + arg(&policy), + arg(subject.address()), + arg(&ACTION_TRANSFER), + arg(&amount), + ]); + ( + bcs::from_bytes(&values[0]).unwrap(), + bcs::from_bytes(&values[1]).unwrap(), + ) +} + +fn reason(h: &mut MoveHarness, policy: AccountAddress, subject: &Account) -> u16 { + policy_view(h, "reason_of", policy, *subject.address(), 1) +} + +// ────────────────────────────────────────────────────────────── +// The consumer contract and authorizations +// ────────────────────────────────────────────────────────────── + +/// Compiled once and shared. Compiling a package against the framework recurses deeply enough to +/// overflow the default 2 MB test-thread stack, so the build runs on its own large-stack thread. +static GATED: Lazy = Lazy::new(|| { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(|| { + BuiltPackage::build( + common::test_dir_path("attestation.data/gated"), + BuildOptions::default(), + ) + .expect("the gated consumer package must build") + }) + .unwrap() + .join() + .unwrap() +}); + +fn publish_gated(h: &mut MoveHarness) { + let account = h.new_account_at(addr("0xcafe")); + let txn = h.create_publish_built_package(&account, &GATED, |_| {}); + assert_success!(h.run(txn)); +} + +fn gated_transfer( + h: &mut MoveHarness, + user: &Account, + policy: AccountAddress, + amount: u64, +) -> TransactionStatus { + run(h, user, "0xcafe::vault::transfer", vec![ + arg(&policy), + arg(&amount), + ]) +} + +fn gated_transfer_authorized( + h: &mut MoveHarness, + user: &Account, + policy: AccountAddress, + amount: u64, + authorization: &[u8], +) -> TransactionStatus { + run(h, user, "0xcafe::vault::transfer_authorized", vec![ + arg(&policy), + arg(&amount), + arg(&authorization.to_vec()), + ]) +} + +fn transfer_count(h: &mut MoveHarness, user: &Account) -> u64 { + view(h, "0xcafe::vault::count_of", vec![arg(user.address())]) +} + +struct Authorization { + policy: AccountAddress, + subject: AccountAddress, + action: u8, + amount_bucket: u8, + nonce: [u8; 32], + issued_at_secs: u64, + expires_at_secs: u64, +} + +/// Rebuilds `attestation_authorization::authorization_message` byte for byte. +fn authorization_message(chain_id: u8, a: &Authorization) -> Vec { + let mut message = AUTHORIZATION_DOMAIN.to_vec(); + message.extend(arg(&chain_id)); + message.extend(arg(&a.policy)); + message.extend(arg(&a.subject)); + message.extend(arg(&a.action)); + message.extend(arg(&a.amount_bucket)); + message.extend(arg(&a.nonce.to_vec())); + message.extend(arg(&a.issued_at_secs)); + message.extend(arg(&a.expires_at_secs)); + message +} + +/// Produce the fixed-width blob `attestation_authorization::decode` expects: +/// action (1) || bucket (1) || nonce (32) || issued_at (8 LE) || expires_at (8 LE) || sig (64). +fn sign_authorization(h: &mut MoveHarness, key: &Ed25519PrivateKey, a: &Authorization) -> Vec { + let message = authorization_message(chain_id(h), a); + let from_view: Vec = view( + h, + "0x1::attestation_authorization::authorization_message", + vec![ + arg(&a.policy), + arg(&a.subject), + arg(&a.action), + arg(&a.amount_bucket), + arg(&a.nonce.to_vec()), + arg(&a.issued_at_secs), + arg(&a.expires_at_secs), + ], + ); + assert_eq!( + message, from_view, + "Rust and Move disagree on the authorization message layout" + ); + let signature = key.sign_arbitrary_message(&message).to_bytes(); + let mut blob = vec![a.action, a.amount_bucket]; + blob.extend_from_slice(&a.nonce); + blob.extend_from_slice(&a.issued_at_secs.to_le_bytes()); + blob.extend_from_slice(&a.expires_at_secs.to_le_bytes()); + blob.extend_from_slice(&signature); + blob +} + +/// A world with a policy requiring the main source at LEVEL_BASIC, a step-up threshold of 1000 +/// on transfers, an authorizer key, and the consumer contract published. Alice is verified, +/// Carol is not. +fn step_up_world() -> (World, AccountAddress, Ed25519PrivateKey) { + let mut w = World::new(); + let alice = w.alice.clone(); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + apply_body( + &mut w.h, + &admin, + policy, + &BodySpec::any(w.source, LEVEL_BASIC), + ); + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation_policy::set_step_up", + vec![arg(&policy), arg(&ACTION_TRANSFER), arg(&1000u64)] + )); + let (authorizer, authorizer_pubkey) = ed25519_key(9); + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation_policy::set_authorizer", + vec![arg(&policy), arg(&authorizer_pubkey), arg(&300u64)] + )); + publish_gated(&mut w.h); + (w, policy, authorizer) +} + +fn authorization_for( + h: &mut MoveHarness, + policy: AccountAddress, + subject: &Account, + nonce_byte: u8, + ttl: u64, +) -> Authorization { + let t = now(h); + Authorization { + policy, + subject: *subject.address(), + action: ACTION_TRANSFER, + // 10^4 = 10000 ceiling. + amount_bucket: 4, + nonce: [nonce_byte; 32], + issued_at_secs: t, + expires_at_secs: t + ttl, + } +} + +// ────────────────────────────────────────────────────────────── +// zkTLS +// ────────────────────────────────────────────────────────────── + +const TEMPLATE_ID: [u8; 32] = [0xAB; 32]; +const ZK_LEVEL: u8 = 3; +const ZK_TTL: u64 = 86_400; + +/// Build a claim that binds the subject and the template the way `zktls::claim_binds` checks: +/// both as lowercase hex anywhere in the claim bytes. A non-empty nullifier must be bound too. +fn claim_for(subject: AccountAddress, template_id: &[u8], nullifier: &[u8], salt: &str) -> Vec { + let mut claim = format!( + "{{\"provider\":\"http\",\"owner\":\"0x{}\",\"template\":\"0x{}\",\"context\":\"{}\"", + hex::encode(subject.to_vec()), + hex::encode(template_id), + salt, + ); + if !nullifier.is_empty() { + claim.push_str(&format!(",\"nullifier\":\"0x{}\"", hex::encode(nullifier))); + } + claim.push('}'); + claim.into_bytes() +} + +/// `zktls::claim_digest`: keccak256 over the Ethereum-prefixed claim. +fn claim_digest(claim: &[u8]) -> [u8; 32] { + let mut prefixed = ETH_PREFIX.to_vec(); + prefixed.extend(claim.len().to_string().as_bytes()); + prefixed.extend(claim); + keccak(&prefixed).try_into().unwrap() +} + +/// 65-byte recoverable signature: r || s || recovery id (0 or 1, not Ethereum's 27/28). +fn sign_claim(attestor: &Attestor, claim: &[u8]) -> Vec { + let message = libsecp256k1::Message::parse(&claim_digest(claim)); + let (signature, recovery_id) = libsecp256k1::sign(&message, &attestor.secret); + let mut out = signature.serialize().to_vec(); + out.push(recovery_id.serialize()); + out +} + +/// A world whose source has a zkTLS verifier with three attestors at threshold two, and one +/// active template. +fn zktls_world() -> (World, Vec) { + let mut w = World::new(); + let attestors = vec![attestor(1), attestor(2), attestor(3)]; + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run(&mut w.h, &admin, "0x1::zktls::initialize", vec![arg( + &source + )])); + set_attestors(&mut w, &attestors.iter().collect::>(), 2, 0); + assert_success!(run( + &mut w.h, + &admin, + "0x1::zktls::register_template", + vec![ + arg(&source), + arg(&TEMPLATE_ID.to_vec()), + arg(&ZK_LEVEL), + arg(&ZK_TTL), + ] + )); + (w, attestors) +} + +fn set_attestors(w: &mut World, attestors: &[&Attestor], threshold: u64, grace_secs: u64) { + let addresses: Vec> = attestors.iter().map(|a| a.address.clone()).collect(); + let admin = w.admin.clone(); + assert_success!(run(&mut w.h, &admin, "0x1::zktls::set_attestor_set", vec![ + arg(&w.source), + arg(&addresses), + arg(&threshold), + arg(&grace_secs), + ])); +} + +fn enroll( + w: &mut World, + user: &Account, + claim: &[u8], + signatures: Vec>, + attestor_epoch: u64, + nullifier: &[u8], +) -> TransactionStatus { + run(&mut w.h, user, "0x1::zktls::enroll", vec![ + arg(&w.source), + arg(&TEMPLATE_ID.to_vec()), + arg(&claim.to_vec()), + arg(&signatures), + arg(&attestor_epoch), + arg(&nullifier.to_vec()), + ]) +} + +fn verify_claim( + w: &mut World, + subject: &Account, + claim: &[u8], + signatures: Vec>, + attestor_epoch: u64, +) -> bool { + let source = w.source; + view(&mut w.h, "0x1::zktls::verify_claim", vec![ + arg(&source), + arg(&TEMPLATE_ID.to_vec()), + arg(subject.address()), + arg(&claim.to_vec()), + arg(&signatures), + arg(&attestor_epoch), + ]) +} + +// ══════════════════════════════════════════════════════════════ +// Sources, roles and issuers +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_source_creation_and_role_views() { + let mut w = World::new(); + let source = w.source; + let admins: Vec = + view(&mut w.h, "0x1::attestation::admins", vec![arg(&source)]); + assert_eq!(admins, vec![*w.admin.address()]); + let issuers: Vec = + view(&mut w.h, "0x1::attestation::issuers", vec![arg(&source)]); + assert_eq!(issuers, vec![*w.issuer.address()]); + assert_eq!( + view::(&mut w.h, "0x1::attestation::issuer_id_of", vec![ + arg(&source), + arg(w.issuer.address()), + ]), + ISSUER_ID + ); + assert_eq!( + view::(&mut w.h, "0x1::attestation::standard_version", vec![]), + 1 + ); + assert!(!view::( + &mut w.h, + "0x1::attestation::is_paused", + vec![arg(&source)] + )); + // The deployer holds no role it was not given: a source deployed by a third party. + let deployer = w.relayer.clone(); + let other = create_source( + &mut w.h, + &deployer, + vec![*w.admin.address()], + vec![], + vec![], + vec![], + vec![], + ); + assert!(!view::(&mut w.h, "0x1::attestation::is_admin", vec![ + arg(deployer.address()), + arg(&other), + ])); + // An address that is not a source is not one. + assert!(!view::( + &mut w.h, + "0x1::attestation::is_source", + vec![arg(w.alice.address())] + )); +} + +#[test] +fn test_only_admin_registers_issuers_and_grants_roles() { + let mut w = World::new(); + let (_, pubkey) = ed25519_key(11); + let issuer = w.issuer.clone(); + let source = w.source; + let status = run( + &mut w.h, + &issuer, + "0x1::attestation::register_issuer", + vec![arg(&source), arg(w.bob.address()), arg(&pubkey)], + ); + assert_abort(&status, "attestation", ATT_ENOT_ADMIN); + let status = run(&mut w.h, &issuer, "0x1::attestation::add_sentinels", vec![ + arg(&source), + arg(&vec![*issuer.address()]), + ]); + assert_abort(&status, "attestation", ATT_ENOT_ADMIN); + + // The admin can, and the new issuer gets the next id. + let admin = w.admin.clone(); + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::register_issuer", + vec![arg(&source), arg(w.bob.address()), arg(&pubkey),] + )); + assert_eq!( + view::(&mut w.h, "0x1::attestation::issuer_id_of", vec![ + arg(&source), + arg(w.bob.address()), + ]), + 2 + ); +} + +// ══════════════════════════════════════════════════════════════ +// Write path 1: issuer batch +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_issue_batch_and_read_back() { + let mut w = World::new(); + let (alice, bob, carol) = (w.alice.clone(), w.bob.clone(), w.carol.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_ENHANCED, LEVEL_BASIC], ONE_YEAR)); + + let source = w.source; + assert!(w.verified(&alice)); + assert!(w.verified(&bob)); + assert!(!w.verified(&carol)); + assert_eq!( + source_view::(&mut w.h, "level_of", source, *alice.address()), + LEVEL_ENHANCED + ); + assert_eq!( + source_view::(&mut w.h, "state_of", source, *alice.address()), + STATE_ACTIVE + ); + assert_eq!( + source_view::(&mut w.h, "state_of", source, *carol.address()), + STATE_NONE + ); + + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.state, STATE_ACTIVE); + assert_eq!(record.level, LEVEL_ENHANCED); + assert_eq!(record.issuer_id, ISSUER_ID); + assert_eq!(record.issuer_epoch, 0); + assert!(record.attestation_digest.is_empty()); + assert_eq!(record.history.len(), 1); + assert_eq!(record.history[0].prev_state, STATE_NONE); + assert_eq!(record.history[0].new_state, STATE_ACTIVE); + assert_eq!(record.history[0].issuer_id, ISSUER_ID); + assert_eq!( + source_view::(&mut w.h, "expires_at", source, *alice.address()), + record.expires_at_secs + ); +} + +#[test] +fn test_issue_batch_rejections() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + // Two subjects, one level. + let status = w.issue(&[&alice, &bob], vec![LEVEL_BASIC], ONE_YEAR); + assert_abort(&status, "attestation", ATT_ELENGTH_MISMATCH); + + // Neither the admin nor the sentinel is an issuer. + for actor in [w.admin.clone(), w.sentinel.clone()] { + let source = w.source; + let status = run(&mut w.h, &actor, "0x1::attestation::issue_batch", vec![ + arg(&source), + arg(&vec![*alice.address()]), + arg(&vec![LEVEL_BASIC]), + arg(&vec![u64::MAX]), + arg(&0u16), + ]); + assert_abort(&status, "attestation", ATT_ENOT_ISSUER); + } +} + +#[test] +fn test_expiry_and_revocation_lifecycle() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + assert_success!(w.issue(&[&bob], vec![LEVEL_BASIC], 100)); + advance(&mut w.h, 101); + // Bob's fact lapsed on its own; nobody had to relay anything. + assert!(!w.verified(&bob)); + assert!(w.verified(&alice)); + + // Suspend is reversible, revoke is terminal. + let issuer = w.issuer.clone(); + let source = w.source; + assert_success!(run(&mut w.h, &issuer, "0x1::attestation::suspend", vec![ + arg(&source), + arg(alice.address()), + arg(&5u16), + ])); + assert!(!w.verified(&alice)); + assert_eq!( + source_view::(&mut w.h, "state_of", source, *alice.address()), + STATE_SUSPENDED + ); + assert_success!(run(&mut w.h, &issuer, "0x1::attestation::unsuspend", vec![ + arg(&source), + arg(alice.address()), + arg(&6u16), + ])); + assert!(w.verified(&alice)); + assert_success!(run( + &mut w.h, + &issuer, + "0x1::attestation::revoke_batch", + vec![arg(&source), arg(&vec![*alice.address()]), arg(&7u16),] + )); + assert!(!w.verified(&alice)); + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.state, STATE_REVOKED); + assert!(record.revoked_at_secs > 0); + // issue, suspend, unsuspend, revoke. + let transitions: Vec<(u8, u8, u16)> = record + .history + .iter() + .map(|c| (c.prev_state, c.new_state, c.reason)) + .collect(); + assert_eq!(transitions, vec![ + (STATE_NONE, STATE_ACTIVE, 0), + (STATE_ACTIVE, STATE_SUSPENDED, 5), + (STATE_SUSPENDED, STATE_ACTIVE, 6), + (STATE_ACTIVE, STATE_REVOKED, 7), + ]); +} + +#[test] +fn test_issuer_epoch_bump_kills_cohort() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_BASIC, LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::bump_issuer_epoch", + vec![arg(&source), arg(&ISSUER_ID),] + )); + // One write, both facts dead. + assert!(!w.verified(&alice)); + assert!(!w.verified(&bob)); + assert_eq!( + view::(&mut w.h, "0x1::attestation::issuer_epoch_of", vec![ + arg(&source), + arg(&ISSUER_ID), + ]), + 1 + ); + // Re-issuing under the new epoch brings a subject back. + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + assert!(w.verified(&alice)); + assert!(!w.verified(&bob)); +} + +#[test] +fn test_floor_epoch_kills_everything_below_it() { + let mut w = World::new(); + let alice = w.alice.clone(); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::set_floor_epoch", + vec![arg(&source), arg(&1u64),] + )); + assert!(!w.verified(&alice)); + assert_eq!( + view::(&mut w.h, "0x1::attestation::floor_epoch", vec![arg( + &source + )]), + 1 + ); + // A fresh write lands at the effective epoch, which is at least the floor. + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + assert!(w.verified(&alice)); + // The floor only rises, so lowering it can never resurrect a fact. + let status = run(&mut w.h, &admin, "0x1::attestation::set_floor_epoch", vec![ + arg(&source), + arg(&1u64), + ]); + assert_abort(&status, "attestation", ATT_ENOT_MONOTONIC); +} + +#[test] +fn test_lifecycle_transitions_are_checked() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let issuer = w.issuer.clone(); + let source = w.source; + let transition = |h: &mut MoveHarness, fun: &str, subject: &Account| { + run(h, &issuer, &format!("0x1::attestation::{fun}"), vec![ + arg(&source), + arg(subject.address()), + arg(&0u16), + ]) + }; + // Only a suspended record can be unsuspended, and there must be a record at all. + assert_abort( + &transition(&mut w.h, "unsuspend", &alice), + "attestation", + ATT_EINVALID_TRANSITION, + ); + assert_abort( + &transition(&mut w.h, "suspend", &bob), + "attestation", + ATT_ERECORD_NOT_FOUND, + ); + + // A transition keeps the epoch the fact was written under, so unsuspending after a bump + // does not launder a fact from a compromised cohort back to life. + assert_success!(transition(&mut w.h, "suspend", &alice)); + let admin = w.admin.clone(); + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::bump_issuer_epoch", + vec![arg(&source), arg(&ISSUER_ID),] + )); + assert_success!(transition(&mut w.h, "unsuspend", &alice)); + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.state, STATE_ACTIVE); + assert_eq!(record.issuer_epoch, 0); + assert!(!w.verified(&alice)); + + // Revocation is terminal: a revoked record cannot be suspended, and revoking it again is a + // no-op rather than an abort, so a batch containing it still goes through. + let revoke = |h: &mut MoveHarness| { + run(h, &issuer, "0x1::attestation::revoke_batch", vec![ + arg(&source), + arg(&vec![*alice.address()]), + arg(&9u16), + ]) + }; + assert_success!(revoke(&mut w.h)); + let history_len = source_view::(&mut w.h, "record_of", source, *alice.address()) + .history + .len(); + assert_success!(revoke(&mut w.h)); + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.history.len(), history_len); + assert_abort( + &transition(&mut w.h, "suspend", &alice), + "attestation", + ATT_EINVALID_TRANSITION, + ); + assert_abort( + &transition(&mut w.h, "unsuspend", &alice), + "attestation", + ATT_EINVALID_TRANSITION, + ); +} + +// ══════════════════════════════════════════════════════════════ +// Write path 2: permissionless relay of issuer-signed attestations +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_redeem_attestation_signed_off_chain() { + let mut w = World::new(); + let alice = w.alice.clone(); + let mut a = fresh_attestation(&mut w, &alice, LEVEL_ENHANCED); + a.nullifier = vec![0x11; 32]; + let signature = sign_attestation(&mut w, &a); + // The relayer is neither the subject nor the issuer, and pays for the write. + assert_success!(redeem(&mut w, &a, &signature)); + + assert!(w.verified(&alice)); + let source = w.source; + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.level, LEVEL_ENHANCED); + assert_eq!(record.issued_at_secs, a.issued_at_secs); + assert_eq!(record.expires_at_secs, a.expires_at_secs); + let chain = chain_id(&mut w.h); + assert_eq!( + record.attestation_digest, + keccak(&attestation_message(chain, source, &a)), + "the stored digest names the exact attestation the fact came from" + ); +} + +#[test] +fn test_redeem_rejects_replay_and_tampering() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let a = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &a); + assert_success!(redeem(&mut w, &a, &signature)); + + // The same attestation again cannot push the fact back out. + assert_abort( + &redeem(&mut w, &a, &signature), + "attestation", + ATT_ENOT_MONOTONIC, + ); + + // Upgrading the level in transit breaks the signature. + let mut tampered = fresh_attestation(&mut w, &bob, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &tampered); + tampered.level = LEVEL_ENHANCED; + assert_abort( + &redeem(&mut w, &tampered, &signature), + "attestation", + ATT_EBAD_SIGNATURE, + ); + + // So does retargeting it at a different subject. + let mut retargeted = fresh_attestation(&mut w, &bob, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &retargeted); + retargeted.subject = *w.carol.address(); + assert_abort( + &redeem(&mut w, &retargeted, &signature), + "attestation", + ATT_EBAD_SIGNATURE, + ); + + // A key that is not the registered issuer's cannot sign for it. + let b = fresh_attestation(&mut w, &bob, LEVEL_BASIC); + let chain = chain_id(&mut w.h); + let (forger, _) = ed25519_key(99); + let forged = forger + .sign_arbitrary_message(&attestation_message(chain, w.source, &b)) + .to_bytes(); + assert_abort( + &redeem(&mut w, &b, &forged), + "attestation", + ATT_EBAD_SIGNATURE, + ); + assert!(!w.verified(&bob)); +} + +#[test] +fn test_redeem_rejects_stale_epoch() { + let mut w = World::new(); + let alice = w.alice.clone(); + // Signed under epoch 0, then the issuer is bumped before it is relayed. + let a = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &a); + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::bump_issuer_epoch", + vec![arg(&source), arg(&ISSUER_ID),] + )); + assert_abort( + &redeem(&mut w, &a, &signature), + "attestation", + ATT_ESTALE_EPOCH, + ); + + // Signing for an epoch the issuer has not entered is refused too. + let mut future = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + future.issuer_epoch = 5; + let signature = sign_attestation(&mut w, &future); + assert_abort( + &redeem(&mut w, &future, &signature), + "attestation", + ATT_ESTALE_EPOCH, + ); + + // Under the current epoch it lands. + let mut current = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + current.issuer_epoch = 1; + let signature = sign_attestation(&mut w, ¤t); + assert_success!(redeem(&mut w, ¤t, &signature)); + assert!(w.verified(&alice)); +} + +#[test] +fn test_relay_stops_when_issuer_is_removed() { + let mut w = World::new(); + let alice = w.alice.clone(); + // Signed while the issuer was in good standing... + let a = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &a); + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::remove_issuers", + vec![arg(&source), arg(&vec![*w.issuer.address()]),] + )); + // ...but relayed after it was removed. Removing an issuer must stop both of its write paths, + // not only the one that needs its account to sign a transaction. + assert_abort( + &redeem(&mut w, &a, &signature), + "attestation", + ATT_ENOT_ISSUER, + ); + assert_abort( + &w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR), + "attestation", + ATT_ENOT_ISSUER, + ); + assert!(!w.verified(&alice)); +} + +#[test] +fn test_nullifier_binds_one_identity_to_one_subject() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let mut a = fresh_attestation(&mut w, &alice, LEVEL_BASIC); + a.nullifier = vec![0x22; 32]; + let signature = sign_attestation(&mut w, &a); + assert_success!(redeem(&mut w, &a, &signature)); + + // The same real-world identity cannot also verify a second address. + let mut b = fresh_attestation(&mut w, &bob, LEVEL_BASIC); + b.nullifier = vec![0x22; 32]; + let signature = sign_attestation(&mut w, &b); + assert_abort( + &redeem(&mut w, &b, &signature), + "attestation", + ATT_ENULLIFIER_BOUND, + ); + assert!(!w.verified(&bob)); +} + +// ══════════════════════════════════════════════════════════════ +// Denial precedence and pause +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_denial_beats_a_valid_fact_and_only_remover_undoes_it() { + let mut w = World::new(); + let alice = w.alice.clone(); + assert_success!(w.issue(&[&alice], vec![LEVEL_ENHANCED], ONE_YEAR)); + assert!(w.verified(&alice)); + + let sentinel = w.sentinel.clone(); + assert_success!(w.deny(&sentinel, &alice)); + let source = w.source; + assert!(!w.verified(&alice)); + assert!(source_view::( + &mut w.h, + "is_denied", + source, + *alice.address() + )); + assert_eq!( + source_view::(&mut w.h, "deny_reason", source, *alice.address()), + 42 + ); + assert_eq!( + source_view::(&mut w.h, "state_of", source, *alice.address()), + STATE_REVOKED + ); + + // No positive write path can overwrite a denial. + assert_abort( + &w.issue(&[&alice], vec![LEVEL_ENHANCED], ONE_YEAR), + "attestation", + ATT_ESUBJECT_DENIED, + ); + // Signed after the existing fact, so it is not refused for being older. + advance(&mut w.h, 1); + let a = fresh_attestation(&mut w, &alice, LEVEL_ENHANCED); + let signature = sign_attestation(&mut w, &a); + assert_abort( + &redeem(&mut w, &a, &signature), + "attestation", + ATT_ESUBJECT_DENIED, + ); + + // The sentinel cannot undo its own work, and neither can the admin. + assert_abort( + &w.undeny(&sentinel, &alice), + "attestation", + ATT_ENOT_REMOVER, + ); + let admin = w.admin.clone(); + assert_abort(&w.undeny(&admin, &alice), "attestation", ATT_ENOT_REMOVER); + + // The remover can, and the underlying fact is intact. + let remover = w.remover.clone(); + assert_success!(w.undeny(&remover, &alice)); + assert!(w.verified(&alice)); + assert_eq!( + source_view::(&mut w.h, "level_of", source, *alice.address()), + LEVEL_ENHANCED + ); +} + +#[test] +fn test_remover_cannot_deny_and_denial_can_be_scheduled() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_BASIC, LEVEL_BASIC], ONE_YEAR)); + let remover = w.remover.clone(); + assert_abort(&w.deny(&remover, &alice), "attestation", ATT_ENOT_SENTINEL); + + // A denial announced for an hour from now does not bite yet. + let sentinel = w.sentinel.clone(); + let source = w.source; + let at = now(&mut w.h) + 3600; + assert_success!(run(&mut w.h, &sentinel, "0x1::attestation::deny", vec![ + arg(&source), + arg(bob.address()), + arg(&1u16), + arg(&at), + ])); + assert!(w.verified(&bob)); + advance(&mut w.h, 3600); + assert!(!w.verified(&bob)); + assert!(w.verified(&alice)); + + // Batch denial is immediate. + assert_success!(run( + &mut w.h, + &sentinel, + "0x1::attestation::deny_batch", + vec![arg(&source), arg(&vec![*alice.address()]), arg(&2u16),] + )); + assert!(!w.verified(&alice)); +} + +#[test] +fn test_pause_blocks_writes_not_reads() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let source = w.source; + + // Only a guardian may pause. + let issuer = w.issuer.clone(); + assert_abort( + &run(&mut w.h, &issuer, "0x1::attestation::pause", vec![arg( + &source, + )]), + "attestation", + ATT_ENOT_GUARDIAN, + ); + let guardian = w.guardian.clone(); + assert_success!(run(&mut w.h, &guardian, "0x1::attestation::pause", vec![ + arg(&source) + ])); + assert!(view::(&mut w.h, "0x1::attestation::is_paused", vec![ + arg(&source) + ])); + + // The answer other protocols depend on does not flip. + assert!(w.verified(&alice)); + // Writes stop, on both positive paths. + assert_abort( + &w.issue(&[&bob], vec![LEVEL_BASIC], ONE_YEAR), + "attestation", + ATT_EPAUSED, + ); + let b = fresh_attestation(&mut w, &bob, LEVEL_BASIC); + let signature = sign_attestation(&mut w, &b); + assert_abort(&redeem(&mut w, &b, &signature), "attestation", ATT_EPAUSED); + // Exclusion keeps working during an incident, which is when it matters most. + let sentinel = w.sentinel.clone(); + assert_success!(w.deny(&sentinel, &alice)); + assert!(!w.verified(&alice)); + + assert_success!(run(&mut w.h, &guardian, "0x1::attestation::unpause", vec![ + arg(&source) + ])); + assert_success!(w.issue(&[&bob], vec![LEVEL_BASIC], ONE_YEAR)); + assert!(w.verified(&bob)); +} + +#[test] +fn test_attributes_round_trip() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let issuer = w.issuer.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &issuer, + "0x1::attestation::set_attribute", + vec![ + arg(&source), + arg(alice.address()), + arg(&ATTR_COUNTRY), + arg(&COUNTRY_PT.to_vec()), + ] + )); + let value: Vec = view(&mut w.h, "0x1::attestation::attribute_of", vec![ + arg(&source), + arg(alice.address()), + arg(&ATTR_COUNTRY), + ]); + assert_eq!(value, COUNTRY_PT.to_vec()); + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.attrs, vec![(ATTR_COUNTRY, COUNTRY_PT.to_vec())]); + // Attributes need a record to hang off. + let status = run(&mut w.h, &issuer, "0x1::attestation::set_attribute", vec![ + arg(&source), + arg(bob.address()), + arg(&ATTR_COUNTRY), + arg(&COUNTRY_PT.to_vec()), + ]); + assert_abort(&status, "attestation", ATT_ERECORD_NOT_FOUND); + + assert_success!(run( + &mut w.h, + &issuer, + "0x1::attestation::remove_attribute", + vec![arg(&source), arg(alice.address()), arg(&ATTR_COUNTRY),] + )); + let value: Vec = view(&mut w.h, "0x1::attestation::attribute_of", vec![ + arg(&source), + arg(alice.address()), + arg(&ATTR_COUNTRY), + ]); + assert!(value.is_empty()); +} + +// ══════════════════════════════════════════════════════════════ +// Policies +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_new_policy_denies_everything() { + let mut w = World::new(); + let alice = w.alice.clone(); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_DENY, REASON_EMPTY_BODY) + ); +} + +#[test] +fn test_policy_staged_activation_is_timed_and_permissionless() { + let mut w = World::new(); + let (alice, bob, carol) = (w.alice.clone(), w.bob.clone(), w.carol.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_ENHANCED, LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + + let effective_at = now(&mut w.h) + 600; + let body = BodySpec::any(w.source, LEVEL_ENHANCED); + assert_success!(stage_body(&mut w.h, &admin, policy, &body, effective_at)); + assert!(view::( + &mut w.h, + "0x1::attestation_policy::has_pending", + vec![arg(&policy)] + )); + assert_eq!( + view::( + &mut w.h, + "0x1::attestation_policy::pending_effective_at", + vec![arg(&policy)] + ), + effective_at + ); + + // A staged body has no effect before its time, and cannot be forced live early. + assert_eq!(reason(&mut w.h, policy, &alice), REASON_EMPTY_BODY); + let relayer = w.relayer.clone(); + assert_abort( + &activate(&mut w.h, &relayer, policy), + "attestation_policy", + POL_ENOT_EFFECTIVE, + ); + + // Once its time arrives anyone may push it live, so the business need not be online. + advance(&mut w.h, 600); + assert_success!(activate(&mut w.h, &relayer, policy)); + assert!(!view::( + &mut w.h, + "0x1::attestation_policy::has_pending", + vec![arg(&policy)] + )); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_ALLOW, REASON_OK) + ); + assert_eq!(reason(&mut w.h, policy, &bob), REASON_NO_QUALIFYING); + assert_eq!(reason(&mut w.h, policy, &carol), REASON_NO_QUALIFYING); + + // simulate returns one decision per subject, in order. + let decisions: Vec = view(&mut w.h, "0x1::attestation_policy::simulate", vec![ + arg(&policy), + arg(&vec![*alice.address(), *bob.address(), *carol.address()]), + arg(&ACTION_TRANSFER), + arg(&1u64), + ]); + assert_eq!(decisions, vec![ + DECISION_ALLOW, + DECISION_DENY, + DECISION_DENY + ]); +} + +#[test] +fn test_policy_require_all_and_levels() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_ENHANCED, LEVEL_BASIC], ONE_YEAR)); + // A second source, run by someone else, that only vouches for Alice. + let other_admin = w.carol.clone(); + let other = create_source( + &mut w.h, + &other_admin, + vec![*other_admin.address()], + vec![*other_admin.address()], + vec![], + vec![], + vec![], + ); + let (_, key) = ed25519_key(12); + assert_success!(run( + &mut w.h, + &other_admin, + "0x1::attestation::register_issuer", + vec![arg(&other), arg(other_admin.address()), arg(&key),] + )); + let expiry = now(&mut w.h) + ONE_YEAR; + assert_success!(run( + &mut w.h, + &other_admin, + "0x1::attestation::issue_batch", + vec![ + arg(&other), + arg(&vec![*alice.address(), *bob.address()]), + arg(&vec![LEVEL_BASIC, LEVEL_BASIC]), + arg(&vec![expiry, expiry]), + arg(&0u16), + ] + )); + + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + apply_body(&mut w.h, &admin, policy, &BodySpec { + require_any: vec![], + require_all: vec![(w.source, LEVEL_ENHANCED), (other, LEVEL_BASIC)], + deny_any: vec![], + chain_deny: vec![], + }); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_ALLOW, REASON_OK) + ); + assert_eq!(reason(&mut w.h, policy, &bob), REASON_LEVEL_TOO_LOW); + let carol = w.carol.clone(); + assert_eq!(reason(&mut w.h, policy, &carol), REASON_MISSING_REQUIRED); +} + +#[test] +fn test_policy_denial_sources_outrank_vouching() { + let mut w = World::new(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&alice, &bob], vec![LEVEL_BASIC, LEVEL_BASIC], ONE_YEAR)); + // A sanctions source and a chain-wide denial source, both run by the relayer account. + let operator = w.relayer.clone(); + let make_deny_source = |h: &mut MoveHarness| { + create_source( + h, + &operator, + vec![*operator.address()], + vec![], + vec![*operator.address()], + vec![], + vec![], + ) + }; + let sanctions = make_deny_source(&mut w.h); + let chain = make_deny_source(&mut w.h); + + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + let t = now(&mut w.h); + // Only one chain-wide denial source may be named. + let mut body = BodySpec::any(w.source, LEVEL_BASIC); + body.chain_deny = vec![chain, sanctions]; + assert_abort( + &stage_body(&mut w.h, &admin, policy, &body, t), + "attestation_policy", + POL_ETOO_MANY_CHAIN_DENY, + ); + body.chain_deny = vec![chain]; + body.deny_any = vec![sanctions]; + apply_body(&mut w.h, &admin, policy, &body); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_ALLOW, REASON_OK) + ); + + let deny_in = |h: &mut MoveHarness, source: AccountAddress, subject: &Account| { + let at = now(h); + assert_success!(run(h, &operator, "0x1::attestation::deny", vec![ + arg(&source), + arg(subject.address()), + arg(&1u16), + arg(&at), + ])); + }; + deny_in(&mut w.h, sanctions, &alice); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_DENY, REASON_SOURCE_DENIED) + ); + // The chain-wide source is consulted first. + deny_in(&mut w.h, chain, &alice); + deny_in(&mut w.h, chain, &bob); + assert_eq!(reason(&mut w.h, policy, &alice), REASON_CHAIN_DENIED); + assert_eq!(reason(&mut w.h, policy, &bob), REASON_CHAIN_DENIED); +} + +#[test] +fn test_policy_attribute_rules() { + let mut w = World::new(); + let (alice, bob, carol) = (w.alice.clone(), w.bob.clone(), w.carol.clone()); + assert_success!(w.issue( + &[&alice, &bob, &carol], + vec![LEVEL_BASIC, LEVEL_BASIC, LEVEL_BASIC], + ONE_YEAR + )); + let issuer = w.issuer.clone(); + let source = w.source; + for (subject, country) in [(&alice, COUNTRY_PT), (&bob, COUNTRY_BR)] { + assert_success!(run( + &mut w.h, + &issuer, + "0x1::attestation::set_attribute", + vec![ + arg(&source), + arg(subject.address()), + arg(&ATTR_COUNTRY), + arg(&country.to_vec()), + ] + )); + } + let admin = w.admin.clone(); + let policy = create_policy(&mut w.h, &admin, vec![]); + let t = now(&mut w.h); + assert_success!(stage_body( + &mut w.h, + &admin, + policy, + &BodySpec::any(source, LEVEL_BASIC), + t + )); + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation_policy::stage_attr_rules", + vec![ + arg(&policy), + arg(&vec![source]), + arg(&vec![ATTR_COUNTRY]), + arg(&vec![OP_IN]), + arg(&vec![vec![COUNTRY_PT.to_vec()]]), + ] + )); + assert_success!(activate(&mut w.h, &admin, policy)); + + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_ALLOW, REASON_OK) + ); + assert_eq!(reason(&mut w.h, policy, &bob), REASON_ATTR_FAILED); + // An unset attribute never satisfies a positive requirement. + assert_eq!(reason(&mut w.h, policy, &carol), REASON_ATTR_FAILED); + + // A malformed rule is rejected at staging time: OP_GTE takes exactly one value. + let status = run( + &mut w.h, + &admin, + "0x1::attestation_policy::stage_attr_rules", + vec![ + arg(&policy), + arg(&vec![source]), + arg(&vec![ATTR_COUNTRY]), + arg(&vec![OP_GTE]), + arg(&vec![vec![COUNTRY_PT.to_vec(), COUNTRY_BR.to_vec()]]), + ], + ); + assert_abort(&status, "attestation_policy", POL_EBAD_RULE); +} + +#[test] +fn test_paused_policy_denies_loudly() { + let mut w = World::new(); + let alice = w.alice.clone(); + assert_success!(w.issue(&[&alice], vec![LEVEL_BASIC], ONE_YEAR)); + let admin = w.admin.clone(); + let guardian = w.guardian.clone(); + let policy = create_policy(&mut w.h, &admin, vec![*guardian.address()]); + apply_body( + &mut w.h, + &admin, + policy, + &BodySpec::any(w.source, LEVEL_BASIC), + ); + assert_success!(run( + &mut w.h, + &guardian, + "0x1::attestation_policy::pause", + vec![arg(&policy)] + )); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_DENY, REASON_POLICY_PAUSED) + ); + assert_success!(run( + &mut w.h, + &guardian, + "0x1::attestation_policy::unpause", + vec![arg(&policy)] + )); + assert_eq!( + decision(&mut w.h, policy, &alice, 1), + (DECISION_ALLOW, REASON_OK) + ); +} + +// ══════════════════════════════════════════════════════════════ +// A consumer contract gated on a policy +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_gated_entry_function_allows_and_denies() { + let (mut w, policy, _) = step_up_world(); + let (alice, carol) = (w.alice.clone(), w.carol.clone()); + + assert_success!(gated_transfer(&mut w.h, &alice, policy, 10)); + assert_eq!(transfer_count(&mut w.h, &alice), 1); + + // An unverified subject is stopped inside the business's own entry function. + assert_abort( + &gated_transfer(&mut w.h, &carol, policy, 10), + "attestation_policy", + POL_EDENIED, + ); + assert_eq!(transfer_count(&mut w.h, &carol), 0); + + // A denial landing after onboarding stops the next transaction. + let sentinel = w.sentinel.clone(); + assert_success!(w.deny(&sentinel, &alice)); + assert_abort( + &gated_transfer(&mut w.h, &alice, policy, 10), + "attestation_policy", + POL_EDENIED, + ); + assert_eq!(transfer_count(&mut w.h, &alice), 1); +} + +#[test] +fn test_gated_entry_function_demands_step_up_over_threshold() { + let (mut w, policy, _) = step_up_world(); + let alice = w.alice.clone(); + assert_eq!( + decision(&mut w.h, policy, &alice, 1001), + (DECISION_STEP_UP, REASON_AMOUNT_THRESHOLD) + ); + // At the threshold is fine, above it is not. + assert_success!(gated_transfer(&mut w.h, &alice, policy, 1000)); + assert_abort( + &gated_transfer(&mut w.h, &alice, policy, 1001), + "attestation_policy", + POL_ESTEP_UP_REQUIRED, + ); + // An allowed amount through the authorized entry point consumes nothing. + assert_success!(gated_transfer_authorized(&mut w.h, &alice, policy, 10, &[])); + assert_eq!(transfer_count(&mut w.h, &alice), 2); + + let counts: Vec = view(&mut w.h, "0x1::attestation_policy::simulate_counts", vec![ + arg(&policy), + arg(&vec![*alice.address(), *w.carol.address()]), + arg(&ACTION_TRANSFER), + arg(&5000u64), + ]); + assert_eq!(counts, vec![0, 1, 1]); +} + +#[test] +fn test_authorization_satisfies_step_up_once() { + let (mut w, policy, authorizer) = step_up_world(); + let alice = w.alice.clone(); + let a = authorization_for(&mut w.h, policy, &alice, 0x01, 60); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + + assert_success!(gated_transfer_authorized( + &mut w.h, &alice, policy, 5000, &blob + )); + assert_eq!(transfer_count(&mut w.h, &alice), 1); + assert!(view::( + &mut w.h, + "0x1::attestation_authorization::is_nonce_used", + vec![arg(&policy), arg(&a.nonce.to_vec())] + )); + + // The nonce is burned: the same capability cannot be spent twice. + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_authorization", + AUTH_ENONCE_USED, + ); + assert_eq!(transfer_count(&mut w.h, &alice), 1); + + // Once it can no longer be replayed, anyone may release its storage. + advance(&mut w.h, 61); + let relayer = w.relayer.clone(); + assert_success!(run( + &mut w.h, + &relayer, + "0x1::attestation_authorization::prune_nonces", + vec![arg(&policy), arg(&vec![a.nonce.to_vec()])] + )); + assert!(!view::( + &mut w.h, + "0x1::attestation_authorization::is_nonce_used", + vec![arg(&policy), arg(&a.nonce.to_vec())] + )); +} + +#[test] +fn test_authorization_rejections() { + let (mut w, policy, authorizer) = step_up_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + + // Expired. + let a = authorization_for(&mut w.h, policy, &alice, 0x02, 60); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + advance(&mut w.h, 60); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_authorization", + AUTH_EEXPIRED, + ); + + // Amount above the committed bucket ceiling (10^4). + let a = authorization_for(&mut w.h, policy, &alice, 0x03, 60); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 10_001, &blob), + "attestation_authorization", + AUTH_EAMOUNT_OVER_BUCKET, + ); + + // A window longer than the policy's max TTL (300s) is refused even if validly signed. + let a = authorization_for(&mut w.h, policy, &alice, 0x04, 301); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_authorization", + AUTH_ETTL_TOO_LONG, + ); + + // Issued to Bob, spent by Alice. + let a = authorization_for(&mut w.h, policy, &bob, 0x05, 60); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_authorization", + AUTH_EBAD_SIGNATURE, + ); + + // Signed by a key the policy does not trust. + let (stranger, _) = ed25519_key(77); + let a = authorization_for(&mut w.h, policy, &alice, 0x06, 60); + let blob = sign_authorization(&mut w.h, &stranger, &a); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_authorization", + AUTH_EBAD_SIGNATURE, + ); + + // An authorization never outranks a denial. + let sentinel = w.sentinel.clone(); + assert_success!(w.deny(&sentinel, &alice)); + let a = authorization_for(&mut w.h, policy, &alice, 0x07, 60); + let blob = sign_authorization(&mut w.h, &authorizer, &a); + assert_abort( + &gated_transfer_authorized(&mut w.h, &alice, policy, 5000, &blob), + "attestation_policy", + POL_EDENIED, + ); + assert_eq!(transfer_count(&mut w.h, &alice), 0); +} + +// ══════════════════════════════════════════════════════════════ +// Write path 3: zkTLS enrollment +// ══════════════════════════════════════════════════════════════ + +#[test] +fn test_zktls_enroll_with_threshold_signatures() { + let (mut w, attestors) = zktls_world(); + let alice = w.alice.clone(); + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "session-1"); + let signatures = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&attestors[2], &claim), + ]; + + // The published digest and recovery views agree with the Rust signer. + let digest: Vec = view(&mut w.h, "0x1::zktls::claim_digest", vec![arg(&claim)]); + assert_eq!(digest, claim_digest(&claim).to_vec()); + let recovered: Vec = view(&mut w.h, "0x1::zktls::recover_attestor", vec![ + arg(&claim), + arg(&signatures[0]), + ]); + assert_eq!(recovered, attestors[0].address); + assert!(verify_claim(&mut w, &alice, &claim, signatures.clone(), 1)); + + assert_success!(enroll(&mut w, &alice, &claim, signatures, 1, &[])); + assert!(w.verified(&alice)); + let source = w.source; + let record: Record = source_view(&mut w.h, "record_of", source, *alice.address()); + assert_eq!(record.level, ZK_LEVEL); + assert_eq!( + record.issuer_id, 0, + "no issuer key is involved on this path" + ); + assert_eq!(record.attestation_digest, keccak(&claim)); + assert_eq!(record.expires_at_secs, now(&mut w.h) + ZK_TTL); +} + +#[test] +fn test_zktls_enroll_rejections() { + let (mut w, attestors) = zktls_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "session-2"); + + // One of two required signatures. + let one = vec![sign_claim(&attestors[0], &claim)]; + assert!(!verify_claim(&mut w, &alice, &claim, one.clone(), 1)); + assert_abort( + &enroll(&mut w, &alice, &claim, one, 1, &[]), + "zktls", + ZK_EBELOW_THRESHOLD, + ); + + // The same attestor twice does not count as two. + let twice = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&attestors[0], &claim), + ]; + assert_abort( + &enroll(&mut w, &alice, &claim, twice, 1, &[]), + "zktls", + ZK_EDUPLICATE_SIGNER, + ); + + // A signer outside the registered set. + let outsider = attestor(40); + let foreign = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&outsider, &claim), + ]; + assert_abort( + &enroll(&mut w, &alice, &claim, foreign, 1, &[]), + "zktls", + ZK_EUNKNOWN_ATTESTOR, + ); + + // A valid claim about Alice is not a valid claim for Bob, whoever relays it. + let signatures = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&attestors[1], &claim), + ]; + assert_abort( + &enroll(&mut w, &bob, &claim, signatures.clone(), 1, &[]), + "zktls", + ZK_EMALFORMED_CLAIM, + ); + assert!(!w.verified(&alice)); + assert!(!w.verified(&bob)); +} + +#[test] +fn test_zktls_revoked_template_stops_new_enrollment() { + let (mut w, attestors) = zktls_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let alice_claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "a"); + let signatures = vec![ + sign_claim(&attestors[0], &alice_claim), + sign_claim(&attestors[1], &alice_claim), + ]; + assert_success!(enroll(&mut w, &alice, &alice_claim, signatures, 1, &[])); + + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run(&mut w.h, &admin, "0x1::zktls::revoke_template", vec![ + arg(&source), + arg(&TEMPLATE_ID.to_vec()), + ])); + let bob_claim = claim_for(*bob.address(), &TEMPLATE_ID, &[], "b"); + let signatures = vec![ + sign_claim(&attestors[0], &bob_claim), + sign_claim(&attestors[1], &bob_claim), + ]; + assert!(!verify_claim( + &mut w, + &bob, + &bob_claim, + signatures.clone(), + 1 + )); + assert_abort( + &enroll(&mut w, &bob, &bob_claim, signatures, 1, &[]), + "zktls", + ZK_ETEMPLATE_REVOKED, + ); + // Facts already recorded are untouched: a broken template degrades enrollment only. + assert!(w.verified(&alice)); + assert!(!w.verified(&bob)); +} + +#[test] +fn test_zktls_claim_is_single_use() { + let (mut w, attestors) = zktls_world(); + let alice = w.alice.clone(); + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "session-3"); + let signatures = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&attestors[1], &claim), + ]; + assert_success!(enroll(&mut w, &alice, &claim, signatures.clone(), 1, &[])); + + // Replaying the same attested session, for example to refresh an expiry without a new TLS + // session, is refused. + assert!(!verify_claim(&mut w, &alice, &claim, signatures.clone(), 1)); + assert_abort( + &enroll(&mut w, &alice, &claim, signatures, 1, &[]), + "zktls", + ZK_ECLAIM_CONSUMED, + ); + + // A fresh session is accepted. + advance(&mut w.h, 10); + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "session-4"); + let signatures = vec![ + sign_claim(&attestors[1], &claim), + sign_claim(&attestors[2], &claim), + ]; + assert_success!(enroll(&mut w, &alice, &claim, signatures, 1, &[])); + assert!(w.verified(&alice)); +} + +#[test] +fn test_zktls_attestor_rotation_grace_window() { + let (mut w, old_set) = zktls_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let new_set = [attestor(4), attestor(5)]; + // Rotate to epoch 2, keeping epoch 1 alive for ten minutes. + set_attestors(&mut w, &new_set.iter().collect::>(), 2, 600); + let source = w.source; + assert_eq!( + view::(&mut w.h, "0x1::zktls::current_epoch", vec![arg(&source)]), + 2 + ); + + // A claim signed by the old set just before the rotation still lands inside the window. + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "late"); + let signatures = vec![ + sign_claim(&old_set[0], &claim), + sign_claim(&old_set[1], &claim), + ]; + assert_success!(enroll(&mut w, &alice, &claim, signatures, 1, &[])); + + // After the window the retired set is refused, while the new one works. + advance(&mut w.h, 600); + let claim = claim_for(*bob.address(), &TEMPLATE_ID, &[], "stale"); + let signatures = vec![ + sign_claim(&old_set[0], &claim), + sign_claim(&old_set[1], &claim), + ]; + assert_abort( + &enroll(&mut w, &bob, &claim, signatures, 1, &[]), + "zktls", + ZK_ERETIRED_ATTESTOR_EPOCH, + ); + let signatures = vec![ + sign_claim(&new_set[0], &claim), + sign_claim(&new_set[1], &claim), + ]; + assert_success!(enroll(&mut w, &bob, &claim, signatures, 2, &[])); + assert!(w.verified(&bob)); + + // A second rotation retires epoch 2's predecessor at once, with or without grace. + set_attestors(&mut w, &old_set.iter().collect::>(), 2, 0); + let carol = w.carol.clone(); + let claim = claim_for(*carol.address(), &TEMPLATE_ID, &[], "rotated"); + let signatures = vec![ + sign_claim(&new_set[0], &claim), + sign_claim(&new_set[1], &claim), + ]; + assert_abort( + &enroll(&mut w, &carol, &claim, signatures, 2, &[]), + "zktls", + ZK_ERETIRED_ATTESTOR_EPOCH, + ); +} + +#[test] +fn test_zktls_nullifier_must_be_attested() { + let (mut w, attestors) = zktls_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + let nullifier = [0x33u8; 32]; + + // A nullifier the attestors did not sign is just a value the user picked. + let unsigned = claim_for(*alice.address(), &TEMPLATE_ID, &[], "n1"); + let signatures = vec![ + sign_claim(&attestors[0], &unsigned), + sign_claim(&attestors[1], &unsigned), + ]; + assert_abort( + &enroll(&mut w, &alice, &unsigned, signatures, 1, &nullifier), + "zktls", + ZK_EMALFORMED_CLAIM, + ); + + // Carried inside the attested claim, it binds. + let signed = claim_for(*alice.address(), &TEMPLATE_ID, &nullifier, "n2"); + let signatures = vec![ + sign_claim(&attestors[0], &signed), + sign_claim(&attestors[1], &signed), + ]; + assert_success!(enroll(&mut w, &alice, &signed, signatures, 1, &nullifier)); + + // The same real-world identity enrolling a second address is refused by the source. + let second = claim_for(*bob.address(), &TEMPLATE_ID, &nullifier, "n3"); + let signatures = vec![ + sign_claim(&attestors[0], &second), + sign_claim(&attestors[1], &second), + ]; + assert_abort( + &enroll(&mut w, &bob, &second, signatures, 1, &nullifier), + "attestation", + ATT_ENULLIFIER_BOUND, + ); + assert!(w.verified(&alice)); + assert!(!w.verified(&bob)); +} + +#[test] +fn test_zktls_cohort_can_be_killed_in_one_write() { + let (mut w, attestors) = zktls_world(); + let (alice, bob) = (w.alice.clone(), w.bob.clone()); + assert_success!(w.issue(&[&bob], vec![LEVEL_BASIC], ONE_YEAR)); + let claim = claim_for(*alice.address(), &TEMPLATE_ID, &[], "cohort"); + let signatures = vec![ + sign_claim(&attestors[0], &claim), + sign_claim(&attestors[1], &claim), + ]; + assert_success!(enroll(&mut w, &alice, &claim, signatures, 1, &[])); + assert!(w.verified(&alice)); + + // Issuer id 0 is the zkTLS cohort. Bumping it is the remedy for a compromised attestor set, + // and it leaves facts written by real issuers alone. + let admin = w.admin.clone(); + let source = w.source; + assert_success!(run( + &mut w.h, + &admin, + "0x1::attestation::bump_issuer_epoch", + vec![arg(&source), arg(&0u16),] + )); + assert!(!w.verified(&alice)); + assert!(w.verified(&bob)); +} diff --git a/aptos-move/e2e-move-tests/src/tests/mod.rs b/aptos-move/e2e-move-tests/src/tests/mod.rs index 9ea95e79242..331dc2d1371 100644 --- a/aptos-move/e2e-move-tests/src/tests/mod.rs +++ b/aptos-move/e2e-move-tests/src/tests/mod.rs @@ -10,6 +10,7 @@ mod aggregator_v2_events; mod aggregator_v2_function_values; mod aggregator_v2_runtime_checks; mod any; +mod attestation; mod attributes; mod chain_id; mod code_publishing; diff --git a/aptos-move/framework/aptos-framework/doc/attestation.md b/aptos-move/framework/aptos-framework/doc/attestation.md new file mode 100644 index 00000000000..ccb65a3e76a --- /dev/null +++ b/aptos-move/framework/aptos-framework/doc/attestation.md @@ -0,0 +1,5089 @@ + + + +# Module `0x1::attestation` + +Attestation source module for Movement. A source is an independent namespace that asserts +facts about addresses: who is verified, at what level, with what attributes, and who is +excluded. Anyone may create one, and nothing here is globally trusted, so a consumer names the +sources it trusts or gets no answer. Two sources may disagree about the same subject and +nothing is broken, because a fact is never true globally, only true according to somebody. + +Each source is a resource account, following aptos_framework::timelock: the deployer +authorizes creation and pays gas but gains no role unless it is listed in the role arguments. +The account has no owner, so unlike an object it cannot be transferred or burned out from under +the integrators that hardcoded its address. + +Roles, and why each is separate: +- Admins configure the source, register and rotate issuers, and grant roles. +- Issuers write facts. This is the hot key, and it deliberately cannot touch denials or roles. +- Sentinels may ADD denials only, which is the fast path for sanctions screening. +- Removers may REMOVE denials only. Mistaken denial is the dominant operational failure mode in +production systems of this kind, so reversal is a designed path with a different key rather +than an afterthought. +- Guardians may pause writes without needing the admin path. +An address may hold several roles; the overlap is allowed on purpose so authority can be handed +over without a gap, exactly as timelock allows for its canceler role. + +Delayed governance comes for free by composition: an admin address may be a +aptos_framework::timelock account, in which case every configuration change inherits that +module's delay, multi-role and cancel semantics. This module deliberately does not reimplement +any of it. + +Properties: +- Denial is evaluated before anything else and no positive write can create, modify or clear it. +- A fact is never active past its expiry, below its issuer's epoch, or below the source floor. +- Bumping an issuer's epoch invalidates every fact that issuer wrote, in one write, which is +the remedy for a compromised issuer key and is O(1) in the size of the cohort. +- Pausing blocks writes and never changes the answer is_verified gives. A pause must not +silently flip a boolean that other protocols depend on. +- Configuration and facts live in two separate resources so the read path touches only +immutable state and per-subject table keys, and gated transactions therefore never conflict +with one another under Block-STM. + + +- [Struct `Change`](#0x1_attestation_Change) +- [Struct `Record`](#0x1_attestation_Record) +- [Struct `DenyEntry`](#0x1_attestation_DenyEntry) +- [Struct `Root`](#0x1_attestation_Root) +- [Struct `Issuer`](#0x1_attestation_Issuer) +- [Resource `Source`](#0x1_attestation_Source) +- [Resource `Facts`](#0x1_attestation_Facts) +- [Struct `CreateSource`](#0x1_attestation_CreateSource) +- [Struct `AddMembers`](#0x1_attestation_AddMembers) +- [Struct `RemoveMembers`](#0x1_attestation_RemoveMembers) +- [Struct `RegisterIssuer`](#0x1_attestation_RegisterIssuer) +- [Struct `RotateIssuerKey`](#0x1_attestation_RotateIssuerKey) +- [Struct `BumpIssuerEpoch`](#0x1_attestation_BumpIssuerEpoch) +- [Struct `SetFloorEpoch`](#0x1_attestation_SetFloorEpoch) +- [Struct `RecordFact`](#0x1_attestation_RecordFact) +- [Struct `SetAttribute`](#0x1_attestation_SetAttribute) +- [Struct `PublishRoot`](#0x1_attestation_PublishRoot) +- [Struct `Deny`](#0x1_attestation_Deny) +- [Struct `Undeny`](#0x1_attestation_Undeny) +- [Struct `SetPaused`](#0x1_attestation_SetPaused) +- [Constants](#@Constants_0) +- [Function `get_next_source_address`](#0x1_attestation_get_next_source_address) +- [Function `is_verified`](#0x1_attestation_is_verified) +- [Function `state_of`](#0x1_attestation_state_of) +- [Function `level_of`](#0x1_attestation_level_of) +- [Function `active_with_level`](#0x1_attestation_active_with_level) +- [Function `record_of`](#0x1_attestation_record_of) +- [Function `attribute_of`](#0x1_attestation_attribute_of) +- [Function `is_denied`](#0x1_attestation_is_denied) +- [Function `deny_reason`](#0x1_attestation_deny_reason) +- [Function `expires_at`](#0x1_attestation_expires_at) +- [Function `current_root`](#0x1_attestation_current_root) +- [Function `verify_membership`](#0x1_attestation_verify_membership) +- [Function `admins`](#0x1_attestation_admins) +- [Function `issuers`](#0x1_attestation_issuers) +- [Function `sentinels`](#0x1_attestation_sentinels) +- [Function `removers`](#0x1_attestation_removers) +- [Function `guardians`](#0x1_attestation_guardians) +- [Function `is_admin`](#0x1_attestation_is_admin) +- [Function `is_issuer`](#0x1_attestation_is_issuer) +- [Function `issuer_id_of`](#0x1_attestation_issuer_id_of) +- [Function `issuer_epoch_of`](#0x1_attestation_issuer_epoch_of) +- [Function `floor_epoch`](#0x1_attestation_floor_epoch) +- [Function `is_paused`](#0x1_attestation_is_paused) +- [Function `is_source`](#0x1_attestation_is_source) +- [Function `standard_version`](#0x1_attestation_standard_version) +- [Function `attestation_message`](#0x1_attestation_attestation_message) +- [Function `create`](#0x1_attestation_create) +- [Function `create_source_internal`](#0x1_attestation_create_source_internal) +- [Function `add_admins`](#0x1_attestation_add_admins) +- [Function `remove_admins`](#0x1_attestation_remove_admins) +- [Function `add_issuers`](#0x1_attestation_add_issuers) +- [Function `remove_issuers`](#0x1_attestation_remove_issuers) +- [Function `add_sentinels`](#0x1_attestation_add_sentinels) +- [Function `remove_sentinels`](#0x1_attestation_remove_sentinels) +- [Function `add_removers`](#0x1_attestation_add_removers) +- [Function `remove_removers`](#0x1_attestation_remove_removers) +- [Function `add_guardians`](#0x1_attestation_add_guardians) +- [Function `remove_guardians`](#0x1_attestation_remove_guardians) +- [Function `pause`](#0x1_attestation_pause) +- [Function `unpause`](#0x1_attestation_unpause) +- [Function `set_paused`](#0x1_attestation_set_paused) +- [Function `register_issuer`](#0x1_attestation_register_issuer) +- [Function `rotate_issuer_key`](#0x1_attestation_rotate_issuer_key) +- [Function `bump_issuer_epoch`](#0x1_attestation_bump_issuer_epoch) +- [Function `set_floor_epoch`](#0x1_attestation_set_floor_epoch) +- [Function `issue_batch`](#0x1_attestation_issue_batch) +- [Function `revoke_batch`](#0x1_attestation_revoke_batch) +- [Function `suspend`](#0x1_attestation_suspend) +- [Function `unsuspend`](#0x1_attestation_unsuspend) +- [Function `set_attribute`](#0x1_attestation_set_attribute) +- [Function `remove_attribute`](#0x1_attestation_remove_attribute) +- [Function `redeem_attestation`](#0x1_attestation_redeem_attestation) +- [Function `source_signer`](#0x1_attestation_source_signer) +- [Function `record_verified_claim`](#0x1_attestation_record_verified_claim) +- [Function `deny`](#0x1_attestation_deny) +- [Function `deny_batch`](#0x1_attestation_deny_batch) +- [Function `undeny`](#0x1_attestation_undeny) +- [Function `deny_internal`](#0x1_attestation_deny_internal) +- [Function `publish_root`](#0x1_attestation_publish_root) +- [Function `record_fact`](#0x1_attestation_record_fact) +- [Function `transition`](#0x1_attestation_transition) +- [Function `create_source_account`](#0x1_attestation_create_source_account) +- [Function `create_source_seed`](#0x1_attestation_create_source_seed) +- [Function `validate_members`](#0x1_attestation_validate_members) +- [Function `add_members`](#0x1_attestation_add_members) +- [Function `remove_members`](#0x1_attestation_remove_members) +- [Function `push_history`](#0x1_attestation_push_history) +- [Function `bind_nullifier`](#0x1_attestation_bind_nullifier) +- [Function `assert_newer`](#0x1_attestation_assert_newer) +- [Function `is_denied_internal`](#0x1_attestation_is_denied_internal) +- [Function `assert_source_exists`](#0x1_attestation_assert_source_exists) +- [Function `assert_admin`](#0x1_attestation_assert_admin) +- [Function `assert_not_paused`](#0x1_attestation_assert_not_paused) +- [Function `assert_issuer`](#0x1_attestation_assert_issuer) +- [Specification](#@Specification_1) + - [High-level Requirements](#high-level-req) + - [Module-level Specification](#module-level-spec) + - [Function `active_with_level`](#@Specification_1_active_with_level) + - [Function `is_denied`](#@Specification_1_is_denied) + - [Function `admins`](#@Specification_1_admins) + - [Function `is_admin`](#@Specification_1_is_admin) + - [Function `is_issuer`](#@Specification_1_is_issuer) + - [Function `issuer_epoch_of`](#@Specification_1_issuer_epoch_of) + - [Function `floor_epoch`](#@Specification_1_floor_epoch) + - [Function `is_paused`](#@Specification_1_is_paused) + - [Function `is_source`](#@Specification_1_is_source) + - [Function `standard_version`](#@Specification_1_standard_version) + - [Function `create`](#@Specification_1_create) + - [Function `create_source_internal`](#@Specification_1_create_source_internal) + - [Function `add_admins`](#@Specification_1_add_admins) + - [Function `remove_admins`](#@Specification_1_remove_admins) + - [Function `pause`](#@Specification_1_pause) + - [Function `unpause`](#@Specification_1_unpause) + - [Function `set_paused`](#@Specification_1_set_paused) + - [Function `bump_issuer_epoch`](#@Specification_1_bump_issuer_epoch) + - [Function `set_floor_epoch`](#@Specification_1_set_floor_epoch) + - [Function `suspend`](#@Specification_1_suspend) + - [Function `unsuspend`](#@Specification_1_unsuspend) + - [Function `redeem_attestation`](#@Specification_1_redeem_attestation) + - [Function `record_verified_claim`](#@Specification_1_record_verified_claim) + - [Function `deny`](#@Specification_1_deny) + - [Function `deny_batch`](#@Specification_1_deny_batch) + - [Function `undeny`](#@Specification_1_undeny) + - [Function `publish_root`](#@Specification_1_publish_root) + - [Function `record_fact`](#@Specification_1_record_fact) + - [Function `transition`](#@Specification_1_transition) + + +
use 0x1::account;
+use 0x1::aptos_hash;
+use 0x1::bcs;
+use 0x1::chain_id;
+use 0x1::ed25519;
+use 0x1::error;
+use 0x1::event;
+use 0x1::merkle_proof;
+use 0x1::signer;
+use 0x1::simple_map;
+use 0x1::table;
+use 0x1::timestamp;
+use 0x1::vector;
+
+ + + + + +## Struct `Change` + +One entry in a subject's change history. + + +
struct Change has copy, drop, store
+
+ + + +
+Fields + + +
+
+at_secs: u64 +
+
+ +
+
+prev_state: u8 +
+
+ +
+
+new_state: u8 +
+
+ +
+
+reason: u16 +
+
+ +
+
+issuer_id: u16 +
+
+ +
+
+ + +
+ + + +## Struct `Record` + +What one source asserts about one subject. + + +
struct Record has copy, drop, store
+
+ + + +
+Fields + + +
+
+state: u8 +
+
+ +
+
+level: u8 +
+
+ +
+
+issuer_id: u16 +
+
+ +
+
+issuer_epoch: u64 +
+
+ +
+
+issued_at_secs: u64 +
+
+ +
+
+expires_at_secs: u64 +
+
+ +
+
+revoked_at_secs: u64 +
+
+ +
+
+reason: u16 +
+
+ +
+
+attestation_digest: vector<u8> +
+
+ +
+
+attrs: simple_map::SimpleMap<u16, vector<u8>> +
+
+ +
+
+history: vector<attestation::Change> +
+
+ +
+
+ + +
+ + + +## Struct `DenyEntry` + +An exclusion. Written only by a sentinel, removed only by a remover, and unreachable from +every positive write path. + + +
struct DenyEntry has copy, drop, store
+
+ + + +
+Fields + + +
+
+reason: u16 +
+
+ +
+
+effective_at_secs: u64 +
+
+ +
+
+added_at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `Root` + +A published set commitment. Present for interoperability, since an EVM contract can verify +the same root, and for audit, since a third party can check the published set matches the +claim. It is not a storage compression device. + + +
struct Root has copy, drop, store
+
+ + + +
+Fields + + +
+
+digest: vector<u8> +
+
+ +
+
+leaf_count: u64 +
+
+ +
+
+published_at_secs: u64 +
+
+ +
+
+issuer_id: u16 +
+
+ +
+
+ + +
+ + + +## Struct `Issuer` + +A registered issuer. pubkey is used only by the permissionless relay path. + + +
struct Issuer has copy, drop, store
+
+ + + +
+Fields + + +
+
+id: u16 +
+
+ +
+
+pubkey: vector<u8> +
+
+ +
+
+active: bool +
+
+ +
+
+ + +
+ + + +## Resource `Source` + +Configuration and governance. Mutable, and deliberately never read by the check path. + + +
struct Source has key
+
+ + + +
+Fields + + +
+
+admins: vector<address> +
+
+ +
+
+issuers: vector<address> +
+
+ +
+
+sentinels: vector<address> +
+
+ +
+
+removers: vector<address> +
+
+ +
+
+guardians: vector<address> +
+
+ +
+
+issuer_info: table::Table<address, attestation::Issuer> +
+
+ +
+
+issuer_by_id: table::Table<u16, address> +
+
+ +
+
+next_issuer_id: u16 +
+
+ +
+
+paused: bool +
+
+ +
+
+root_epoch: u64 +
+
+ +
+
+roots: table::Table<u64, attestation::Root> +
+
+ +
+
+signer_cap: account::SignerCapability +
+
+ +
+
+ + +
+ + + +## Resource `Facts` + +Facts. This resource is written once at creation and never again: only its table ENTRIES +change, and each entry is its own state key. That is what keeps gated transactions from +conflicting with one another under Block-STM. + + +
struct Facts has key
+
+ + + +
+Fields + + +
+
+subjects: table::Table<address, attestation::Record> +
+
+ +
+
+denied: table::Table<address, attestation::DenyEntry> +
+
+ +
+
+nullifiers: table::Table<vector<u8>, address> +
+
+ +
+
+issuer_epochs: table::Table<u16, u64> +
+
+ +
+
+floor_epoch: table::Table<u8, u64> +
+
+ +
+
+ + +
+ + + +## Struct `CreateSource` + + + +
#[event]
+struct CreateSource has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+deployer: address +
+
+ +
+
+admins: vector<address> +
+
+ +
+
+issuers: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `AddMembers` + + + +
#[event]
+struct AddMembers has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+role: u8 +
+
+ +
+
+members: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `RemoveMembers` + + + +
#[event]
+struct RemoveMembers has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+role: u8 +
+
+ +
+
+members: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `RegisterIssuer` + + + +
#[event]
+struct RegisterIssuer has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+issuer: address +
+
+ +
+
+id: u16 +
+
+ +
+
+ + +
+ + + +## Struct `RotateIssuerKey` + + + +
#[event]
+struct RotateIssuerKey has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+id: u16 +
+
+ +
+
+ + +
+ + + +## Struct `BumpIssuerEpoch` + + + +
#[event]
+struct BumpIssuerEpoch has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+id: u16 +
+
+ +
+
+epoch: u64 +
+
+ +
+
+ + +
+ + + +## Struct `SetFloorEpoch` + + + +
#[event]
+struct SetFloorEpoch has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+epoch: u64 +
+
+ +
+
+ + +
+ + + +## Struct `RecordFact` + + + +
#[event]
+struct RecordFact has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+subject: address +
+
+ +
+
+state: u8 +
+
+ +
+
+level: u8 +
+
+ +
+
+issuer_id: u16 +
+
+ +
+
+expires_at_secs: u64 +
+
+ +
+
+reason: u16 +
+
+ +
+
+ + +
+ + + +## Struct `SetAttribute` + + + +
#[event]
+struct SetAttribute has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+subject: address +
+
+ +
+
+key: u16 +
+
+ +
+
+ + +
+ + + +## Struct `PublishRoot` + + + +
#[event]
+struct PublishRoot has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+epoch: u64 +
+
+ +
+
+digest: vector<u8> +
+
+ +
+
+leaf_count: u64 +
+
+ +
+
+ + +
+ + + +## Struct `Deny` + + + +
#[event]
+struct Deny has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+subject: address +
+
+ +
+
+reason: u16 +
+
+ +
+
+effective_at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `Undeny` + + + +
#[event]
+struct Undeny has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+subject: address +
+
+ +
+
+ + +
+ + + +## Struct `SetPaused` + + + +
#[event]
+struct SetPaused has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+paused: bool +
+
+ +
+
+ + +
+ + + +## Constants + + + + +Required length of a 32-byte digest, nullifier or root. + + +
const DIGEST_LENGTH: u64 = 32;
+
+ + + + + +Domain separator for the message an issuer signs for the permissionless relay path. Keeps a +relay attestation from being reinterpreted as any other signed payload. + + +
const DOMAIN_ATTESTATION: vector<u8> = [97, 112, 116, 111, 115, 95, 102, 114, 97, 109, 101, 119, 111, 114, 107, 58, 58, 97, 116, 116, 101, 115, 116, 97, 116, 105, 111, 110, 58, 58, 65, 84, 84, 69, 83, 84];
+
+ + + + + +Domain separator used when deriving the resource account seed, to avoid collisions with +other modules that create resource accounts. + + +
const DOMAIN_SEPARATOR: vector<u8> = [97, 112, 116, 111, 115, 95, 102, 114, 97, 109, 101, 119, 111, 114, 107, 58, 58, 97, 116, 116, 101, 115, 116, 97, 116, 105, 111, 110];
+
+ + + + + +Specified account is not an attestation source. + + +
const EACCOUNT_NOT_SOURCE: u64 = 1;
+
+ + + + + +The attribute key is in the reserved range but is not a known vocabulary key. + + +
const EBAD_ATTRIBUTE: u64 = 22;
+
+ + + + + +The issuer signature over the relayed attestation did not verify. + + +
const EBAD_SIGNATURE: u64 = 16;
+
+ + + + + +The batch exceeds MAX_BATCH. + + +
const EBATCH_TOO_LARGE: u64 = 13;
+
+ + + + + +An issuer is already registered under this address. + + +
const EDUPLICATE_ISSUER: u64 = 15;
+
+ + + + + +A role list cannot contain duplicate addresses. + + +
const EDUPLICATE_MEMBER: u64 = 8;
+
+ + + + + +The provided digest, nullifier or root must be exactly 32 bytes. + + +
const EINVALID_BYTES_LENGTH: u64 = 23;
+
+ + + + + +The record's current state does not allow this lifecycle change. + + +
const EINVALID_TRANSITION: u64 = 25;
+
+ + + + + +Batch argument vectors have differing lengths. + + +
const ELENGTH_MISMATCH: u64 = 12;
+
+ + + + + +The caller is not an admin. + + +
const ENOT_ADMIN: u64 = 2;
+
+ + + + + +A source must have at least one admin. + + +
const ENOT_ENOUGH_ADMINS: u64 = 10;
+
+ + + + + +The caller is not a guardian. + + +
const ENOT_GUARDIAN: u64 = 6;
+
+ + + + + +The caller is not a registered, active issuer. + + +
const ENOT_ISSUER: u64 = 3;
+
+ + + + + +A newer attestation has already been recorded for this subject. + + +
const ENOT_MONOTONIC: u64 = 18;
+
+ + + + + +The caller is not a remover. + + +
const ENOT_REMOVER: u64 = 5;
+
+ + + + + +The caller is not a sentinel. + + +
const ENOT_SENTINEL: u64 = 4;
+
+ + + + + +This nullifier is already bound to a different subject. + + +
const ENULLIFIER_BOUND: u64 = 20;
+
+ + + + + +Writes are paused on this source. + + +
const EPAUSED: u64 = 7;
+
+ + + + + +No record exists for this subject. + + +
const ERECORD_NOT_FOUND: u64 = 21;
+
+ + + + + +No root has been published for the requested epoch. + + +
const EROOT_NOT_FOUND: u64 = 24;
+
+ + + + + +The source account itself cannot hold a role. + + +
const ESELF_CANNOT_BE_MEMBER: u64 = 9;
+
+ + + + + +The attestation names an issuer epoch other than the issuer's current one. + + +
const ESTALE_EPOCH: u64 = 17;
+
+ + + + + +The subject is denied, so no positive fact may be written for it. + + +
const ESUBJECT_DENIED: u64 = 19;
+
+ + + + + +No issuer is registered under the given address or id. + + +
const EUNKNOWN_ISSUER: u64 = 14;
+
+ + + + + +Removing these admins would leave the source with zero admins. + + +
const EWOULD_REMOVE_ALL_ADMINS: u64 = 11;
+
+ + + + + +Largest number of attributes retained per subject. + + +
const MAX_ATTRS: u64 = 32;
+
+ + + + + +Largest number of subjects one batch call may touch. + + +
const MAX_BATCH: u64 = 1000;
+
+ + + + + +Largest number of change entries retained per subject. Older entries are dropped. + + +
const MAX_HISTORY: u64 = 32;
+
+ + + + + +Expiry value meaning the fact never expires on its own. + + +
const NEVER_EXPIRES: u64 = 18446744073709551615;
+
+ + + + + +Required length of an ed25519 public key. + + +
const PUBKEY_LENGTH: u64 = 32;
+
+ + + + + +Attribute keys below this value are reserved for the published vocabulary. + + +
const RESERVED_ATTR_KEYS: u16 = 1024;
+
+ + + + + + + +
const ROLE_ADMIN: u8 = 0;
+
+ + + + + + + +
const ROLE_GUARDIAN: u8 = 4;
+
+ + + + + + + +
const ROLE_ISSUER: u8 = 1;
+
+ + + + + + + +
const ROLE_REMOVER: u8 = 3;
+
+ + + + + + + +
const ROLE_SENTINEL: u8 = 2;
+
+ + + + + +Required length of an ed25519 signature. + + +
const SIGNATURE_LENGTH: u64 = 64;
+
+ + + + + +The source asserts this subject currently qualifies. + + +
const STATE_ACTIVE: u8 = 1;
+
+ + + + + +No record exists. The default for every address. + + +
const STATE_NONE: u8 = 0;
+
+ + + + + +Terminal for this record; requires re-issuance rather than un-revocation. + + +
const STATE_REVOKED: u8 = 3;
+
+ + + + + +Temporarily withheld, reversible by the issuer. + + +
const STATE_SUSPENDED: u8 = 2;
+
+ + + + + +Published version of this module's interface. + + +
const VERSION: u64 = 1;
+
+ + + + + +## Function `get_next_source_address` + +Return the predicted address for the next source deployed by the given account. The +deployer authorizes resource-account creation but gains no role unless it is listed in the +role arguments to create. + + +
#[view]
+public fun get_next_source_address(deployer: address): address
+
+ + + +
+Implementation + + +
public fun get_next_source_address(deployer: address): address {
+    let owner_nonce = account::get_sequence_number(deployer);
+    create_resource_address(&deployer, create_source_seed(to_bytes(&owner_nonce)))
+}
+
+ + + +
+ + + +## Function `is_verified` + +The single mandatory conformance function: does this source currently vouch for this +subject at all. Denial, expiry and epoch staleness are all accounted for. + + +
#[view]
+public fun is_verified(source: address, subject: address): bool
+
+ + + +
+Implementation + + +
public fun is_verified(source: address, subject: address): bool acquires Facts {
+    let (active, _) = active_with_level(source, subject);
+    active
+}
+
+ + + +
+ + + +## Function `state_of` + +Lifecycle state as the check path sees it, so an expired or stale record reads as +STATE_NONE and a denied subject reads as STATE_REVOKED. + + +
#[view]
+public fun state_of(source: address, subject: address): u8
+
+ + + +
+Implementation + + +
public fun state_of(source: address, subject: address): u8 acquires Facts {
+    let (active, _) = active_with_level(source, subject);
+    if (active) {
+        return STATE_ACTIVE
+    };
+    let facts = &Facts[source];
+    if (is_denied_internal(facts, subject)) {
+        return STATE_REVOKED
+    };
+    if (!table::contains(&facts.subjects, subject)) {
+        return STATE_NONE
+    };
+    let record = table::borrow(&facts.subjects, subject);
+    if (record.state == STATE_ACTIVE) {
+        // Active but not usable, so expired or stale.
+        return STATE_NONE
+    };
+    record.state
+}
+
+ + + +
+ + + +## Function `level_of` + +Tier of a currently usable fact, or 0 when there is none. + + +
#[view]
+public fun level_of(source: address, subject: address): u8
+
+ + + +
+Implementation + + +
public fun level_of(source: address, subject: address): u8 acquires Facts {
+    let (_, level) = active_with_level(source, subject);
+    level
+}
+
+ + + +
+ + + +## Function `active_with_level` + +Whether this source currently vouches for the subject, and at what level, in one pass. +This is what aptos_framework::attestation_policy calls. Not a #[view] returning two +values by design: callers that want one value use is_verified or level_of. + +Order is load-bearing. Denial is checked before anything else and cannot be overridden. + + +
public fun active_with_level(source: address, subject: address): (bool, u8)
+
+ + + +
+Implementation + + +
public fun active_with_level(source: address, subject: address): (bool, u8) acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+
+    if (is_denied_internal(facts, subject)) {
+        return (false, 0)
+    };
+    if (!table::contains(&facts.subjects, subject)) {
+        return (false, 0)
+    };
+
+    let record = table::borrow(&facts.subjects, subject);
+    if (record.state != STATE_ACTIVE) {
+        return (false, 0)
+    };
+    if (record.expires_at_secs <= now_seconds()) {
+        return (false, 0)
+    };
+    // A bump of this issuer's epoch, or a raise of the source floor, invalidates the fact.
+    if (table::contains(&facts.issuer_epochs, record.issuer_id)
+        && record.issuer_epoch < *table::borrow(&facts.issuer_epochs, record.issuer_id)) {
+        return (false, 0)
+    };
+    if (record.issuer_epoch < *table::borrow(&facts.floor_epoch, 0)) {
+        return (false, 0)
+    };
+
+    (true, record.level)
+}
+
+ + + +
+ + + +## Function `record_of` + +The full record, including history and attributes. Aborts when there is none. + + +
#[view]
+public fun record_of(source: address, subject: address): attestation::Record
+
+ + + +
+Implementation + + +
public fun record_of(source: address, subject: address): Record acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+    assert!(
+        table::contains(&facts.subjects, subject),
+        error::not_found(ERECORD_NOT_FOUND)
+    );
+    *table::borrow(&facts.subjects, subject)
+}
+
+ + + +
+ + + +## Function `attribute_of` + +An attribute value, or an empty vector when unset. + + +
#[view]
+public fun attribute_of(source: address, subject: address, key: u16): vector<u8>
+
+ + + +
+Implementation + + +
public fun attribute_of(source: address, subject: address, key: u16): vector<u8> acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+    if (!table::contains(&facts.subjects, subject)) {
+        return vector[]
+    };
+    let attrs = &table::borrow(&facts.subjects, subject).attrs;
+    if (simple_map::contains_key(attrs, &key)) {
+        *simple_map::borrow(attrs, &key)
+    } else {
+        vector[]
+    }
+}
+
+ + + +
+ + + +## Function `is_denied` + +Whether the subject is excluded and the exclusion is in effect now. + + +
#[view]
+public fun is_denied(source: address, subject: address): bool
+
+ + + +
+Implementation + + +
public fun is_denied(source: address, subject: address): bool acquires Facts {
+    assert_source_exists(source);
+    is_denied_internal(&Facts[source], subject)
+}
+
+ + + +
+ + + +## Function `deny_reason` + +Reason code attached to an exclusion, or 0 when there is none. + + +
#[view]
+public fun deny_reason(source: address, subject: address): u16
+
+ + + +
+Implementation + + +
public fun deny_reason(source: address, subject: address): u16 acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+    if (table::contains(&facts.denied, subject)) {
+        table::borrow(&facts.denied, subject).reason
+    } else { 0 }
+}
+
+ + + +
+ + + +## Function `expires_at` + +Expiry of the stored record, or 0 when there is none. + + +
#[view]
+public fun expires_at(source: address, subject: address): u64
+
+ + + +
+Implementation + + +
public fun expires_at(source: address, subject: address): u64 acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+    if (table::contains(&facts.subjects, subject)) {
+        table::borrow(&facts.subjects, subject).expires_at_secs
+    } else { 0 }
+}
+
+ + + +
+ + + +## Function `current_root` + +Most recently published root. + + +
#[view]
+public fun current_root(source: address): attestation::Root
+
+ + + +
+Implementation + + +
public fun current_root(source: address): Root acquires Source {
+    assert_source_exists(source);
+    let config = &Source[source];
+    assert!(
+        table::contains(&config.roots, config.root_epoch),
+        error::not_found(EROOT_NOT_FOUND)
+    );
+    *table::borrow(&config.roots, config.root_epoch)
+}
+
+ + + +
+ + + +## Function `verify_membership` + +Membership of the subject in the most recently published root. OpenZeppelin shape: the +subject address and the proof, with no leaf index and no commitment argument. + + +
#[view]
+public fun verify_membership(source: address, subject: address, proof: vector<vector<u8>>): bool
+
+ + + +
+Implementation + + +
public fun verify_membership(
+    source: address, subject: address, proof: vector<vector<u8>>
+): bool acquires Source {
+    let root = current_root(source);
+    aptos_framework::merkle_proof::verify(
+        root.digest,
+        aptos_framework::merkle_proof::subject_leaf(source, subject),
+        proof
+    )
+}
+
+ + + +
+ + + +## Function `admins` + + + +
#[view]
+public fun admins(source: address): vector<address>
+
+ + + +
+Implementation + + +
public fun admins(source: address): vector<address> acquires Source {
+    assert_source_exists(source);
+    Source[source].admins
+}
+
+ + + +
+ + + +## Function `issuers` + + + +
#[view]
+public fun issuers(source: address): vector<address>
+
+ + + +
+Implementation + + +
public fun issuers(source: address): vector<address> acquires Source {
+    assert_source_exists(source);
+    Source[source].issuers
+}
+
+ + + +
+ + + +## Function `sentinels` + + + +
#[view]
+public fun sentinels(source: address): vector<address>
+
+ + + +
+Implementation + + +
public fun sentinels(source: address): vector<address> acquires Source {
+    assert_source_exists(source);
+    Source[source].sentinels
+}
+
+ + + +
+ + + +## Function `removers` + + + +
#[view]
+public fun removers(source: address): vector<address>
+
+ + + +
+Implementation + + +
public fun removers(source: address): vector<address> acquires Source {
+    assert_source_exists(source);
+    Source[source].removers
+}
+
+ + + +
+ + + +## Function `guardians` + + + +
#[view]
+public fun guardians(source: address): vector<address>
+
+ + + +
+Implementation + + +
public fun guardians(source: address): vector<address> acquires Source {
+    assert_source_exists(source);
+    Source[source].guardians
+}
+
+ + + +
+ + + +## Function `is_admin` + + + +
#[view]
+public fun is_admin(addr: address, source: address): bool
+
+ + + +
+Implementation + + +
public fun is_admin(addr: address, source: address): bool acquires Source {
+    assert_source_exists(source);
+    Source[source].admins.contains(&addr)
+}
+
+ + + +
+ + + +## Function `is_issuer` + + + +
#[view]
+public fun is_issuer(addr: address, source: address): bool
+
+ + + +
+Implementation + + +
public fun is_issuer(addr: address, source: address): bool acquires Source {
+    assert_source_exists(source);
+    Source[source].issuers.contains(&addr)
+}
+
+ + + +
+ + + +## Function `issuer_id_of` + +Stable id assigned to an issuer at registration. Aborts when unregistered. + + +
#[view]
+public fun issuer_id_of(source: address, issuer: address): u16
+
+ + + +
+Implementation + + +
public fun issuer_id_of(source: address, issuer: address): u16 acquires Source {
+    assert_source_exists(source);
+    let config = &Source[source];
+    assert!(
+        table::contains(&config.issuer_info, issuer),
+        error::not_found(EUNKNOWN_ISSUER)
+    );
+    table::borrow(&config.issuer_info, issuer).id
+}
+
+ + + +
+ + + +## Function `issuer_epoch_of` + +Effective epoch of an issuer: the larger of its own counter and the source floor. Facts +written below it are no longer usable, and new writes by that issuer are stamped with it. +Issuer id 0 is the zkTLS enrollment cohort, which has no registered issuer. + + +
#[view]
+public fun issuer_epoch_of(source: address, issuer_id: u16): u64
+
+ + + +
+Implementation + + +
public fun issuer_epoch_of(source: address, issuer_id: u16): u64 acquires Facts {
+    assert_source_exists(source);
+    let facts = &Facts[source];
+    let own =
+        if (table::contains(&facts.issuer_epochs, issuer_id)) {
+            *table::borrow(&facts.issuer_epochs, issuer_id)
+        } else { 0 };
+    let floor = *table::borrow(&facts.floor_epoch, 0);
+    if (own > floor) { own } else { floor }
+}
+
+ + + +
+ + + +## Function `floor_epoch` + + + +
#[view]
+public fun floor_epoch(source: address): u64
+
+ + + +
+Implementation + + +
public fun floor_epoch(source: address): u64 acquires Facts {
+    assert_source_exists(source);
+    *table::borrow(&Facts[source].floor_epoch, 0)
+}
+
+ + + +
+ + + +## Function `is_paused` + + + +
#[view]
+public fun is_paused(source: address): bool
+
+ + + +
+Implementation + + +
public fun is_paused(source: address): bool acquires Source {
+    assert_source_exists(source);
+    Source[source].paused
+}
+
+ + + +
+ + + +## Function `is_source` + +Whether an address is an attestation source. Consulted by attestation_policy at staging +time, so a policy cannot be configured to name a source that does not exist and then abort +for every subject at evaluation time. + + +
#[view]
+public fun is_source(source: address): bool
+
+ + + +
+Implementation + + +
public fun is_source(source: address): bool {
+    exists<Source>(source) && exists<Facts>(source)
+}
+
+ + + +
+ + + +## Function `standard_version` + + + +
#[view]
+public fun standard_version(): u64
+
+ + + +
+Implementation + + +
public fun standard_version(): u64 {
+    VERSION
+}
+
+ + + +
+ + + +## Function `attestation_message` + +The message an issuer signs for the permissionless relay path. Published so an issuing +service can be implemented in any language without reading this module. + + +
#[view]
+public fun attestation_message(source: address, subject: address, issuer_id: u16, issuer_epoch: u64, level: u8, expires_at_secs: u64, issued_at_secs: u64, nullifier: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
public fun attestation_message(
+    source: address,
+    subject: address,
+    issuer_id: u16,
+    issuer_epoch: u64,
+    level: u8,
+    expires_at_secs: u64,
+    issued_at_secs: u64,
+    nullifier: vector<u8>
+): vector<u8> {
+    let message = vector[];
+    message.append(DOMAIN_ATTESTATION);
+    message.append(to_bytes(&chain_id::get()));
+    message.append(to_bytes(&source));
+    message.append(to_bytes(&subject));
+    message.append(to_bytes(&issuer_id));
+    message.append(to_bytes(&issuer_epoch));
+    message.append(to_bytes(&level));
+    message.append(to_bytes(&expires_at_secs));
+    message.append(to_bytes(&issued_at_secs));
+    message.append(to_bytes(&nullifier));
+    message
+}
+
+ + + +
+ + + +## Function `create` + +Create a new attestation source. The deployer only authorizes resource-account creation and +pays gas; it gains no role unless listed in the role arguments. + +@param deployer Signer that authorizes resource-account creation and pays gas. +@param admins Addresses allowed to configure. At least one, no duplicates, not the source. +@param issuers Addresses allowed to write facts. May be empty and filled in later. +@param sentinels Addresses allowed to add denials only. May be empty. +@param removers Addresses allowed to remove denials only. May be empty. +@param guardians Addresses allowed to pause writes. May be empty. +@abort If a list has duplicates, names the source itself, or there is no admin. + + +
public entry fun create(deployer: &signer, admins: vector<address>, issuers: vector<address>, sentinels: vector<address>, removers: vector<address>, guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun create(
+    deployer: &signer,
+    admins: vector<address>,
+    issuers: vector<address>,
+    sentinels: vector<address>,
+    removers: vector<address>,
+    guardians: vector<address>
+) {
+    let (source_signer, source_signer_cap) = create_source_account(deployer);
+    create_source_internal(
+        &source_signer,
+        address_of(deployer),
+        admins,
+        issuers,
+        sentinels,
+        removers,
+        guardians,
+        source_signer_cap
+    );
+}
+
+ + + +
+ + + +## Function `create_source_internal` + + + +
fun create_source_internal(source_account: &signer, deployer: address, admins: vector<address>, issuers: vector<address>, sentinels: vector<address>, removers: vector<address>, guardians: vector<address>, signer_cap: account::SignerCapability)
+
+ + + +
+Implementation + + +
fun create_source_internal(
+    source_account: &signer,
+    deployer: address,
+    admins: vector<address>,
+    issuers: vector<address>,
+    sentinels: vector<address>,
+    removers: vector<address>,
+    guardians: vector<address>,
+    signer_cap: SignerCapability
+) {
+    let source_address = address_of(source_account);
+    assert!(admins.length() >= 1, error::invalid_argument(ENOT_ENOUGH_ADMINS));
+    validate_members(&admins, source_address);
+    validate_members(&issuers, source_address);
+    validate_members(&sentinels, source_address);
+    validate_members(&removers, source_address);
+    validate_members(&guardians, source_address);
+
+    let floor_epoch = table::new<u8, u64>();
+    table::add(&mut floor_epoch, 0, 0);
+
+    move_to(
+        source_account,
+        Source {
+            admins,
+            issuers,
+            sentinels,
+            removers,
+            guardians,
+            issuer_info: table::new<address, Issuer>(),
+            issuer_by_id: table::new<u16, address>(),
+            next_issuer_id: 1,
+            paused: false,
+            root_epoch: 0,
+            roots: table::new<u64, Root>(),
+            signer_cap
+        }
+    );
+    move_to(
+        source_account,
+        Facts {
+            subjects: table::new<address, Record>(),
+            denied: table::new<address, DenyEntry>(),
+            nullifiers: table::new<vector<u8>, address>(),
+            issuer_epochs: table::new<u16, u64>(),
+            floor_epoch
+        }
+    );
+
+    emit(CreateSource { source: source_address, deployer, admins, issuers });
+}
+
+ + + +
+ + + +## Function `add_admins` + +Add admins. + + +
public entry fun add_admins(admin: &signer, source: address, new_admins: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_admins(
+    admin: &signer, source: address, new_admins: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    add_members(&mut config.admins, &new_admins, source);
+    emit(AddMembers { source, role: ROLE_ADMIN, members: new_admins });
+}
+
+ + + +
+ + + +## Function `remove_admins` + +Remove admins. A source may never be left with zero admins. + + +
public entry fun remove_admins(admin: &signer, source: address, old_admins: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_admins(
+    admin: &signer, source: address, old_admins: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    remove_members(&mut config.admins, &old_admins);
+    assert!(
+        config.admins.length() >= 1,
+        error::invalid_state(EWOULD_REMOVE_ALL_ADMINS)
+    );
+    emit(RemoveMembers { source, role: ROLE_ADMIN, members: old_admins });
+}
+
+ + + +
+ + + +## Function `add_issuers` + + + +
public entry fun add_issuers(admin: &signer, source: address, new_issuers: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_issuers(
+    admin: &signer, source: address, new_issuers: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    add_members(&mut config.issuers, &new_issuers, source);
+    emit(AddMembers { source, role: ROLE_ISSUER, members: new_issuers });
+}
+
+ + + +
+ + + +## Function `remove_issuers` + + + +
public entry fun remove_issuers(admin: &signer, source: address, old_issuers: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_issuers(
+    admin: &signer, source: address, old_issuers: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    remove_members(&mut config.issuers, &old_issuers);
+    emit(RemoveMembers { source, role: ROLE_ISSUER, members: old_issuers });
+}
+
+ + + +
+ + + +## Function `add_sentinels` + + + +
public entry fun add_sentinels(admin: &signer, source: address, new_sentinels: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_sentinels(
+    admin: &signer, source: address, new_sentinels: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    add_members(&mut config.sentinels, &new_sentinels, source);
+    emit(AddMembers { source, role: ROLE_SENTINEL, members: new_sentinels });
+}
+
+ + + +
+ + + +## Function `remove_sentinels` + + + +
public entry fun remove_sentinels(admin: &signer, source: address, old_sentinels: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_sentinels(
+    admin: &signer, source: address, old_sentinels: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    remove_members(&mut config.sentinels, &old_sentinels);
+    emit(RemoveMembers { source, role: ROLE_SENTINEL, members: old_sentinels });
+}
+
+ + + +
+ + + +## Function `add_removers` + + + +
public entry fun add_removers(admin: &signer, source: address, new_removers: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_removers(
+    admin: &signer, source: address, new_removers: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    add_members(&mut config.removers, &new_removers, source);
+    emit(AddMembers { source, role: ROLE_REMOVER, members: new_removers });
+}
+
+ + + +
+ + + +## Function `remove_removers` + + + +
public entry fun remove_removers(admin: &signer, source: address, old_removers: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_removers(
+    admin: &signer, source: address, old_removers: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    remove_members(&mut config.removers, &old_removers);
+    emit(RemoveMembers { source, role: ROLE_REMOVER, members: old_removers });
+}
+
+ + + +
+ + + +## Function `add_guardians` + + + +
public entry fun add_guardians(admin: &signer, source: address, new_guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_guardians(
+    admin: &signer, source: address, new_guardians: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    add_members(&mut config.guardians, &new_guardians, source);
+    emit(AddMembers { source, role: ROLE_GUARDIAN, members: new_guardians });
+}
+
+ + + +
+ + + +## Function `remove_guardians` + + + +
public entry fun remove_guardians(admin: &signer, source: address, old_guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_guardians(
+    admin: &signer, source: address, old_guardians: vector<address>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    let config = &mut Source[source];
+    remove_members(&mut config.guardians, &old_guardians);
+    emit(RemoveMembers { source, role: ROLE_GUARDIAN, members: old_guardians });
+}
+
+ + + +
+ + + +## Function `pause` + +Pause writes. Never changes the answer is_verified gives. + + +
public entry fun pause(guardian: &signer, source: address)
+
+ + + +
+Implementation + + +
public entry fun pause(guardian: &signer, source: address) acquires Source {
+    set_paused(guardian, source, true);
+}
+
+ + + +
+ + + +## Function `unpause` + + + +
public entry fun unpause(guardian: &signer, source: address)
+
+ + + +
+Implementation + + +
public entry fun unpause(guardian: &signer, source: address) acquires Source {
+    set_paused(guardian, source, false);
+}
+
+ + + +
+ + + +## Function `set_paused` + + + +
fun set_paused(guardian: &signer, source: address, paused: bool)
+
+ + + +
+Implementation + + +
fun set_paused(guardian: &signer, source: address, paused: bool) acquires Source {
+    assert_source_exists(source);
+    assert!(
+        Source[source].guardians.contains(&address_of(guardian)),
+        error::permission_denied(ENOT_GUARDIAN)
+    );
+    Source[source].paused = paused;
+    emit(SetPaused { source, paused });
+}
+
+ + + +
+ + + +## Function `register_issuer` + +Register an issuer and assign it a stable id. The public key is used only by the +permissionless relay path, and may be empty for an issuer that only writes directly. + +@param admin An admin of the source. +@param source The source address. +@param issuer Address to register. +@param pubkey 32-byte ed25519 public key, or empty. +@abort If the issuer is already registered or the key length is wrong. + + +
public entry fun register_issuer(admin: &signer, source: address, issuer: address, pubkey: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun register_issuer(
+    admin: &signer, source: address, issuer: address, pubkey: vector<u8>
+) acquires Source, Facts {
+    assert_admin(source, address_of(admin));
+    assert!(
+        pubkey.is_empty() || pubkey.length() == PUBKEY_LENGTH,
+        error::invalid_argument(EINVALID_BYTES_LENGTH)
+    );
+    let config = &mut Source[source];
+    assert!(
+        !table::contains(&config.issuer_info, issuer),
+        error::already_exists(EDUPLICATE_ISSUER)
+    );
+    let id = config.next_issuer_id;
+    config.next_issuer_id = id + 1;
+    table::add(&mut config.issuer_info, issuer, Issuer { id, pubkey, active: true });
+    table::add(&mut config.issuer_by_id, id, issuer);
+    if (!config.issuers.contains(&issuer)) {
+        config.issuers.push_back(issuer);
+    };
+    table::add(&mut Facts[source].issuer_epochs, id, 0);
+    emit(RegisterIssuer { source, issuer, id });
+}
+
+ + + +
+ + + +## Function `rotate_issuer_key` + +Replace an issuer's signing key. Facts already written stay valid; use +bump_issuer_epoch to invalidate them. + + +
public entry fun rotate_issuer_key(admin: &signer, source: address, issuer: address, new_pubkey: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun rotate_issuer_key(
+    admin: &signer, source: address, issuer: address, new_pubkey: vector<u8>
+) acquires Source {
+    assert_admin(source, address_of(admin));
+    assert!(
+        new_pubkey.is_empty() || new_pubkey.length() == PUBKEY_LENGTH,
+        error::invalid_argument(EINVALID_BYTES_LENGTH)
+    );
+    let config = &mut Source[source];
+    assert!(
+        table::contains(&config.issuer_info, issuer),
+        error::not_found(EUNKNOWN_ISSUER)
+    );
+    let info = table::borrow_mut(&mut config.issuer_info, issuer);
+    info.pubkey = new_pubkey;
+    emit(RotateIssuerKey { source, id: info.id });
+}
+
+ + + +
+ + + +## Function `bump_issuer_epoch` + +Invalidate every fact an issuer has written, in one write. This is the remedy for a +compromised issuer key and it is O(1) in the size of the cohort. Issuer id 0 bumps the +zkTLS enrollment cohort, which has no registered issuer. The new epoch is one above the +issuer's effective epoch, so a bump always takes effect even below a raised floor. + + +
public entry fun bump_issuer_epoch(admin: &signer, source: address, issuer_id: u16)
+
+ + + +
+Implementation + + +
public entry fun bump_issuer_epoch(
+    admin: &signer, source: address, issuer_id: u16
+) acquires Source, Facts {
+    assert_admin(source, address_of(admin));
+    assert!(
+        issuer_id == 0 || table::contains(&Source[source].issuer_by_id, issuer_id),
+        error::not_found(EUNKNOWN_ISSUER)
+    );
+    let epoch = issuer_epoch_of(source, issuer_id) + 1;
+    table::upsert(&mut Facts[source].issuer_epochs, issuer_id, epoch);
+    emit(BumpIssuerEpoch { source, id: issuer_id, epoch });
+}
+
+ + + +
+ + + +## Function `set_floor_epoch` + +Invalidate every fact written below the given epoch, across all issuers including the zkTLS +cohort. Strictly increasing, so lowering the floor can never resurrect a fact. + + +
public entry fun set_floor_epoch(admin: &signer, source: address, epoch: u64)
+
+ + + +
+Implementation + + +
public entry fun set_floor_epoch(
+    admin: &signer, source: address, epoch: u64
+) acquires Source, Facts {
+    assert_admin(source, address_of(admin));
+    let floor = table::borrow_mut(&mut Facts[source].floor_epoch, 0);
+    assert!(epoch > *floor, error::invalid_argument(ENOT_MONOTONIC));
+    *floor = epoch;
+    emit(SetFloorEpoch { source, epoch });
+}
+
+ + + +
+ + + +## Function `issue_batch` + +Record or refresh facts for many subjects at once. + +@param issuer A registered, active issuer of the source. +@param source The source address. +@param subjects Subjects to write. +@param levels Tier per subject, same length as subjects. +@param expires_at_secs Expiry per subject, same length as subjects. +@param reason Reason code recorded in each subject's history. +@abort If paused, the caller is not an issuer, the lengths differ, the batch is too large, +or any subject is denied. + + +
public entry fun issue_batch(issuer: &signer, source: address, subjects: vector<address>, levels: vector<u8>, expires_at_secs: vector<u64>, reason: u16)
+
+ + + +
+Implementation + + +
public entry fun issue_batch(
+    issuer: &signer,
+    source: address,
+    subjects: vector<address>,
+    levels: vector<u8>,
+    expires_at_secs: vector<u64>,
+    reason: u16
+) acquires Source, Facts {
+    let count = subjects.length();
+    assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE));
+    assert!(
+        count == levels.length() && count == expires_at_secs.length(),
+        error::invalid_argument(ELENGTH_MISMATCH)
+    );
+    let (issuer_id, issuer_epoch) = assert_issuer(source, address_of(issuer));
+    let index = 0;
+    while (index < count) {
+        record_fact(
+            source,
+            subjects[index],
+            STATE_ACTIVE,
+            levels[index],
+            issuer_id,
+            issuer_epoch,
+            expires_at_secs[index],
+            now_seconds(),
+            reason,
+            vector[]
+        );
+        index += 1;
+    };
+}
+
+ + + +
+ + + +## Function `revoke_batch` + +Move many subjects to STATE_REVOKED. A subject that is already revoked is skipped, so one +stale entry cannot brick a batch. + + +
public entry fun revoke_batch(issuer: &signer, source: address, subjects: vector<address>, reason: u16)
+
+ + + +
+Implementation + + +
public entry fun revoke_batch(
+    issuer: &signer, source: address, subjects: vector<address>, reason: u16
+) acquires Source, Facts {
+    let count = subjects.length();
+    assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE));
+    let (issuer_id, _) = assert_issuer(source, address_of(issuer));
+    let index = 0;
+    while (index < count) {
+        transition(source, subjects[index], STATE_REVOKED, issuer_id, reason);
+        index += 1;
+    };
+}
+
+ + + +
+ + + +## Function `suspend` + +Temporarily withhold an active subject's fact, reversibly. + + +
public entry fun suspend(issuer: &signer, source: address, subject: address, reason: u16)
+
+ + + +
+Implementation + + +
public entry fun suspend(
+    issuer: &signer, source: address, subject: address, reason: u16
+) acquires Source, Facts {
+    let (issuer_id, _) = assert_issuer(source, address_of(issuer));
+    transition(source, subject, STATE_SUSPENDED, issuer_id, reason);
+}
+
+ + + +
+ + + +## Function `unsuspend` + +Reverse a suspension. Only a suspended record can be reactivated: a revoked one needs +re-issuance, and a denied subject cannot be reactivated at all. The record keeps the issuer +and epoch it was issued under, so a fact killed by an epoch bump stays dead. + + +
public entry fun unsuspend(issuer: &signer, source: address, subject: address, reason: u16)
+
+ + + +
+Implementation + + +
public entry fun unsuspend(
+    issuer: &signer, source: address, subject: address, reason: u16
+) acquires Source, Facts {
+    let (issuer_id, _) = assert_issuer(source, address_of(issuer));
+    transition(source, subject, STATE_ACTIVE, issuer_id, reason);
+}
+
+ + + +
+ + + +## Function `set_attribute` + +Set an attribute on a subject. Every attribute written is public forever, so a source that +writes jurisdiction data has made a disclosure decision on behalf of its subjects. + + +
public entry fun set_attribute(issuer: &signer, source: address, subject: address, key: u16, value: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun set_attribute(
+    issuer: &signer, source: address, subject: address, key: u16, value: vector<u8>
+) acquires Source, Facts {
+    assert_not_paused(source);
+    assert_issuer(source, address_of(issuer));
+    let facts = &mut Facts[source];
+    assert!(
+        table::contains(&facts.subjects, subject),
+        error::not_found(ERECORD_NOT_FOUND)
+    );
+    let record = table::borrow_mut(&mut facts.subjects, subject);
+    if (simple_map::contains_key(&record.attrs, &key)) {
+        *simple_map::borrow_mut(&mut record.attrs, &key) = value;
+    } else {
+        assert!(
+            simple_map::length(&record.attrs) < MAX_ATTRS,
+            error::invalid_state(EBAD_ATTRIBUTE)
+        );
+        simple_map::add(&mut record.attrs, key, value);
+    };
+    emit(SetAttribute { source, subject, key });
+}
+
+ + + +
+ + + +## Function `remove_attribute` + + + +
public entry fun remove_attribute(issuer: &signer, source: address, subject: address, key: u16)
+
+ + + +
+Implementation + + +
public entry fun remove_attribute(
+    issuer: &signer, source: address, subject: address, key: u16
+) acquires Source, Facts {
+    assert_not_paused(source);
+    assert_issuer(source, address_of(issuer));
+    let facts = &mut Facts[source];
+    assert!(
+        table::contains(&facts.subjects, subject),
+        error::not_found(ERECORD_NOT_FOUND)
+    );
+    let record = table::borrow_mut(&mut facts.subjects, subject);
+    if (simple_map::contains_key(&record.attrs, &key)) {
+        simple_map::remove(&mut record.attrs, &key);
+    };
+}
+
+ + + +
+ + + +## Function `redeem_attestation` + +Record a fact from an attestation the issuer signed off chain. The caller need not be the +subject or the issuer. + +@param source The source address. +@param subject Subject the attestation is about. +@param issuer_id Issuer that signed. +@param issuer_epoch Must equal the issuer's current epoch, so an attestation signed before +a compromise bump is refused and one signed for a future epoch is too. +@param nullifier 32 bytes binding one real-world identity to one subject, or empty to skip. +@param signature 64-byte ed25519 signature over attestation_message. +@abort If paused, the epoch is stale, the signature fails, a newer attestation is already +recorded, the nullifier is bound elsewhere, or the subject is denied. + + +
public entry fun redeem_attestation(_relayer: &signer, source: address, subject: address, issuer_id: u16, issuer_epoch: u64, level: u8, expires_at_secs: u64, issued_at_secs: u64, nullifier: vector<u8>, signature: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun redeem_attestation(
+    _relayer: &signer,
+    source: address,
+    subject: address,
+    issuer_id: u16,
+    issuer_epoch: u64,
+    level: u8,
+    expires_at_secs: u64,
+    issued_at_secs: u64,
+    nullifier: vector<u8>,
+    signature: vector<u8>
+) acquires Source, Facts {
+    assert_not_paused(source);
+    let config = &Source[source];
+    assert!(
+        table::contains(&config.issuer_by_id, issuer_id),
+        error::not_found(EUNKNOWN_ISSUER)
+    );
+    let issuer_address = *table::borrow(&config.issuer_by_id, issuer_id);
+    // Same rule as assert_issuer: removing an issuer's role also stops its relayed
+    // attestations.
+    assert!(
+        config.issuers.contains(&issuer_address),
+        error::permission_denied(ENOT_ISSUER)
+    );
+    let info = table::borrow(&config.issuer_info, issuer_address);
+    assert!(info.active, error::invalid_state(EUNKNOWN_ISSUER));
+    assert!(
+        info.pubkey.length() == PUBKEY_LENGTH,
+        error::invalid_state(EUNKNOWN_ISSUER)
+    );
+
+    // Equality, not "at least": refuse an epoch the issuer has not entered, and refuse one
+    // signed before a bump.
+    assert!(
+        issuer_epoch == issuer_epoch_of(source, issuer_id),
+        error::invalid_state(ESTALE_EPOCH)
+    );
+
+    let message =
+        attestation_message(
+            source,
+            subject,
+            issuer_id,
+            issuer_epoch,
+            level,
+            expires_at_secs,
+            issued_at_secs,
+            nullifier
+        );
+    assert!(
+        ed25519::signature_verify_strict(
+            &ed25519::new_signature_from_bytes(signature),
+            &ed25519::new_unvalidated_public_key_from_bytes(info.pubkey),
+            message
+        ),
+        error::invalid_argument(EBAD_SIGNATURE)
+    );
+
+    // Monotonicity: a kept attestation must not be replayable to push an expiry back out.
+    assert_newer(source, subject, issued_at_secs);
+    bind_nullifier(source, subject, nullifier);
+
+    record_fact(
+        source,
+        subject,
+        STATE_ACTIVE,
+        level,
+        issuer_id,
+        issuer_epoch,
+        expires_at_secs,
+        issued_at_secs,
+        0,
+        std::aptos_hash::keccak256(message)
+    );
+}
+
+ + + +
+ + + +## Function `source_signer` + +Return a signer for the source's resource account. Restricted to the friend list, which +is the security boundary: zktls needs it to store its attestor set and template +allowlist under the source address. Nothing outside the friend list can obtain it. + + +
public(friend) fun source_signer(source: address): signer
+
+ + + +
+Implementation + + +
public(friend) fun source_signer(source: address): signer acquires Source {
+    assert_source_exists(source);
+    account::create_signer_with_capability(&Source[source].signer_cap)
+}
+
+ + + +
+ + + +## Function `record_verified_claim` + +Record a fact from a claim aptos_framework::zktls has already verified against its +attestor set. No issuer key is involved on this path at all. + + +
public(friend) fun record_verified_claim(source: address, subject: address, level: u8, expires_at_secs: u64, attestation_digest: vector<u8>, nullifier: vector<u8>)
+
+ + + +
+Implementation + + +
public(friend) fun record_verified_claim(
+    source: address,
+    subject: address,
+    level: u8,
+    expires_at_secs: u64,
+    attestation_digest: vector<u8>,
+    nullifier: vector<u8>
+) acquires Source, Facts {
+    assert_not_paused(source);
+    assert!(
+        attestation_digest.length() == DIGEST_LENGTH,
+        error::invalid_argument(EINVALID_BYTES_LENGTH)
+    );
+    bind_nullifier(source, subject, nullifier);
+    // Issuer id 0 is the zkTLS cohort, stamped with its effective epoch so a raised floor does
+    // not silently kill new enrollments and a bump of id 0 kills the whole cohort.
+    let cohort_epoch = issuer_epoch_of(source, 0);
+    record_fact(
+        source,
+        subject,
+        STATE_ACTIVE,
+        level,
+        0,
+        cohort_epoch,
+        expires_at_secs,
+        now_seconds(),
+        0,
+        attestation_digest
+    );
+}
+
+ + + +
+ + + +## Function `deny` + +Exclude a subject. Takes effect at effective_at_secs, which may be in the future so a +denial can be announced before it bites. + + +
public entry fun deny(sentinel: &signer, source: address, subject: address, reason: u16, effective_at_secs: u64)
+
+ + + +
+Implementation + + +
public entry fun deny(
+    sentinel: &signer,
+    source: address,
+    subject: address,
+    reason: u16,
+    effective_at_secs: u64
+) acquires Source, Facts {
+    assert_source_exists(source);
+    assert!(
+        Source[source].sentinels.contains(&address_of(sentinel)),
+        error::permission_denied(ENOT_SENTINEL)
+    );
+    deny_internal(source, subject, reason, effective_at_secs);
+}
+
+ + + +
+ + + +## Function `deny_batch` + +Exclude many subjects at once, with a shared reason and immediate effect. + + +
public entry fun deny_batch(sentinel: &signer, source: address, subjects: vector<address>, reason: u16)
+
+ + + +
+Implementation + + +
public entry fun deny_batch(
+    sentinel: &signer, source: address, subjects: vector<address>, reason: u16
+) acquires Source, Facts {
+    assert_source_exists(source);
+    assert!(
+        Source[source].sentinels.contains(&address_of(sentinel)),
+        error::permission_denied(ENOT_SENTINEL)
+    );
+    let count = subjects.length();
+    assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE));
+    let index = 0;
+    let now = now_seconds();
+    while (index < count) {
+        deny_internal(source, subjects[index], reason, now);
+        index += 1;
+    };
+}
+
+ + + +
+ + + +## Function `undeny` + +Remove an exclusion. Deliberately a different role from deny. + + +
public entry fun undeny(remover: &signer, source: address, subject: address)
+
+ + + +
+Implementation + + +
public entry fun undeny(
+    remover: &signer, source: address, subject: address
+) acquires Source, Facts {
+    assert_source_exists(source);
+    assert!(
+        Source[source].removers.contains(&address_of(remover)),
+        error::permission_denied(ENOT_REMOVER)
+    );
+    let denied = &mut Facts[source].denied;
+    if (table::contains(denied, subject)) {
+        table::remove(denied, subject);
+        emit(Undeny { source, subject });
+    };
+}
+
+ + + +
+ + + +## Function `deny_internal` + + + +
fun deny_internal(source: address, subject: address, reason: u16, effective_at_secs: u64)
+
+ + + +
+Implementation + + +
fun deny_internal(
+    source: address, subject: address, reason: u16, effective_at_secs: u64
+) acquires Facts {
+    // upsert, never add: a repeated denial must be idempotent rather than abort a batch.
+    table::upsert(
+        &mut Facts[source].denied,
+        subject,
+        DenyEntry { reason, effective_at_secs, added_at_secs: now_seconds() }
+    );
+    emit(Deny { source, subject, reason, effective_at_secs });
+}
+
+ + + +
+ + + +## Function `publish_root` + +Publish a set commitment for the next epoch. Rotation invalidates outstanding proofs, so +publish on a fixed low-frequency cadence: it is a privacy measure, because cohort timing +leaks, and a throughput one, because every gated transaction reads this slot. + + +
public entry fun publish_root(issuer: &signer, source: address, digest: vector<u8>, leaf_count: u64)
+
+ + + +
+Implementation + + +
public entry fun publish_root(
+    issuer: &signer, source: address, digest: vector<u8>, leaf_count: u64
+) acquires Source, Facts {
+    assert!(
+        digest.length() == DIGEST_LENGTH,
+        error::invalid_argument(EINVALID_BYTES_LENGTH)
+    );
+    let (issuer_id, _) = assert_issuer(source, address_of(issuer));
+    let config = &mut Source[source];
+    let epoch = config.root_epoch + 1;
+    config.root_epoch = epoch;
+    table::add(
+        &mut config.roots,
+        epoch,
+        Root { digest, leaf_count, published_at_secs: now_seconds(), issuer_id }
+    );
+    emit(PublishRoot { source, epoch, digest, leaf_count });
+}
+
+ + + +
+ + + +## Function `record_fact` + + + +
fun record_fact(source: address, subject: address, state: u8, level: u8, issuer_id: u16, issuer_epoch: u64, expires_at_secs: u64, issued_at_secs: u64, reason: u16, attestation_digest: vector<u8>)
+
+ + + +
+Implementation + + +
fun record_fact(
+    source: address,
+    subject: address,
+    state: u8,
+    level: u8,
+    issuer_id: u16,
+    issuer_epoch: u64,
+    expires_at_secs: u64,
+    issued_at_secs: u64,
+    reason: u16,
+    attestation_digest: vector<u8>
+) acquires Facts {
+    let now = now_seconds();
+    let facts = &mut Facts[source];
+
+    // A positive write can never overwrite, clear or ignore an exclusion.
+    assert!(
+        !table::contains(&facts.denied, subject),
+        error::invalid_state(ESUBJECT_DENIED)
+    );
+
+    let subjects = &mut facts.subjects;
+    if (table::contains(subjects, subject)) {
+        let record = table::borrow_mut(subjects, subject);
+        let change = Change {
+            at_secs: now,
+            prev_state: record.state,
+            new_state: state,
+            reason,
+            issuer_id
+        };
+        record.state = state;
+        record.level = level;
+        record.issuer_id = issuer_id;
+        record.issuer_epoch = issuer_epoch;
+        record.expires_at_secs = expires_at_secs;
+        record.issued_at_secs = issued_at_secs;
+        record.reason = reason;
+        record.attestation_digest = attestation_digest;
+        if (state == STATE_REVOKED) {
+            record.revoked_at_secs = now;
+        };
+        push_history(&mut record.history, change);
+    } else {
+        table::add(
+            subjects,
+            subject,
+            Record {
+                state,
+                level,
+                issuer_id,
+                issuer_epoch,
+                issued_at_secs,
+                expires_at_secs,
+                revoked_at_secs: if (state == STATE_REVOKED) { now } else { 0 },
+                reason,
+                attestation_digest,
+                attrs: simple_map::create<u16, vector<u8>>(),
+                history: vector[
+                    Change {
+                        at_secs: now,
+                        prev_state: STATE_NONE,
+                        new_state: state,
+                        reason,
+                        issuer_id
+                    }
+                ]
+            }
+        );
+    };
+
+    emit(RecordFact { source, subject, state, level, issuer_id, expires_at_secs, reason });
+}
+
+ + + +
+ + + +## Function `transition` + +Change the state of an existing record. Used by revoke, suspend and unsuspend, all of which +must not silently create a record. The record keeps the issuer id and epoch it was issued +under, because validity comes from the original issuance; the acting issuer is recorded in +the history entry only. + +Allowed: ACTIVE to SUSPENDED, SUSPENDED to ACTIVE, and ACTIVE or SUSPENDED to REVOKED. +Revoking an already revoked record is a silent no-op so a batch is not bricked by one entry. + + +
fun transition(source: address, subject: address, state: u8, acting_issuer_id: u16, reason: u16)
+
+ + + +
+Implementation + + +
fun transition(
+    source: address,
+    subject: address,
+    state: u8,
+    acting_issuer_id: u16,
+    reason: u16
+) acquires Facts {
+    let now = now_seconds();
+    let facts = &mut Facts[source];
+    assert!(
+        table::contains(&facts.subjects, subject),
+        error::not_found(ERECORD_NOT_FOUND)
+    );
+    let denied = table::contains(&facts.denied, subject);
+    let record = table::borrow_mut(&mut facts.subjects, subject);
+    let prev_state = record.state;
+    if (state == STATE_REVOKED) {
+        if (prev_state == STATE_REVOKED) {
+            return
+        };
+        assert!(
+            prev_state == STATE_ACTIVE || prev_state == STATE_SUSPENDED,
+            error::invalid_state(EINVALID_TRANSITION)
+        );
+    } else if (state == STATE_SUSPENDED) {
+        assert!(
+            prev_state == STATE_ACTIVE,
+            error::invalid_state(EINVALID_TRANSITION)
+        );
+    } else {
+        assert!(
+            state == STATE_ACTIVE && prev_state == STATE_SUSPENDED,
+            error::invalid_state(EINVALID_TRANSITION)
+        );
+        // Reactivation is a positive write, so it obeys the same exclusion rule as record_fact.
+        assert!(!denied, error::invalid_state(ESUBJECT_DENIED));
+    };
+    let change = Change {
+        at_secs: now,
+        prev_state,
+        new_state: state,
+        reason,
+        issuer_id: acting_issuer_id
+    };
+    record.state = state;
+    record.reason = reason;
+    if (state == STATE_REVOKED) {
+        record.revoked_at_secs = now;
+    };
+    push_history(&mut record.history, change);
+    emit(
+        RecordFact {
+            source,
+            subject,
+            state,
+            level: record.level,
+            issuer_id: acting_issuer_id,
+            expires_at_secs: record.expires_at_secs,
+            reason
+        }
+    );
+}
+
+ + + +
+ + + +## Function `create_source_account` + + + +
fun create_source_account(deployer: &signer): (signer, account::SignerCapability)
+
+ + + +
+Implementation + + +
fun create_source_account(deployer: &signer): (signer, SignerCapability) {
+    let deployer_nonce = account::get_sequence_number(address_of(deployer));
+    account::create_resource_account(
+        deployer, create_source_seed(to_bytes(&deployer_nonce))
+    )
+}
+
+ + + +
+ + + +## Function `create_source_seed` + + + +
fun create_source_seed(seed: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
fun create_source_seed(seed: vector<u8>): vector<u8> {
+    let account_seed = vector[];
+    account_seed.append(DOMAIN_SEPARATOR);
+    account_seed.append(seed);
+    account_seed
+}
+
+ + + +
+ + + +## Function `validate_members` + +Validate that a role list has no duplicates and does not name the source itself. + + +
fun validate_members(members: &vector<address>, source_address: address)
+
+ + + +
+Implementation + + +
fun validate_members(members: &vector<address>, source_address: address) {
+    let distinct: vector<address> = vector[];
+    members.for_each_ref(|member| {
+        assert!(
+            *member != source_address,
+            error::invalid_argument(ESELF_CANNOT_BE_MEMBER)
+        );
+        assert!(
+            !distinct.contains(member),
+            error::invalid_argument(EDUPLICATE_MEMBER)
+        );
+        distinct.push_back(*member);
+    });
+}
+
+ + + +
+ + + +## Function `add_members` + + + +
fun add_members(list: &mut vector<address>, new_members: &vector<address>, source_address: address)
+
+ + + +
+Implementation + + +
fun add_members(
+    list: &mut vector<address>, new_members: &vector<address>, source_address: address
+) {
+    validate_members(new_members, source_address);
+    new_members.for_each_ref(|member| {
+        assert!(
+            !list.contains(member),
+            error::invalid_argument(EDUPLICATE_MEMBER)
+        );
+        list.push_back(*member);
+    });
+}
+
+ + + +
+ + + +## Function `remove_members` + + + +
fun remove_members(list: &mut vector<address>, old_members: &vector<address>)
+
+ + + +
+Implementation + + +
fun remove_members(list: &mut vector<address>, old_members: &vector<address>) {
+    old_members.for_each_ref(|member| {
+        let (found, index) = list.index_of(member);
+        if (found) {
+            list.remove(index);
+        };
+    });
+}
+
+ + + +
+ + + +## Function `push_history` + + + +
fun push_history(history: &mut vector<attestation::Change>, change: attestation::Change)
+
+ + + +
+Implementation + + +
fun push_history(history: &mut vector<Change>, change: Change) {
+    if (history.length() >= MAX_HISTORY) {
+        history.remove(0);
+    };
+    history.push_back(change);
+}
+
+ + + +
+ + + +## Function `bind_nullifier` + + + +
fun bind_nullifier(source: address, subject: address, nullifier: vector<u8>)
+
+ + + +
+Implementation + + +
fun bind_nullifier(
+    source: address, subject: address, nullifier: vector<u8>
+) acquires Facts {
+    if (nullifier.is_empty()) {
+        return
+    };
+    assert!(
+        nullifier.length() == DIGEST_LENGTH,
+        error::invalid_argument(EINVALID_BYTES_LENGTH)
+    );
+    let nullifiers = &mut Facts[source].nullifiers;
+    if (table::contains(nullifiers, nullifier)) {
+        assert!(
+            *table::borrow(nullifiers, nullifier) == subject,
+            error::invalid_state(ENULLIFIER_BOUND)
+        );
+    } else {
+        table::add(nullifiers, nullifier, subject);
+    };
+}
+
+ + + +
+ + + +## Function `assert_newer` + + + +
fun assert_newer(source: address, subject: address, issued_at_secs: u64)
+
+ + + +
+Implementation + + +
fun assert_newer(
+    source: address, subject: address, issued_at_secs: u64
+) acquires Facts {
+    let subjects = &Facts[source].subjects;
+    if (table::contains(subjects, subject)) {
+        assert!(
+            issued_at_secs > table::borrow(subjects, subject).issued_at_secs,
+            error::invalid_argument(ENOT_MONOTONIC)
+        );
+    };
+}
+
+ + + +
+ + + +## Function `is_denied_internal` + + + +
fun is_denied_internal(facts: &attestation::Facts, subject: address): bool
+
+ + + +
+Implementation + + +
fun is_denied_internal(facts: &Facts, subject: address): bool {
+    table::contains(&facts.denied, subject)
+        && now_seconds() >= table::borrow(&facts.denied, subject).effective_at_secs
+}
+
+ + + +
+ + + +## Function `assert_source_exists` + + + +
fun assert_source_exists(source: address)
+
+ + + +
+Implementation + + +
fun assert_source_exists(source: address) {
+    assert!(exists<Source>(source), error::not_found(EACCOUNT_NOT_SOURCE));
+    assert!(exists<Facts>(source), error::not_found(EACCOUNT_NOT_SOURCE));
+}
+
+ + + +
+ + + +## Function `assert_admin` + + + +
fun assert_admin(source: address, addr: address)
+
+ + + +
+Implementation + + +
fun assert_admin(source: address, addr: address) acquires Source {
+    assert_source_exists(source);
+    assert!(
+        Source[source].admins.contains(&addr),
+        error::permission_denied(ENOT_ADMIN)
+    );
+}
+
+ + + +
+ + + +## Function `assert_not_paused` + + + +
fun assert_not_paused(source: address)
+
+ + + +
+Implementation + + +
fun assert_not_paused(source: address) acquires Source {
+    assert_source_exists(source);
+    assert!(!Source[source].paused, error::invalid_state(EPAUSED));
+}
+
+ + + +
+ + + +## Function `assert_issuer` + +Assert the caller is a registered, active issuer and return its id and current epoch. + + +
fun assert_issuer(source: address, addr: address): (u16, u64)
+
+ + + +
+Implementation + + +
fun assert_issuer(source: address, addr: address): (u16, u64) acquires Source, Facts {
+    assert_not_paused(source);
+    let config = &Source[source];
+    assert!(
+        config.issuers.contains(&addr) && table::contains(&config.issuer_info, addr),
+        error::permission_denied(ENOT_ISSUER)
+    );
+    let info = table::borrow(&config.issuer_info, addr);
+    assert!(info.active, error::permission_denied(ENOT_ISSUER));
+    (info.id, issuer_epoch_of(source, info.id))
+}
+
+ + + +
+ + + +## Specification + + + + + + +### High-level Requirements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.RequirementCriticalityImplementationEnforcement
1A denial always wins. While a denial is in effect for a subject, the source never reports that subject as verified, regardless of any fact recorded for it (INV-1).Criticalactive_with_level checks is_denied_internal before reading the subject's record and returns (false, 0) when the denial is in effect. is_verified, level_of and state_of all route through it, and attestation_policy consults is_denied before any positive rule.Formally verified via active_with_level.
2No positive write path can create, modify or clear a denial (INV-2), and no positive write can be recorded for a subject that has a denial entry.CriticalEvery positive write ends in record_fact, which aborts when the subject has a denial entry and never touches the denied table. transition refuses to reactivate a denied subject.Formally verified via record_fact and transition.
3A fact whose expiry has passed, whose epoch is below its issuer's current epoch, or whose epoch is below the source floor is never treated as active (INV-3). A floor raise or an epoch bump always takes effect, and new writes are stamped with the issuer's effective epoch so they are not born stale.Criticalactive_with_level compares the record against now_seconds(), the per-issuer epoch and the floor. issuer_epoch_of returns max(own counter, floor), which is what assert_issuer and record_verified_claim stamp and what bump_issuer_epoch increments. set_floor_epoch is strictly increasing.Formally verified via active_with_level, issuer_epoch_of, bump_issuer_epoch, set_floor_epoch and record_verified_claim.
4Pausing a source blocks writes and never changes any fact or denial, so it never changes what is_verified reports (INV-4).Highset_paused writes only Source.paused. Facts is a separate resource that the read path uses exclusively; every write path calls assert_not_paused.Formally verified via set_paused.
5Only a sentinel can add a denial and only a remover can remove one (INV-5). Neither operation touches any fact.Criticaldeny and deny_batch check the sentinel list, undeny checks the remover list, and both write only the denied table.Formally verified via deny, deny_batch and undeny.
6Lifecycle changes follow ACTIVE <-> SUSPENDED and ACTIVE or SUSPENDED -> REVOKED only. A revoked record is terminal until re-issued, and a lifecycle change never alters the issuer or epoch a fact was issued under, so it cannot resurrect a fact an epoch bump killed.Criticaltransition asserts the allowed transitions, treats a repeated revocation as a no-op, and leaves issuer_id and issuer_epoch unchanged; the acting issuer is recorded in the history entry only.Formally verified via transition.
7Configuration and facts live in separate resources, and the read path touches only Facts and per-subject table entries, so gated transactions never conflict with one another under Block-STM (INV-7).MediumFacts is published once at creation and only its table entries change afterwards. The read functions (is_verified, active_with_level, is_denied) acquire Facts only.Enforced by the acquires annotations of the read functions, which the compiler checks. Audited that no write path replaces the Facts resource itself.
8Every role list is duplicate free, never contains the source itself, and a source always has at least one admin.Highvalidate_members runs on creation and on every add, and remove_admins asserts that at least one admin remains.Formally verified via create_source_internal and remove_admins.
9Only a current issuer can write facts, directly or through the permissionless relay path. Removing an issuer's role also stops its relayed attestations.Criticalassert_issuer and redeem_attestation both require the issuer to be in Source.issuers.Formally verified via suspend and redeem_attestation.
+ + + +INV-6 of the functional specification (every keyed resource an enum with a V1 variant) was not adopted: the +module follows the plain-struct style of the rest of the framework, so it is not specified here. INV-8 and INV-9 +belong to attestation_authorization and attestation_policy respectively. + + + + +### Module-level Specification + + +
pragma verify = true;
+pragma aborts_if_is_strict = false;
+
+ + + + + + + +
fun spec_now(): u64 {
+   aptos_framework::timestamp::spec_now_seconds()
+}
+
+ + + + + + + +
fun spec_has_time(): bool {
+   exists<aptos_framework::timestamp::CurrentTimeMicroseconds>(@aptos_framework)
+}
+
+ + + + + + + +
fun spec_is_source(source: address): bool {
+   exists<Source>(source) && exists<Facts>(source)
+}
+
+ + +A denial is in effect for the subject. + + + + + +
fun spec_is_denied(source: address, subject: address): bool {
+   let denied = global<Facts>(source).denied;
+   table::spec_contains(denied, subject)
+       && spec_now() >= table::spec_get(denied, subject).effective_at_secs
+}
+
+ + + + + + + +
fun spec_floor(source: address): u64 {
+   table::spec_get(global<Facts>(source).floor_epoch, 0)
+}
+
+ + + + + + + +
fun spec_own_epoch(source: address, issuer_id: u16): u64 {
+   let epochs = global<Facts>(source).issuer_epochs;
+   if (table::spec_contains(epochs, issuer_id)) {
+       table::spec_get(epochs, issuer_id)
+   } else { 0 }
+}
+
+ + +Effective epoch of an issuer: the larger of its own counter and the source floor. + + + + + +
fun spec_effective_epoch(source: address, issuer_id: u16): u64 {
+   let own = spec_own_epoch(source, issuer_id);
+   let floor = spec_floor(source);
+   if (own > floor) { own } else { floor }
+}
+
+ + + + + + + +
schema SourceExistsAbortsIf {
+    source: address;
+    aborts_if !spec_is_source(source);
+}
+
+ + + + + +### Function `active_with_level` + + +
public fun active_with_level(source: address, subject: address): (bool, u8)
+
+ + + + +
pragma aborts_if_is_partial;
+include SourceExistsAbortsIf;
+let facts = global<Facts>(source);
+let record = table::spec_get(facts.subjects, subject);
+// This enforces high-level requirement 1:
+ensures spec_is_denied(source, subject) ==> !result_1 && result_2 == 0;
+// This enforces high-level requirement 3:
+ensures result_1 ==> table::spec_contains(facts.subjects, subject)
+    && record.state == STATE_ACTIVE
+    && record.expires_at_secs > spec_now()
+    && record.issuer_epoch >= spec_effective_epoch(source, record.issuer_id);
+ensures result_1 ==> result_2 == record.level;
+ensures !result_1 ==> result_2 == 0;
+
+ + + + + +### Function `is_denied` + + +
#[view]
+public fun is_denied(source: address, subject: address): bool
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if table::spec_contains(global<Facts>(source).denied, subject) && !spec_has_time();
+ensures result == spec_is_denied(source, subject);
+
+ + + + + +### Function `admins` + + +
#[view]
+public fun admins(source: address): vector<address>
+
+ + + + +
include SourceExistsAbortsIf;
+ensures result == global<Source>(source).admins;
+
+ + + + + +### Function `is_admin` + + +
#[view]
+public fun is_admin(addr: address, source: address): bool
+
+ + + + +
include SourceExistsAbortsIf;
+ensures result == contains(global<Source>(source).admins, addr);
+
+ + + + + +### Function `is_issuer` + + +
#[view]
+public fun is_issuer(addr: address, source: address): bool
+
+ + + + +
include SourceExistsAbortsIf;
+ensures result == contains(global<Source>(source).issuers, addr);
+
+ + + + + +### Function `issuer_epoch_of` + + +
#[view]
+public fun issuer_epoch_of(source: address, issuer_id: u16): u64
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if !table::spec_contains(global<Facts>(source).floor_epoch, 0);
+// This enforces high-level requirement 3:
+ensures result == spec_effective_epoch(source, issuer_id);
+ensures result >= spec_floor(source);
+ensures result >= spec_own_epoch(source, issuer_id);
+
+ + + + + +### Function `floor_epoch` + + +
#[view]
+public fun floor_epoch(source: address): u64
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if !table::spec_contains(global<Facts>(source).floor_epoch, 0);
+ensures result == spec_floor(source);
+
+ + + + + +### Function `is_paused` + + +
#[view]
+public fun is_paused(source: address): bool
+
+ + + + +
include SourceExistsAbortsIf;
+ensures result == global<Source>(source).paused;
+
+ + + + + +### Function `is_source` + + +
#[view]
+public fun is_source(source: address): bool
+
+ + + + +
aborts_if false;
+ensures result == spec_is_source(source);
+
+ + + + + +### Function `standard_version` + + +
#[view]
+public fun standard_version(): u64
+
+ + + + +
aborts_if false;
+ensures result == VERSION;
+
+ + + + + +### Function `create` + + +
public entry fun create(deployer: &signer, admins: vector<address>, issuers: vector<address>, sentinels: vector<address>, removers: vector<address>, guardians: vector<address>)
+
+ + + + +
pragma verify = false;
+
+ + + + + +### Function `create_source_internal` + + +
fun create_source_internal(source_account: &signer, deployer: address, admins: vector<address>, issuers: vector<address>, sentinels: vector<address>, removers: vector<address>, guardians: vector<address>, signer_cap: account::SignerCapability)
+
+ + + + +
let addr = address_of(source_account);
+pragma aborts_if_is_partial;
+// This enforces high-level requirement 8:
+aborts_if len(admins) < 1;
+aborts_if exists<Source>(addr);
+aborts_if exists<Facts>(addr);
+ensures spec_is_source(addr);
+ensures global<Source>(addr).admins == admins;
+ensures global<Source>(addr).issuers == issuers;
+ensures global<Source>(addr).sentinels == sentinels;
+ensures global<Source>(addr).removers == removers;
+ensures global<Source>(addr).guardians == guardians;
+ensures !global<Source>(addr).paused;
+ensures global<Source>(addr).next_issuer_id == 1;
+ensures table::spec_contains(global<Facts>(addr).floor_epoch, 0);
+ensures spec_floor(addr) == 0;
+
+ + + + + +### Function `add_admins` + + +
public entry fun add_admins(admin: &signer, source: address, new_admins: vector<address>)
+
+ + + + +
pragma aborts_if_is_partial;
+include AdminAbortsIf;
+
+ + + + + +### Function `remove_admins` + + +
public entry fun remove_admins(admin: &signer, source: address, old_admins: vector<address>)
+
+ + + + +
pragma aborts_if_is_partial;
+include AdminAbortsIf;
+// This enforces high-level requirement 8:
+ensures len(global<Source>(source).admins) >= 1;
+
+ + + + + +### Function `pause` + + +
public entry fun pause(guardian: &signer, source: address)
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if !contains(global<Source>(source).guardians, address_of(guardian));
+ensures global<Source>(source).paused;
+ensures global<Facts>(source) == old(global<Facts>(source));
+
+ + + + + +### Function `unpause` + + +
public entry fun unpause(guardian: &signer, source: address)
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if !contains(global<Source>(source).guardians, address_of(guardian));
+ensures !global<Source>(source).paused;
+ensures global<Facts>(source) == old(global<Facts>(source));
+
+ + + + + +### Function `set_paused` + + +
fun set_paused(guardian: &signer, source: address, paused: bool)
+
+ + + + +
include SourceExistsAbortsIf;
+aborts_if !contains(global<Source>(source).guardians, address_of(guardian));
+ensures global<Source>(source).paused == paused;
+// This enforces high-level requirement 4:
+ensures global<Facts>(source) == old(global<Facts>(source));
+
+ + + + + +### Function `bump_issuer_epoch` + + +
public entry fun bump_issuer_epoch(admin: &signer, source: address, issuer_id: u16)
+
+ + + + +
include AdminAbortsIf;
+aborts_if issuer_id != 0 && !table::spec_contains(global<Source>(source).issuer_by_id, issuer_id);
+aborts_if !table::spec_contains(global<Facts>(source).floor_epoch, 0);
+aborts_if spec_effective_epoch(source, issuer_id) + 1 > MAX_U64;
+// This enforces high-level requirement 3:
+ensures spec_own_epoch(source, issuer_id) == old(spec_effective_epoch(source, issuer_id)) + 1;
+ensures spec_effective_epoch(source, issuer_id) > old(spec_effective_epoch(source, issuer_id));
+ensures global<Facts>(source).subjects == old(global<Facts>(source).subjects);
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+ensures global<Facts>(source).floor_epoch == old(global<Facts>(source).floor_epoch);
+
+ + + + + +### Function `set_floor_epoch` + + +
public entry fun set_floor_epoch(admin: &signer, source: address, epoch: u64)
+
+ + + + +
include AdminAbortsIf;
+aborts_if !table::spec_contains(global<Facts>(source).floor_epoch, 0);
+// This enforces high-level requirement 3:
+aborts_if epoch <= spec_floor(source);
+ensures spec_floor(source) == epoch;
+ensures spec_floor(source) > old(spec_floor(source));
+ensures global<Facts>(source).subjects == old(global<Facts>(source).subjects);
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+
+ + + + + +### Function `suspend` + + +
public entry fun suspend(issuer: &signer, source: address, subject: address, reason: u16)
+
+ + + + +
pragma aborts_if_is_partial;
+include SourceExistsAbortsIf;
+aborts_if global<Source>(source).paused;
+// This enforces high-level requirement 9:
+aborts_if !contains(global<Source>(source).issuers, address_of(issuer));
+aborts_if !table::spec_contains(global<Source>(source).issuer_info, address_of(issuer));
+ensures table::spec_get(global<Facts>(source).subjects, subject).state == STATE_SUSPENDED;
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+
+ + + + + +### Function `unsuspend` + + +
public entry fun unsuspend(issuer: &signer, source: address, subject: address, reason: u16)
+
+ + + + +
pragma aborts_if_is_partial;
+include SourceExistsAbortsIf;
+aborts_if global<Source>(source).paused;
+aborts_if !contains(global<Source>(source).issuers, address_of(issuer));
+aborts_if table::spec_contains(global<Facts>(source).denied, subject);
+aborts_if table::spec_contains(global<Facts>(source).subjects, subject)
+    && table::spec_get(global<Facts>(source).subjects, subject).state != STATE_SUSPENDED;
+ensures table::spec_get(global<Facts>(source).subjects, subject).issuer_epoch
+    == old(table::spec_get(global<Facts>(source).subjects, subject).issuer_epoch);
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+
+ + + + + +### Function `redeem_attestation` + + +
public entry fun redeem_attestation(_relayer: &signer, source: address, subject: address, issuer_id: u16, issuer_epoch: u64, level: u8, expires_at_secs: u64, issued_at_secs: u64, nullifier: vector<u8>, signature: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+let config = global<Source>(source);
+let issuer_address = table::spec_get(config.issuer_by_id, issuer_id);
+include SourceExistsAbortsIf;
+aborts_if config.paused;
+aborts_if !table::spec_contains(config.issuer_by_id, issuer_id);
+// This enforces high-level requirement 9:
+aborts_if !contains(config.issuers, issuer_address);
+aborts_if table::spec_contains(global<Facts>(source).denied, subject);
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+ensures table::spec_get(global<Facts>(source).subjects, subject).issuer_epoch == issuer_epoch;
+
+ + + + + +### Function `record_verified_claim` + + +
public(friend) fun record_verified_claim(source: address, subject: address, level: u8, expires_at_secs: u64, attestation_digest: vector<u8>, nullifier: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+include SourceExistsAbortsIf;
+aborts_if global<Source>(source).paused;
+aborts_if len(attestation_digest) != DIGEST_LENGTH;
+aborts_if table::spec_contains(global<Facts>(source).denied, subject);
+let post record = table::spec_get(global<Facts>(source).subjects, subject);
+ensures record.issuer_id == 0;
+// This enforces high-level requirement 3:
+ensures record.issuer_epoch == old(spec_effective_epoch(source, 0));
+ensures record.level == level;
+ensures record.state == STATE_ACTIVE;
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+
+ + + + + +### Function `deny` + + +
public entry fun deny(sentinel: &signer, source: address, subject: address, reason: u16, effective_at_secs: u64)
+
+ + + + +
include SourceExistsAbortsIf;
+// This enforces high-level requirement 5:
+aborts_if !contains(global<Source>(source).sentinels, address_of(sentinel));
+aborts_if !spec_has_time();
+ensures table::spec_contains(global<Facts>(source).denied, subject);
+ensures table::spec_get(global<Facts>(source).denied, subject).effective_at_secs == effective_at_secs;
+ensures global<Facts>(source).subjects == old(global<Facts>(source).subjects);
+
+ + + + + +### Function `deny_batch` + + +
public entry fun deny_batch(sentinel: &signer, source: address, subjects: vector<address>, reason: u16)
+
+ + + + +
pragma aborts_if_is_partial;
+include SourceExistsAbortsIf;
+// This enforces high-level requirement 5:
+aborts_if !contains(global<Source>(source).sentinels, address_of(sentinel));
+aborts_if len(subjects) > MAX_BATCH;
+
+ + + + + +### Function `undeny` + + +
public entry fun undeny(remover: &signer, source: address, subject: address)
+
+ + + + +
include SourceExistsAbortsIf;
+// This enforces high-level requirement 5:
+aborts_if !contains(global<Source>(source).removers, address_of(remover));
+ensures !table::spec_contains(global<Facts>(source).denied, subject);
+ensures global<Facts>(source).subjects == old(global<Facts>(source).subjects);
+
+ + + + + +### Function `publish_root` + + +
public entry fun publish_root(issuer: &signer, source: address, digest: vector<u8>, leaf_count: u64)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if len(digest) != DIGEST_LENGTH;
+include SourceExistsAbortsIf;
+aborts_if global<Source>(source).paused;
+ensures global<Source>(source).root_epoch == old(global<Source>(source).root_epoch) + 1;
+ensures global<Facts>(source) == old(global<Facts>(source));
+
+ + + + + +### Function `record_fact` + + +
fun record_fact(source: address, subject: address, state: u8, level: u8, issuer_id: u16, issuer_epoch: u64, expires_at_secs: u64, issued_at_secs: u64, reason: u16, attestation_digest: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Facts>(source);
+aborts_if !spec_has_time();
+// This enforces high-level requirement 2:
+aborts_if table::spec_contains(global<Facts>(source).denied, subject);
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+ensures global<Facts>(source).issuer_epochs == old(global<Facts>(source).issuer_epochs);
+ensures global<Facts>(source).floor_epoch == old(global<Facts>(source).floor_epoch);
+ensures global<Facts>(source).nullifiers == old(global<Facts>(source).nullifiers);
+let post record = table::spec_get(global<Facts>(source).subjects, subject);
+ensures table::spec_contains(global<Facts>(source).subjects, subject);
+ensures record.state == state;
+ensures record.level == level;
+ensures record.issuer_id == issuer_id;
+ensures record.issuer_epoch == issuer_epoch;
+ensures record.expires_at_secs == expires_at_secs;
+
+ + + + + +### Function `transition` + + +
fun transition(source: address, subject: address, state: u8, acting_issuer_id: u16, reason: u16)
+
+ + + + +
pragma aborts_if_is_partial;
+let facts = global<Facts>(source);
+let record = table::spec_get(facts.subjects, subject);
+let post post_record = table::spec_get(global<Facts>(source).subjects, subject);
+aborts_if !exists<Facts>(source);
+aborts_if !table::spec_contains(facts.subjects, subject);
+// This enforces high-level requirement 6:
+aborts_if state == STATE_SUSPENDED && record.state != STATE_ACTIVE;
+aborts_if state == STATE_ACTIVE && record.state != STATE_SUSPENDED;
+aborts_if state == STATE_REVOKED && record.state != STATE_ACTIVE
+    && record.state != STATE_SUSPENDED && record.state != STATE_REVOKED;
+aborts_if state != STATE_ACTIVE && state != STATE_SUSPENDED && state != STATE_REVOKED;
+// This enforces high-level requirement 2:
+aborts_if state == STATE_ACTIVE && table::spec_contains(facts.denied, subject);
+ensures post_record.issuer_id == record.issuer_id;
+ensures post_record.issuer_epoch == record.issuer_epoch;
+ensures post_record.level == record.level;
+ensures post_record.expires_at_secs == record.expires_at_secs;
+ensures post_record.state == state;
+ensures record.state == STATE_REVOKED ==> post_record == record;
+ensures global<Facts>(source).denied == old(global<Facts>(source).denied);
+ensures global<Facts>(source).issuer_epochs == old(global<Facts>(source).issuer_epochs);
+ensures global<Facts>(source).floor_epoch == old(global<Facts>(source).floor_epoch);
+
+ + +[move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/doc/attestation_authorization.md b/aptos-move/framework/aptos-framework/doc/attestation_authorization.md new file mode 100644 index 00000000000..cdf152ec704 --- /dev/null +++ b/aptos-move/framework/aptos-framework/doc/attestation_authorization.md @@ -0,0 +1,965 @@ + + + +# Module `0x1::attestation_authorization` + +Per-action authorization: a short-lived signed capability that satisfies one step-up decision +for one action. + +The point is not freshness, although a sixty-second capability does make revocation trivial, +since revoking means declining to issue the next one and no denylist, epoch or status list is +involved on this path. The point is the AUDIT RECORD. A view call leaves no trace and a +transaction-prologue rejection is a discard with no ledger record at all, so a design built only +on persistent facts cannot answer "prove this address was authorized at the moment this exact +transaction executed, and show the basis". An authorization is a transaction argument, so it is +in the ledger permanently, naming the policy, the action and the moment. + +The symmetric cost, stated because it is a disclosure decision rather than an oversight: +everything an authorization names is public forever. That is why the amount is committed as a +BUCKET rather than a value, so the authorizer commits to a ceiling instead of publishing a +customer's exact transaction size. + +Replay protection is a nonce table. The clever alternative is to bind the capability to the +account's sequence number, which costs no storage because the number advances when the +transaction lands, but orderless transactions carry a Nonce replay protector instead and +leave the sequence number untouched, so a capability bound to it would stay reusable there. +Orderless transactions are in this fork's genesis default features, so the clever version is a +footgun and this module does not use it. + +Batched pre-authorization needs no separate mechanism: a batch is N authorizations with distinct +nonces over the same window, which drops the liveness dependency on the authorizer from +per-transaction to per-window. This note exists so nobody builds a second code path for it. + + +- [Resource `Nonces`](#0x1_attestation_authorization_Nonces) +- [Struct `ConsumeAuthorization`](#0x1_attestation_authorization_ConsumeAuthorization) +- [Struct `PruneNonces`](#0x1_attestation_authorization_PruneNonces) +- [Constants](#@Constants_0) +- [Function `is_initialized`](#0x1_attestation_authorization_is_initialized) +- [Function `is_nonce_used`](#0x1_attestation_authorization_is_nonce_used) +- [Function `bucket_ceiling`](#0x1_attestation_authorization_bucket_ceiling) +- [Function `authorization_message`](#0x1_attestation_authorization_authorization_message) +- [Function `initialize`](#0x1_attestation_authorization_initialize) +- [Function `verify_and_consume`](#0x1_attestation_authorization_verify_and_consume) +- [Function `prune_nonces`](#0x1_attestation_authorization_prune_nonces) +- [Function `decode`](#0x1_attestation_authorization_decode) +- [Function `read_u64_le`](#0x1_attestation_authorization_read_u64_le) +- [Specification](#@Specification_1) + - [High-level Requirements](#high-level-req) + - [Module-level Specification](#module-level-spec) + - [Function `is_initialized`](#@Specification_1_is_initialized) + - [Function `is_nonce_used`](#@Specification_1_is_nonce_used) + - [Function `bucket_ceiling`](#@Specification_1_bucket_ceiling) + - [Function `authorization_message`](#@Specification_1_authorization_message) + - [Function `initialize`](#@Specification_1_initialize) + - [Function `verify_and_consume`](#@Specification_1_verify_and_consume) + - [Function `prune_nonces`](#@Specification_1_prune_nonces) + + +
use 0x1::bcs;
+use 0x1::chain_id;
+use 0x1::ed25519;
+use 0x1::error;
+use 0x1::event;
+use 0x1::table;
+use 0x1::timestamp;
+use 0x1::vector;
+
+ + + + + +## Resource `Nonces` + +Consumed nonces, stored at the policy's address. The value is the expiry, retained so an +entry can be pruned once it can no longer be replayed. + + +
struct Nonces has key
+
+ + + +
+Fields + + +
+
+used: table::Table<vector<u8>, u64> +
+
+ +
+
+ + +
+ + + +## Struct `ConsumeAuthorization` + + + +
#[event]
+struct ConsumeAuthorization has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+subject: address +
+
+ +
+
+action: u8 +
+
+ +
+
+amount_bucket: u8 +
+
+ +
+
+nonce: vector<u8> +
+
+ +
+
+issued_at_secs: u64 +
+
+ +
+
+expires_at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `PruneNonces` + + + +
#[event]
+struct PruneNonces has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+released: u64 +
+
+ +
+
+ + +
+ + + +## Constants + + + + +The authorization has expired. + + +
const EEXPIRED: u64 = 3;
+
+ + + + + +The authorizer signature did not verify. + + +
const EBAD_SIGNATURE: u64 = 2;
+
+ + + + + +The batch exceeds MAX_PRUNE. + + +
const EBATCH_TOO_LARGE: u64 = 12;
+
+ + + + + +Required length of an ed25519 public key. + + +
const PUBKEY_LENGTH: u64 = 32;
+
+ + + + + +Required length of an ed25519 signature. + + +
const SIGNATURE_LENGTH: u64 = 64;
+
+ + + + + +Domain separator for the signed message, so an authorization cannot be reinterpreted as any +other signed payload. + + +
const DOMAIN_AUTHORIZE: vector<u8> = [97, 112, 116, 111, 115, 95, 102, 114, 97, 109, 101, 119, 111, 114, 107, 58, 58, 97, 116, 116, 101, 115, 116, 97, 116, 105, 111, 110, 95, 97, 117, 116, 104, 111, 114, 105, 122, 97, 116, 105, 111, 110, 58, 58, 65, 85, 84, 72];
+
+ + + + + +The amount exceeds the ceiling the authorizer committed to. + + +
const EAMOUNT_OVER_BUCKET: u64 = 5;
+
+ + + + + +The bucket exponent exceeds MAX_BUCKET. + + +
const EBAD_BUCKET: u64 = 10;
+
+ + + + + +A nonce must be exactly 32 bytes. + + +
const EBAD_NONCE_LENGTH: u64 = 8;
+
+ + + + + +A signature must be exactly 64 bytes. + + +
const EBAD_SIGNATURE_LENGTH: u64 = 9;
+
+ + + + + +This nonce has already been consumed. + + +
const ENONCE_USED: u64 = 4;
+
+ + + + + +This policy has no nonce store, so it was not created by attestation_policy. + + +
const ENOT_INITIALIZED: u64 = 1;
+
+ + + + + +The authorization was issued in the future. + + +
const ENOT_YET_VALID: u64 = 11;
+
+ + + + + +The policy has no authorizer key configured. + + +
const ENO_AUTHORIZER: u64 = 7;
+
+ + + + + +The authorization's validity window is longer than the policy permits. + + +
const ETTL_TOO_LONG: u64 = 6;
+
+ + + + + +Largest bucket exponent. Bucket b covers amounts up to 10^b, saturating at u64 max. + + +
const MAX_BUCKET: u8 = 20;
+
+ + + + + +Largest number of nonces one prune call may release. + + +
const MAX_PRUNE: u64 = 1000;
+
+ + + + + +Required length of a nonce. + + +
const NONCE_LENGTH: u64 = 32;
+
+ + + + + +## Function `is_initialized` + + + +
#[view]
+public fun is_initialized(policy: address): bool
+
+ + + +
+Implementation + + +
public fun is_initialized(policy: address): bool {
+    exists<Nonces>(policy)
+}
+
+ + + +
+ + + +## Function `is_nonce_used` + + + +
#[view]
+public fun is_nonce_used(policy: address, nonce: vector<u8>): bool
+
+ + + +
+Implementation + + +
public fun is_nonce_used(policy: address, nonce: vector<u8>): bool acquires Nonces {
+    exists<Nonces>(policy) && table::contains(&Nonces[policy].used, nonce)
+}
+
+ + + +
+ + + +## Function `bucket_ceiling` + +Ceiling a bucket exponent commits to: 10^bucket, saturating at u64 max. + + +
#[view]
+public fun bucket_ceiling(bucket: u8): u64
+
+ + + +
+Implementation + + +
public fun bucket_ceiling(bucket: u8): u64 {
+    assert!(bucket <= MAX_BUCKET, error::invalid_argument(EBAD_BUCKET));
+    let ceiling = 1u64;
+    let step = 0;
+    while (step < bucket) {
+        // 10^20 overflows u64, so saturate rather than abort.
+        if (ceiling > 1844674407370955161) {
+            return 18446744073709551615
+        };
+        ceiling *= 10;
+        step += 1;
+    };
+    ceiling
+}
+
+ + + +
+ + + +## Function `authorization_message` + +The message an authorizer signs. Published so an authorizing service can be implemented in +any language without reading this module. + + +
#[view]
+public fun authorization_message(policy: address, subject: address, action: u8, amount_bucket: u8, nonce: vector<u8>, issued_at_secs: u64, expires_at_secs: u64): vector<u8>
+
+ + + +
+Implementation + + +
public fun authorization_message(
+    policy: address,
+    subject: address,
+    action: u8,
+    amount_bucket: u8,
+    nonce: vector<u8>,
+    issued_at_secs: u64,
+    expires_at_secs: u64
+): vector<u8> {
+    let message = vector[];
+    message.append(DOMAIN_AUTHORIZE);
+    message.append(to_bytes(&chain_id::get()));
+    message.append(to_bytes(&policy));
+    message.append(to_bytes(&subject));
+    message.append(to_bytes(&action));
+    message.append(to_bytes(&amount_bucket));
+    message.append(to_bytes(&nonce));
+    message.append(to_bytes(&issued_at_secs));
+    message.append(to_bytes(&expires_at_secs));
+    message
+}
+
+ + + +
+ + + +## Function `initialize` + +Create the nonce store. Called by attestation_policy::create with the policy's own +resource-account signer, which is why this needs no permission check of its own. + + +
public(friend) fun initialize(policy_account: &signer)
+
+ + + +
+Implementation + + +
public(friend) fun initialize(policy_account: &signer) {
+    move_to(policy_account, Nonces { used: table::new<vector<u8>, u64>() });
+}
+
+ + + +
+ + + +## Function `verify_and_consume` + +Verify an authorization and consume its nonce. + +Every field is checked against the call it is being used for; none is advisory. The policy +supplies the authorizer key and the longest window it will accept, so a compromised +authorizer cannot mint a long-lived capability by setting a distant expiry. + +@param policy The policy the authorization was issued for. +@param authorizer_pubkey 32-byte ed25519 key the policy trusts. +@param max_ttl_secs Longest validity window the policy accepts. +@param subject The subject taking the action. +@param action The action being taken. +@param amount The actual amount, which must be within the committed bucket ceiling. +@param authorization BCS of (action, amount_bucket, nonce, issued_at, expires_at) followed +by the 64-byte signature. +@abort If any field disagrees with the call, the window is too long, the signature fails, +or the nonce has already been consumed. + + +
public(friend) fun verify_and_consume(policy: address, authorizer_pubkey: vector<u8>, max_ttl_secs: u64, subject: address, action: u8, amount: u64, authorization: vector<u8>)
+
+ + + +
+Implementation + + +
public(friend) fun verify_and_consume(
+    policy: address,
+    authorizer_pubkey: vector<u8>,
+    max_ttl_secs: u64,
+    subject: address,
+    action: u8,
+    amount: u64,
+    authorization: vector<u8>
+) acquires Nonces {
+    assert!(exists<Nonces>(policy), error::not_found(ENOT_INITIALIZED));
+    assert!(
+        authorizer_pubkey.length() == PUBKEY_LENGTH,
+        error::invalid_state(ENO_AUTHORIZER)
+    );
+
+    let (signed_action, amount_bucket, nonce, issued_at_secs, expires_at_secs, signature) =
+        decode(authorization);
+
+    assert!(signed_action == action, error::invalid_argument(EBAD_SIGNATURE));
+    assert!(
+        amount <= bucket_ceiling(amount_bucket),
+        error::invalid_argument(EAMOUNT_OVER_BUCKET)
+    );
+
+    let now = now_seconds();
+    assert!(issued_at_secs <= now, error::invalid_argument(ENOT_YET_VALID));
+    assert!(now < expires_at_secs, error::invalid_state(EEXPIRED));
+    assert!(
+        expires_at_secs - issued_at_secs <= max_ttl_secs,
+        error::invalid_argument(ETTL_TOO_LONG)
+    );
+
+    let message =
+        authorization_message(
+            policy,
+            subject,
+            action,
+            amount_bucket,
+            nonce,
+            issued_at_secs,
+            expires_at_secs
+        );
+    assert!(
+        ed25519::signature_verify_strict(
+            &ed25519::new_signature_from_bytes(signature),
+            &ed25519::new_unvalidated_public_key_from_bytes(authorizer_pubkey),
+            message
+        ),
+        error::invalid_argument(EBAD_SIGNATURE)
+    );
+
+    let used = &mut Nonces[policy].used;
+    assert!(!table::contains(used, nonce), error::invalid_state(ENONCE_USED));
+    table::add(used, nonce, expires_at_secs);
+
+    emit(
+        ConsumeAuthorization {
+            policy,
+            subject,
+            action,
+            amount_bucket,
+            nonce,
+            issued_at_secs,
+            expires_at_secs
+        }
+    );
+}
+
+ + + +
+ + + +## Function `prune_nonces` + +Release the storage held by nonces that can no longer be replayed. Permissionless, because +it is pure cleanup and nobody has a reason to withhold it other than the fee, which is the +caller's to pay. + + +
public entry fun prune_nonces(_anyone: &signer, policy: address, nonces: vector<vector<u8>>)
+
+ + + +
+Implementation + + +
public entry fun prune_nonces(
+    _anyone: &signer, policy: address, nonces: vector<vector<u8>>
+) acquires Nonces {
+    assert!(exists<Nonces>(policy), error::not_found(ENOT_INITIALIZED));
+    assert!(nonces.length() <= MAX_PRUNE, error::invalid_argument(EBATCH_TOO_LARGE));
+    let now = now_seconds();
+    let used = &mut Nonces[policy].used;
+    let released = 0;
+    nonces.for_each(|nonce| {
+        if (table::contains(used, nonce) && *table::borrow(used, nonce) <= now) {
+            table::remove(used, nonce);
+            released += 1;
+        };
+    });
+    emit(PruneNonces { policy, released });
+}
+
+ + + +
+ + + +## Function `decode` + +Split an authorization blob into its fields and the signature. The layout is fixed width up +to the signature so it can be parsed without a length prefix: +action (1) || bucket (1) || nonce (32) || issued_at (8) || expires_at (8) || signature (64). +Integers are little-endian, matching BCS. + + +
fun decode(authorization: vector<u8>): (u8, u8, vector<u8>, u64, u64, vector<u8>)
+
+ + + +
+Implementation + + +
fun decode(authorization: vector<u8>): (u8, u8, vector<u8>, u64, u64, vector<u8>) {
+    let expected = 1 + 1 + NONCE_LENGTH + 8 + 8 + SIGNATURE_LENGTH;
+    assert!(
+        authorization.length() == expected,
+        error::invalid_argument(EBAD_SIGNATURE_LENGTH)
+    );
+    let action = authorization[0];
+    let amount_bucket = authorization[1];
+
+    let nonce = vector[];
+    let index = 2;
+    while (index < 2 + NONCE_LENGTH) {
+        nonce.push_back(authorization[index]);
+        index += 1;
+    };
+    assert!(nonce.length() == NONCE_LENGTH, error::invalid_argument(EBAD_NONCE_LENGTH));
+
+    let issued_at_secs = read_u64_le(&authorization, 2 + NONCE_LENGTH);
+    let expires_at_secs = read_u64_le(&authorization, 2 + NONCE_LENGTH + 8);
+
+    let signature = vector[];
+    index = 2 + NONCE_LENGTH + 16;
+    while (index < expected) {
+        signature.push_back(authorization[index]);
+        index += 1;
+    };
+
+    (action, amount_bucket, nonce, issued_at_secs, expires_at_secs, signature)
+}
+
+ + + +
+ + + +## Function `read_u64_le` + + + +
fun read_u64_le(bytes: &vector<u8>, offset: u64): u64
+
+ + + +
+Implementation + + +
fun read_u64_le(bytes: &vector<u8>, offset: u64): u64 {
+    let value = 0u64;
+    let index = 0;
+    while (index < 8) {
+        value += (bytes[offset + index] as u64) << ((index * 8) as u8);
+        index += 1;
+    };
+    value
+}
+
+ + + +
+ + + +## Specification + + + + + + +### High-level Requirements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.RequirementCriticalityImplementationEnforcement
1An authorization is usable at most once, and only inside its validity window (INV-8).Criticalverify_and_consume aborts if the nonce is already in Nonces.used, if now is before issued_at or at or after expires_at, and otherwise inserts the nonce before returning.Formally verified via verify_and_consume that every successful call inserts a nonce that was not present before. The window checks are audited in unit tests, because the fields are decoded by loops the prover havocs.
2Only a nonce that can no longer be replayed is ever pruned.Highprune_nonces removes an entry only when its stored expiry is at or before now; replaying an authorization with that nonce would fail the expiry check anyway.Audited in unit tests (test_prune_expired_nonces). The removal loop is havocked by the prover.
3The nonce store can only be created by attestation_policy, with the policy's own signer, exactly once per policy.Highinitialize and verify_and_consume are public(friend) with attestation_policy as the only friend.Enforced by the friend declaration, which the compiler checks. Formally verified via initialize that a second initialization aborts.
4An authorization requires a configured 32-byte authorizer key and a blob of the exact fixed layout.Mediumverify_and_consume asserts the key length and decode asserts the blob length.Formally verified via verify_and_consume.
+ + + + + +### Module-level Specification + + +
pragma verify = true;
+pragma aborts_if_is_strict = false;
+
+ + + + + +### Function `is_initialized` + + +
#[view]
+public fun is_initialized(policy: address): bool
+
+ + + + +
aborts_if false;
+ensures result == exists<Nonces>(policy);
+
+ + + + + +### Function `is_nonce_used` + + +
#[view]
+public fun is_nonce_used(policy: address, nonce: vector<u8>): bool
+
+ + + + +
aborts_if false;
+ensures result == (exists<Nonces>(policy)
+    && table::spec_contains(global<Nonces>(policy).used, nonce));
+
+ + + + + +### Function `bucket_ceiling` + + +
#[view]
+public fun bucket_ceiling(bucket: u8): u64
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if bucket > MAX_BUCKET;
+
+ + + + + +### Function `authorization_message` + + +
#[view]
+public fun authorization_message(policy: address, subject: address, action: u8, amount_bucket: u8, nonce: vector<u8>, issued_at_secs: u64, expires_at_secs: u64): vector<u8>
+
+ + + + +
aborts_if !exists<chain_id::ChainId>(@aptos_framework);
+
+ + + + + +### Function `initialize` + + +
public(friend) fun initialize(policy_account: &signer)
+
+ + + + +
// This enforces high-level requirement 3:
+aborts_if exists<Nonces>(std::signer::address_of(policy_account));
+ensures exists<Nonces>(std::signer::address_of(policy_account));
+
+ + + + + +### Function `verify_and_consume` + + +
public(friend) fun verify_and_consume(policy: address, authorizer_pubkey: vector<u8>, max_ttl_secs: u64, subject: address, action: u8, amount: u64, authorization: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Nonces>(policy);
+// This enforces high-level requirement 4:
+aborts_if len(authorizer_pubkey) != PUBKEY_LENGTH;
+aborts_if len(authorization) != 2 + NONCE_LENGTH + 16 + SIGNATURE_LENGTH;
+let used = global<Nonces>(policy).used;
+let post post_used = global<Nonces>(policy).used;
+// This enforces high-level requirement 1:
+ensures exists nonce: vector<u8>:
+    !table::spec_contains(used, nonce) && table::spec_contains(post_used, nonce);
+
+ + + + + +### Function `prune_nonces` + + +
public entry fun prune_nonces(_anyone: &signer, policy: address, nonces: vector<vector<u8>>)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Nonces>(policy);
+aborts_if len(nonces) > MAX_PRUNE;
+
+ + +[move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/doc/attestation_policy.md b/aptos-move/framework/aptos-framework/doc/attestation_policy.md new file mode 100644 index 00000000000..3de9dca8eb1 --- /dev/null +++ b/aptos-move/framework/aptos-framework/doc/attestation_policy.md @@ -0,0 +1,3329 @@ + + + +# Module `0x1::attestation_policy` + +A business's rules over attestation sources, without deploying anything. + +A policy is a resource account, created the same way aptos_framework::attestation creates a +source and aptos_framework::timelock creates a timelock account: the deployer authorizes +creation and pays gas but gains no role unless listed. A policy names the sources it trusts, +how to combine them, predicates over their attributes, and a per-action amount above which a +fresh authorization is demanded. + +The consequence is the reason this module exists rather than a single registry. A new business +does not onboard subjects: it creates a policy pointing at sources that already have them, so +its marginal onboarding cost is zero, and the network effect sits on the source side. + +Evaluation returns three values, not two. Two-valued authorization forces one global threshold +on every consumer. The third value lets a policy be permissive for ordinary activity and strict +where the business actually cares, and it is what joins persistent facts and per-action +authorization into one product instead of two. + +Properties: +- An empty body denies everything. A policy that allows nothing is obvious in testing; one that +allows everything is not. +- Denial is evaluated before anything positive, and no source can override another's denial. +- Rule changes are staged with an activation time, so a change never breaks a transaction that +is already in flight, and anyone may push a staged body live once its time arrives. +- A paused policy denies with its own reason rather than silently allowing. +- Source lists are bounded, because unbounded iteration over rules is a denial-of-service +vector. ERC-3643 caps its module list at 25 for the same reason. + +Delayed governance comes by composition: an admin may be a aptos_framework::timelock account, +in which case every rule change inherits that module's delay and cancel semantics. + + +- [Struct `SourceRef`](#0x1_attestation_policy_SourceRef) +- [Struct `AttrRule`](#0x1_attestation_policy_AttrRule) +- [Struct `Body`](#0x1_attestation_policy_Body) +- [Struct `Staged`](#0x1_attestation_policy_Staged) +- [Resource `Policy`](#0x1_attestation_policy_Policy) +- [Struct `CreatePolicy`](#0x1_attestation_policy_CreatePolicy) +- [Struct `AddMembers`](#0x1_attestation_policy_AddMembers) +- [Struct `RemoveMembers`](#0x1_attestation_policy_RemoveMembers) +- [Struct `StageBody`](#0x1_attestation_policy_StageBody) +- [Struct `ActivateBody`](#0x1_attestation_policy_ActivateBody) +- [Struct `CancelPending`](#0x1_attestation_policy_CancelPending) +- [Struct `SetStepUp`](#0x1_attestation_policy_SetStepUp) +- [Struct `SetAuthorizer`](#0x1_attestation_policy_SetAuthorizer) +- [Struct `SetPaused`](#0x1_attestation_policy_SetPaused) +- [Constants](#@Constants_0) +- [Function `get_next_policy_address`](#0x1_attestation_policy_get_next_policy_address) +- [Function `evaluate`](#0x1_attestation_policy_evaluate) +- [Function `is_allowed`](#0x1_attestation_policy_is_allowed) +- [Function `decision_of`](#0x1_attestation_policy_decision_of) +- [Function `reason_of`](#0x1_attestation_policy_reason_of) +- [Function `simulate`](#0x1_attestation_policy_simulate) +- [Function `simulate_counts`](#0x1_attestation_policy_simulate_counts) +- [Function `admins`](#0x1_attestation_policy_admins) +- [Function `guardians`](#0x1_attestation_policy_guardians) +- [Function `is_admin`](#0x1_attestation_policy_is_admin) +- [Function `is_paused`](#0x1_attestation_policy_is_paused) +- [Function `require_any_sources`](#0x1_attestation_policy_require_any_sources) +- [Function `require_all_sources`](#0x1_attestation_policy_require_all_sources) +- [Function `deny_any_sources`](#0x1_attestation_policy_deny_any_sources) +- [Function `step_up_for`](#0x1_attestation_policy_step_up_for) +- [Function `has_pending`](#0x1_attestation_policy_has_pending) +- [Function `pending_effective_at`](#0x1_attestation_policy_pending_effective_at) +- [Function `authorizer_pubkey`](#0x1_attestation_policy_authorizer_pubkey) +- [Function `authorizer_max_ttl_secs`](#0x1_attestation_policy_authorizer_max_ttl_secs) +- [Function `standard_version`](#0x1_attestation_policy_standard_version) +- [Function `create`](#0x1_attestation_policy_create) +- [Function `stage_body`](#0x1_attestation_policy_stage_body) +- [Function `stage_attr_rules`](#0x1_attestation_policy_stage_attr_rules) +- [Function `activate_pending`](#0x1_attestation_policy_activate_pending) +- [Function `cancel_pending`](#0x1_attestation_policy_cancel_pending) +- [Function `set_step_up`](#0x1_attestation_policy_set_step_up) +- [Function `set_authorizer`](#0x1_attestation_policy_set_authorizer) +- [Function `clear_step_up`](#0x1_attestation_policy_clear_step_up) +- [Function `add_admins`](#0x1_attestation_policy_add_admins) +- [Function `remove_admins`](#0x1_attestation_policy_remove_admins) +- [Function `add_guardians`](#0x1_attestation_policy_add_guardians) +- [Function `remove_guardians`](#0x1_attestation_policy_remove_guardians) +- [Function `pause`](#0x1_attestation_policy_pause) +- [Function `unpause`](#0x1_attestation_policy_unpause) +- [Function `set_paused`](#0x1_attestation_policy_set_paused) +- [Function `require`](#0x1_attestation_policy_require) +- [Function `require_authorized`](#0x1_attestation_policy_require_authorized) +- [Function `check`](#0x1_attestation_policy_check) +- [Function `create_policy_account`](#0x1_attestation_policy_create_policy_account) +- [Function `create_policy_seed`](#0x1_attestation_policy_create_policy_seed) +- [Function `empty_body`](#0x1_attestation_policy_empty_body) +- [Function `build_body`](#0x1_attestation_policy_build_body) +- [Function `build_source_refs`](#0x1_attestation_policy_build_source_refs) +- [Function `eval_attr_rule`](#0x1_attestation_policy_eval_attr_rule) +- [Function `gte_bytes`](#0x1_attestation_policy_gte_bytes) +- [Function `validate_members`](#0x1_attestation_policy_validate_members) +- [Function `add_members`](#0x1_attestation_policy_add_members) +- [Function `remove_members`](#0x1_attestation_policy_remove_members) +- [Function `assert_policy_exists`](#0x1_attestation_policy_assert_policy_exists) +- [Function `assert_admin`](#0x1_attestation_policy_assert_admin) +- [Specification](#@Specification_1) + - [High-level Requirements](#high-level-req) + - [Module-level Specification](#module-level-spec) + - [Function `evaluate`](#@Specification_1_evaluate) + - [Function `is_paused`](#@Specification_1_is_paused) + - [Function `step_up_for`](#@Specification_1_step_up_for) + - [Function `has_pending`](#@Specification_1_has_pending) + - [Function `standard_version`](#@Specification_1_standard_version) + - [Function `create`](#@Specification_1_create) + - [Function `stage_body`](#@Specification_1_stage_body) + - [Function `stage_attr_rules`](#@Specification_1_stage_attr_rules) + - [Function `activate_pending`](#@Specification_1_activate_pending) + - [Function `cancel_pending`](#@Specification_1_cancel_pending) + - [Function `set_step_up`](#@Specification_1_set_step_up) + - [Function `set_authorizer`](#@Specification_1_set_authorizer) + - [Function `remove_admins`](#@Specification_1_remove_admins) + - [Function `set_paused`](#@Specification_1_set_paused) + - [Function `require`](#@Specification_1_require) + + +
use 0x1::account;
+use 0x1::attestation;
+use 0x1::attestation_authorization;
+use 0x1::bcs;
+use 0x1::error;
+use 0x1::event;
+use 0x1::option;
+use 0x1::signer;
+use 0x1::table;
+use 0x1::timestamp;
+use 0x1::vector;
+
+ + + + + +## Struct `SourceRef` + +One source the policy consults, and the minimum level it must vouch at. + + +
struct SourceRef has copy, drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+min_level: u8 +
+
+ +
+
+ + +
+ + + +## Struct `AttrRule` + +A predicate over one attribute of one source. + + +
struct AttrRule has copy, drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+key: u16 +
+
+ +
+
+op: u8 +
+
+ +
+
+values: vector<vector<u8>> +
+
+ +
+
+ + +
+ + + +## Struct `Body` + +The rules themselves, swapped atomically when a staged body activates. + + +
struct Body has copy, drop, store
+
+ + + +
+Fields + + +
+
+require_any: vector<attestation_policy::SourceRef> +
+
+ +
+
+require_all: vector<attestation_policy::SourceRef> +
+
+ +
+
+deny_any: vector<address> +
+
+ +
+
+attr_rules: vector<attestation_policy::AttrRule> +
+
+ +
+
+chain_deny: option::Option<address> +
+
+ +
+
+ + +
+ + + +## Struct `Staged` + +A body waiting for its activation time. + + +
struct Staged has copy, drop, store
+
+ + + +
+Fields + + +
+
+body: attestation_policy::Body +
+
+ +
+
+effective_at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Resource `Policy` + +Stored at the policy's resource account address. + + +
struct Policy has key
+
+ + + +
+Fields + + +
+
+admins: vector<address> +
+
+ +
+
+guardians: vector<address> +
+
+ +
+
+paused: bool +
+
+ +
+
+body: attestation_policy::Body +
+
+ +
+
+pending: option::Option<attestation_policy::Staged> +
+
+ +
+
+step_up_above: table::Table<u8, u64> +
+
+ +
+
+authorizer_pubkey: vector<u8> +
+
+ +
+
+authorizer_max_ttl_secs: u64 +
+
+ +
+
+signer_cap: account::SignerCapability +
+
+ +
+
+ + +
+ + + +## Struct `CreatePolicy` + + + +
#[event]
+struct CreatePolicy has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+deployer: address +
+
+ +
+
+admins: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `AddMembers` + + + +
#[event]
+struct AddMembers has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+role: u8 +
+
+ +
+
+members: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `RemoveMembers` + + + +
#[event]
+struct RemoveMembers has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+role: u8 +
+
+ +
+
+members: vector<address> +
+
+ +
+
+ + +
+ + + +## Struct `StageBody` + + + +
#[event]
+struct StageBody has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+effective_at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `ActivateBody` + + + +
#[event]
+struct ActivateBody has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+at_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `CancelPending` + + + +
#[event]
+struct CancelPending has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+ + +
+ + + +## Struct `SetStepUp` + + + +
#[event]
+struct SetStepUp has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+action: u8 +
+
+ +
+
+threshold: u64 +
+
+ +
+
+ + +
+ + + +## Struct `SetAuthorizer` + + + +
#[event]
+struct SetAuthorizer has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+max_ttl_secs: u64 +
+
+ +
+
+ + +
+ + + +## Struct `SetPaused` + + + +
#[event]
+struct SetPaused has drop, store
+
+ + + +
+Fields + + +
+
+policy: address +
+
+ +
+
+paused: bool +
+
+ +
+
+ + +
+ + + +## Constants + + + + +Domain separator used when deriving the resource account seed, to avoid collisions with +other modules that create resource accounts. + + +
const DOMAIN_SEPARATOR: vector<u8> = [97, 112, 116, 111, 115, 95, 102, 114, 97, 109, 101, 119, 111, 114, 107, 58, 58, 97, 116, 116, 101, 115, 116, 97, 116, 105, 111, 110, 95, 112, 111, 108, 105, 99, 121];
+
+ + + + + +A role list cannot contain duplicate addresses. + + +
const EDUPLICATE_MEMBER: u64 = 4;
+
+ + + + + +Argument vectors have differing lengths. + + +
const ELENGTH_MISMATCH: u64 = 10;
+
+ + + + + +The caller is not an admin. + + +
const ENOT_ADMIN: u64 = 2;
+
+ + + + + +A policy must have at least one admin. + + +
const ENOT_ENOUGH_ADMINS: u64 = 6;
+
+ + + + + +The caller is not a guardian. + + +
const ENOT_GUARDIAN: u64 = 3;
+
+ + + + + +The policy account itself cannot hold a role. + + +
const ESELF_CANNOT_BE_MEMBER: u64 = 5;
+
+ + + + + +Removing these admins would leave the policy with zero admins. + + +
const EWOULD_REMOVE_ALL_ADMINS: u64 = 7;
+
+ + + + + + + +
const ROLE_ADMIN: u8 = 0;
+
+ + + + + + + +
const ROLE_GUARDIAN: u8 = 1;
+
+ + + + + +Published version of this module's interface. + + +
const VERSION: u64 = 1;
+
+ + + + + +Borrow. + + +
const ACTION_BORROW: u8 = 5;
+
+ + + + + +Mint. + + +
const ACTION_MINT: u8 = 3;
+
+ + + + + +Receive value. + + +
const ACTION_RECEIVE: u8 = 2;
+
+ + + + + +Redeem or burn. + + +
const ACTION_REDEEM: u8 = 4;
+
+ + + + + +Transfer value out of the subject's control. + + +
const ACTION_TRANSFER: u8 = 1;
+
+ + + + + +Vote. + + +
const ACTION_VOTE: u8 = 6;
+
+ + + + + +The subject may take the action. + + +
const DECISION_ALLOW: u8 = 0;
+
+ + + + + +The subject may not take the action. + + +
const DECISION_DENY: u8 = 1;
+
+ + + + + +The subject may take the action only with a fresh authorization attached. + + +
const DECISION_STEP_UP: u8 = 2;
+
+ + + + + +Specified account is not a policy. + + +
const EACCOUNT_NOT_POLICY: u64 = 1;
+
+ + + + + +The authorizer key must be 32 bytes or empty. + + +
const EBAD_AUTHORIZER: u64 = 17;
+
+ + + + + +An attribute predicate is malformed. + + +
const EBAD_RULE: u64 = 16;
+
+ + + + + +The subject may not take this action. + + +
const EDENIED: u64 = 13;
+
+ + + + + +The staged body's activation time has not arrived. + + +
const ENOT_EFFECTIVE: u64 = 12;
+
+ + + + + +No staged body is waiting. + + +
const ENO_PENDING: u64 = 11;
+
+ + + + + +The action needs a fresh authorization, which was not supplied. + + +
const ESTEP_UP_REQUIRED: u64 = 14;
+
+ + + + + +At most one chain-wide denial source may be named. + + +
const ETOO_MANY_CHAIN_DENY: u64 = 18;
+
+ + + + + +A body carries more predicates than MAX_RULES. + + +
const ETOO_MANY_RULES: u64 = 9;
+
+ + + + + +A list names more sources than MAX_SOURCES. + + +
const ETOO_MANY_SOURCES: u64 = 8;
+
+ + + + + +A named source does not exist. + + +
const EUNKNOWN_SOURCE: u64 = 15;
+
+ + + + + +Largest number of attribute predicates a body may carry. + + +
const MAX_RULES: u64 = 16;
+
+ + + + + +Largest number of sources any one list in a body may name. + + +
const MAX_SOURCES: u64 = 16;
+
+ + + + + +The attribute value must equal the single listed value. + + +
const OP_EQ: u8 = 2;
+
+ + + + + +The attribute value must be greater than or equal to the single listed value, compared as a +big-endian unsigned integer of the same length. + + +
const OP_GTE: u8 = 3;
+
+ + + + + +The attribute value must be one of the listed values. + + +
const OP_IN: u8 = 0;
+
+ + + + + +The attribute value must not be any of the listed values. + + +
const OP_NOT_IN: u8 = 1;
+
+ + + + + +The amount is above the policy's step-up threshold for this action. + + +
const REASON_AMOUNT_THRESHOLD: u16 = 7;
+
+ + + + + +An attribute predicate failed. + + +
const REASON_ATTR_FAILED: u16 = 6;
+
+ + + + + +Excluded by the chain-wide denial source. + + +
const REASON_CHAIN_DENIED: u16 = 1;
+
+ + + + + +The policy has no rules, so it allows nothing. + + +
const REASON_EMPTY_BODY: u16 = 9;
+
+ + + + + +A source vouches for the subject but below the level the policy requires. + + +
const REASON_LEVEL_TOO_LOW: u16 = 5;
+
+ + + + + +A source the policy requires does not vouch for the subject. + + +
const REASON_MISSING_REQUIRED: u16 = 3;
+
+ + + + + +No source among the alternatives vouches for the subject. + + +
const REASON_NO_QUALIFYING: u16 = 4;
+
+ + + + + +Allowed. + + +
const REASON_OK: u16 = 0;
+
+ + + + + +The policy is paused. + + +
const REASON_POLICY_PAUSED: u16 = 8;
+
+ + + + + +Excluded by one of the policy's denial sources. + + +
const REASON_SOURCE_DENIED: u16 = 2;
+
+ + + + + +## Function `get_next_policy_address` + +Return the predicted address for the next policy deployed by the given account. + + +
#[view]
+public fun get_next_policy_address(deployer: address): address
+
+ + + +
+Implementation + + +
public fun get_next_policy_address(deployer: address): address {
+    let owner_nonce = account::get_sequence_number(deployer);
+    create_resource_address(&deployer, create_policy_seed(to_bytes(&owner_nonce)))
+}
+
+ + + +
+ + + +## Function `evaluate` + +Evaluate the policy for a subject, action and amount, returning a decision and a reason. + +The order below is the load-bearing part of this module. Denial comes before anything +positive and cannot be outvoted by a source that vouches. + + +
#[view]
+public fun evaluate(policy: address, subject: address, action: u8, amount: u64): (u8, u16)
+
+ + + +
+Implementation + + +
public fun evaluate(
+    policy: address, subject: address, action: u8, amount: u64
+): (u8, u16) acquires Policy {
+    assert_policy_exists(policy);
+    let config = &Policy[policy];
+
+    if (config.paused) {
+        return (DECISION_DENY, REASON_POLICY_PAUSED)
+    };
+
+    let body = &config.body;
+
+    // A policy with no positive rule allows nothing. Stated explicitly so an unconfigured
+    // policy is a loud failure rather than an open door.
+    if (body.require_any.is_empty() && body.require_all.is_empty()) {
+        return (DECISION_DENY, REASON_EMPTY_BODY)
+    };
+
+    // 1. chain-wide denial
+    if (option::is_some(&body.chain_deny)) {
+        let chain_source = *option::borrow(&body.chain_deny);
+        if (attestation::is_denied(chain_source, subject)) {
+            return (DECISION_DENY, REASON_CHAIN_DENIED)
+        };
+    };
+
+    // 2. per-source denials
+    let denied = false;
+    body.deny_any.for_each_ref(|source| {
+        if (!denied && attestation::is_denied(*source, subject)) {
+            denied = true;
+        };
+    });
+    if (denied) {
+        return (DECISION_DENY, REASON_SOURCE_DENIED)
+    };
+
+    // 3. every required source must vouch, at or above its level
+    let missing = false;
+    let too_low = false;
+    body.require_all.for_each_ref(|entry| {
+        if (!missing && !too_low) {
+            let (active, level) = attestation::active_with_level(entry.source, subject);
+            if (!active) {
+                missing = true;
+            } else if (level < entry.min_level) {
+                too_low = true;
+            };
+        };
+    });
+    if (missing) {
+        return (DECISION_DENY, REASON_MISSING_REQUIRED)
+    };
+    if (too_low) {
+        return (DECISION_DENY, REASON_LEVEL_TOO_LOW)
+    };
+
+    // 4. at least one alternative must vouch, when any are configured
+    if (!body.require_any.is_empty()) {
+        let qualified = false;
+        body.require_any.for_each_ref(|entry| {
+            if (!qualified) {
+                let (active, level) = attestation::active_with_level(entry.source, subject);
+                if (active && level >= entry.min_level) {
+                    qualified = true;
+                };
+            };
+        });
+        if (!qualified) {
+            return (DECISION_DENY, REASON_NO_QUALIFYING)
+        };
+    };
+
+    // 5. attribute predicates
+    let failed = false;
+    body.attr_rules.for_each_ref(|rule| {
+        if (!failed && !eval_attr_rule(rule, subject)) {
+            failed = true;
+        };
+    });
+    if (failed) {
+        return (DECISION_DENY, REASON_ATTR_FAILED)
+    };
+
+    // 6. step-up threshold for this action
+    if (table::contains(&config.step_up_above, action)
+        && amount > *table::borrow(&config.step_up_above, action)) {
+        return (DECISION_STEP_UP, REASON_AMOUNT_THRESHOLD)
+    };
+
+    (DECISION_ALLOW, REASON_OK)
+}
+
+ + + +
+ + + +## Function `is_allowed` + +Whether the subject may take the action outright, with no authorization needed. + + +
#[view]
+public fun is_allowed(policy: address, subject: address, action: u8, amount: u64): bool
+
+ + + +
+Implementation + + +
public fun is_allowed(
+    policy: address, subject: address, action: u8, amount: u64
+): bool acquires Policy {
+    let (decision, _) = evaluate(policy, subject, action, amount);
+    decision == DECISION_ALLOW
+}
+
+ + + +
+ + + +## Function `decision_of` + +Decision only, for a caller that does not need the reason. + + +
#[view]
+public fun decision_of(policy: address, subject: address, action: u8, amount: u64): u8
+
+ + + +
+Implementation + + +
public fun decision_of(
+    policy: address, subject: address, action: u8, amount: u64
+): u8 acquires Policy {
+    let (decision, _) = evaluate(policy, subject, action, amount);
+    decision
+}
+
+ + + +
+ + + +## Function `reason_of` + +Reason only, for a wallet that wants to explain a refusal rather than show a revert. + + +
#[view]
+public fun reason_of(policy: address, subject: address, action: u8, amount: u64): u16
+
+ + + +
+Implementation + + +
public fun reason_of(
+    policy: address, subject: address, action: u8, amount: u64
+): u16 acquires Policy {
+    let (_, reason) = evaluate(policy, subject, action, amount);
+    reason
+}
+
+ + + +
+ + + +## Function `simulate` + +Dry-run the policy against a population, returning one decision per subject in order. +A business uses this to see what a staged rule change will do before its time arrives. + + +
#[view]
+public fun simulate(policy: address, subjects: vector<address>, action: u8, amount: u64): vector<u8>
+
+ + + +
+Implementation + + +
public fun simulate(
+    policy: address, subjects: vector<address>, action: u8, amount: u64
+): vector<u8> acquires Policy {
+    let decisions = vector[];
+    subjects.for_each(|subject| {
+        let (decision, _) = evaluate(policy, subject, action, amount);
+        decisions.push_back(decision);
+    });
+    decisions
+}
+
+ + + +
+ + + +## Function `simulate_counts` + +Counts of allow, deny and step-up over a population, in that order. + + +
#[view]
+public fun simulate_counts(policy: address, subjects: vector<address>, action: u8, amount: u64): vector<u64>
+
+ + + +
+Implementation + + +
public fun simulate_counts(
+    policy: address, subjects: vector<address>, action: u8, amount: u64
+): vector<u64> acquires Policy {
+    let counts = vector[0, 0, 0];
+    simulate(policy, subjects, action, amount).for_each(|decision| {
+        let slot = counts.borrow_mut((decision as u64));
+        *slot += 1;
+    });
+    counts
+}
+
+ + + +
+ + + +## Function `admins` + + + +
#[view]
+public fun admins(policy: address): vector<address>
+
+ + + +
+Implementation + + +
public fun admins(policy: address): vector<address> acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].admins
+}
+
+ + + +
+ + + +## Function `guardians` + + + +
#[view]
+public fun guardians(policy: address): vector<address>
+
+ + + +
+Implementation + + +
public fun guardians(policy: address): vector<address> acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].guardians
+}
+
+ + + +
+ + + +## Function `is_admin` + + + +
#[view]
+public fun is_admin(addr: address, policy: address): bool
+
+ + + +
+Implementation + + +
public fun is_admin(addr: address, policy: address): bool acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].admins.contains(&addr)
+}
+
+ + + +
+ + + +## Function `is_paused` + + + +
#[view]
+public fun is_paused(policy: address): bool
+
+ + + +
+Implementation + + +
public fun is_paused(policy: address): bool acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].paused
+}
+
+ + + +
+ + + +## Function `require_any_sources` + +Sources named in require_any, with their minimum levels alongside in require_any_levels. + + +
#[view]
+public fun require_any_sources(policy: address): vector<address>
+
+ + + +
+Implementation + + +
public fun require_any_sources(policy: address): vector<address> acquires Policy {
+    assert_policy_exists(policy);
+    let sources = vector[];
+    Policy[policy].body.require_any.for_each_ref(|entry| {
+        sources.push_back(entry.source);
+    });
+    sources
+}
+
+ + + +
+ + + +## Function `require_all_sources` + + + +
#[view]
+public fun require_all_sources(policy: address): vector<address>
+
+ + + +
+Implementation + + +
public fun require_all_sources(policy: address): vector<address> acquires Policy {
+    assert_policy_exists(policy);
+    let sources = vector[];
+    Policy[policy].body.require_all.for_each_ref(|entry| {
+        sources.push_back(entry.source);
+    });
+    sources
+}
+
+ + + +
+ + + +## Function `deny_any_sources` + + + +
#[view]
+public fun deny_any_sources(policy: address): vector<address>
+
+ + + +
+Implementation + + +
public fun deny_any_sources(policy: address): vector<address> acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].body.deny_any
+}
+
+ + + +
+ + + +## Function `step_up_for` + +Amount above which this action needs an authorization, or 0 when it never does. + + +
#[view]
+public fun step_up_for(policy: address, action: u8): u64
+
+ + + +
+Implementation + + +
public fun step_up_for(policy: address, action: u8): u64 acquires Policy {
+    assert_policy_exists(policy);
+    let thresholds = &Policy[policy].step_up_above;
+    if (table::contains(thresholds, action)) {
+        *table::borrow(thresholds, action)
+    } else { 0 }
+}
+
+ + + +
+ + + +## Function `has_pending` + + + +
#[view]
+public fun has_pending(policy: address): bool
+
+ + + +
+Implementation + + +
public fun has_pending(policy: address): bool acquires Policy {
+    assert_policy_exists(policy);
+    option::is_some(&Policy[policy].pending)
+}
+
+ + + +
+ + + +## Function `pending_effective_at` + +When the staged body becomes active, or 0 when nothing is staged. + + +
#[view]
+public fun pending_effective_at(policy: address): u64
+
+ + + +
+Implementation + + +
public fun pending_effective_at(policy: address): u64 acquires Policy {
+    assert_policy_exists(policy);
+    let pending = &Policy[policy].pending;
+    if (option::is_some(pending)) {
+        option::borrow(pending).effective_at_secs
+    } else { 0 }
+}
+
+ + + +
+ + + +## Function `authorizer_pubkey` + +The key that signs authorizations for this policy, or empty when step-up is disabled. + + +
#[view]
+public fun authorizer_pubkey(policy: address): vector<u8>
+
+ + + +
+Implementation + + +
public fun authorizer_pubkey(policy: address): vector<u8> acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].authorizer_pubkey
+}
+
+ + + +
+ + + +## Function `authorizer_max_ttl_secs` + + + +
#[view]
+public fun authorizer_max_ttl_secs(policy: address): u64
+
+ + + +
+Implementation + + +
public fun authorizer_max_ttl_secs(policy: address): u64 acquires Policy {
+    assert_policy_exists(policy);
+    Policy[policy].authorizer_max_ttl_secs
+}
+
+ + + +
+ + + +## Function `standard_version` + + + +
#[view]
+public fun standard_version(): u64
+
+ + + +
+Implementation + + +
public fun standard_version(): u64 {
+    VERSION
+}
+
+ + + +
+ + + +## Function `create` + +Create a new policy. The deployer only authorizes resource-account creation and pays gas; +it gains no role unless listed. The body starts empty, which denies everything until rules +are staged and activated. + +@param deployer Signer that authorizes resource-account creation and pays gas. +@param admins Addresses allowed to stage rules. At least one, no duplicates. +@param guardians Addresses allowed to pause evaluation. May be empty. +@abort If a list has duplicates, names the policy itself, or there is no admin. + + +
public entry fun create(deployer: &signer, admins: vector<address>, guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun create(
+    deployer: &signer, admins: vector<address>, guardians: vector<address>
+) {
+    let (policy_signer, policy_signer_cap) = create_policy_account(deployer);
+    let policy_address = address_of(&policy_signer);
+    assert!(admins.length() >= 1, error::invalid_argument(ENOT_ENOUGH_ADMINS));
+    validate_members(&admins, policy_address);
+    validate_members(&guardians, policy_address);
+
+    move_to(
+        &policy_signer,
+        Policy {
+            admins,
+            guardians,
+            paused: false,
+            body: empty_body(),
+            pending: option::none(),
+            step_up_above: table::new<u8, u64>(),
+            authorizer_pubkey: vector[],
+            authorizer_max_ttl_secs: 0,
+            signer_cap: policy_signer_cap
+        }
+    );
+    attestation_authorization::initialize(&policy_signer);
+    emit(
+        CreatePolicy {
+            policy: policy_address,
+            deployer: address_of(deployer),
+            admins
+        }
+    );
+}
+
+ + + +
+ + + +## Function `stage_body` + +Stage a new body, to take effect at effective_at_secs. Staging rather than applying +immediately is what keeps a rule change from breaking a transaction already in flight. + +@param admin An admin of the policy. +@param policy The policy address. +@param require_any_sources Sources of which at least one must vouch. May be empty. +@param require_any_levels Minimum level per entry, same length as require_any_sources. +@param require_all_sources Sources that must all vouch. May be empty. +@param require_all_levels Minimum level per entry, same length as require_all_sources. +@param deny_any Sources whose denial denies. May be empty. +@param chain_deny Optional chain-wide denial source: empty for none, or exactly one address. +A vector rather than an Option because entry functions cannot take Option arguments. +@param effective_at_secs When the body becomes active. +@abort If the lengths differ, a list is over MAX_SOURCES, chain_deny names more than one +source, or a named source does not exist. + + +
public entry fun stage_body(admin: &signer, policy: address, require_any_sources: vector<address>, require_any_levels: vector<u8>, require_all_sources: vector<address>, require_all_levels: vector<u8>, deny_any: vector<address>, chain_deny: vector<address>, effective_at_secs: u64)
+
+ + + +
+Implementation + + +
public entry fun stage_body(
+    admin: &signer,
+    policy: address,
+    require_any_sources: vector<address>,
+    require_any_levels: vector<u8>,
+    require_all_sources: vector<address>,
+    require_all_levels: vector<u8>,
+    deny_any: vector<address>,
+    chain_deny: vector<address>,
+    effective_at_secs: u64
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    assert!(chain_deny.length() <= 1, error::invalid_argument(ETOO_MANY_CHAIN_DENY));
+    let chain_deny =
+        if (chain_deny.is_empty()) option::none()
+        else option::some(chain_deny[0]);
+    let body =
+        build_body(
+            require_any_sources,
+            require_any_levels,
+            require_all_sources,
+            require_all_levels,
+            deny_any,
+            chain_deny
+        );
+    // Keep any predicates already staged, so the two staging calls compose in either order.
+    let config = &mut Policy[policy];
+    if (option::is_some(&config.pending)) {
+        body.attr_rules = option::borrow(&config.pending).body.attr_rules;
+    };
+    config.pending = option::some(Staged { body, effective_at_secs });
+    emit(StageBody { policy, effective_at_secs });
+}
+
+ + + +
+ + + +## Function `stage_attr_rules` + +Stage attribute predicates onto the pending body. Call stage_body first. + +@param sources Source whose attribute each predicate reads. +@param keys Attribute key per predicate. +@param ops One of OP_IN, OP_NOT_IN, OP_EQ, OP_GTE. +@param values Candidate values per predicate. OP_EQ and OP_GTE take exactly one. + + +
public entry fun stage_attr_rules(admin: &signer, policy: address, sources: vector<address>, keys: vector<u16>, ops: vector<u8>, values: vector<vector<vector<u8>>>)
+
+ + + +
+Implementation + + +
public entry fun stage_attr_rules(
+    admin: &signer,
+    policy: address,
+    sources: vector<address>,
+    keys: vector<u16>,
+    ops: vector<u8>,
+    values: vector<vector<vector<u8>>>
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let count = sources.length();
+    assert!(count <= MAX_RULES, error::invalid_argument(ETOO_MANY_RULES));
+    assert!(
+        count == keys.length() && count == ops.length() && count == values.length(),
+        error::invalid_argument(ELENGTH_MISMATCH)
+    );
+
+    let rules = vector[];
+    let index = 0;
+    while (index < count) {
+        let op = ops[index];
+        assert!(op <= OP_GTE, error::invalid_argument(EBAD_RULE));
+        let candidates = values[index];
+        // A comparison against a set is meaningless with no members, and OP_EQ and OP_GTE
+        // compare against exactly one.
+        assert!(!candidates.is_empty(), error::invalid_argument(EBAD_RULE));
+        if (op == OP_EQ || op == OP_GTE) {
+            assert!(candidates.length() == 1, error::invalid_argument(EBAD_RULE));
+        };
+        assert!(
+            attestation::is_source(sources[index]),
+            error::not_found(EUNKNOWN_SOURCE)
+        );
+        rules.push_back(
+            AttrRule { source: sources[index], key: keys[index], op, values: candidates }
+        );
+        index += 1;
+    };
+
+    let config = &mut Policy[policy];
+    let staged =
+        if (option::is_some(&config.pending)) {
+            option::extract(&mut config.pending)
+        } else {
+            Staged { body: config.body, effective_at_secs: now_seconds() }
+        };
+    staged.body.attr_rules = rules;
+    let effective_at_secs = staged.effective_at_secs;
+    config.pending = option::some(staged);
+    emit(StageBody { policy, effective_at_secs });
+}
+
+ + + +
+ + + +## Function `activate_pending` + +Push the staged body live. Permissionless once its time has arrived, so the business does +not have to be online at the moment its own rule change takes effect. + + +
public entry fun activate_pending(_anyone: &signer, policy: address)
+
+ + + +
+Implementation + + +
public entry fun activate_pending(_anyone: &signer, policy: address) acquires Policy {
+    assert_policy_exists(policy);
+    let config = &mut Policy[policy];
+    assert!(option::is_some(&config.pending), error::invalid_state(ENO_PENDING));
+    let staged = option::extract(&mut config.pending);
+    assert!(
+        now_seconds() >= staged.effective_at_secs,
+        error::invalid_state(ENOT_EFFECTIVE)
+    );
+    config.body = staged.body;
+    emit(ActivateBody { policy, at_secs: now_seconds() });
+}
+
+ + + +
+ + + +## Function `cancel_pending` + +Discard a staged body that has not activated yet. + + +
public entry fun cancel_pending(admin: &signer, policy: address)
+
+ + + +
+Implementation + + +
public entry fun cancel_pending(admin: &signer, policy: address) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let config = &mut Policy[policy];
+    assert!(option::is_some(&config.pending), error::invalid_state(ENO_PENDING));
+    config.pending = option::none();
+    emit(CancelPending { policy });
+}
+
+ + + +
+ + + +## Function `set_step_up` + +Set the amount above which an action needs a fresh authorization. Absent by default, so a +liveness dependency is never enabled by accident. + + +
public entry fun set_step_up(admin: &signer, policy: address, action: u8, threshold: u64)
+
+ + + +
+Implementation + + +
public entry fun set_step_up(
+    admin: &signer, policy: address, action: u8, threshold: u64
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    table::upsert(&mut Policy[policy].step_up_above, action, threshold);
+    emit(SetStepUp { policy, action, threshold });
+}
+
+ + + +
+ + + +## Function `set_authorizer` + +Set the key that signs authorizations for this policy, and the longest window it may +issue. A window of 0 with an empty key disables the step-up path entirely. + + +
public entry fun set_authorizer(admin: &signer, policy: address, pubkey: vector<u8>, max_ttl_secs: u64)
+
+ + + +
+Implementation + + +
public entry fun set_authorizer(
+    admin: &signer, policy: address, pubkey: vector<u8>, max_ttl_secs: u64
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    assert!(
+        pubkey.is_empty() || pubkey.length() == 32,
+        error::invalid_argument(EBAD_AUTHORIZER)
+    );
+    let config = &mut Policy[policy];
+    config.authorizer_pubkey = pubkey;
+    config.authorizer_max_ttl_secs = max_ttl_secs;
+    emit(SetAuthorizer { policy, max_ttl_secs });
+}
+
+ + + +
+ + + +## Function `clear_step_up` + +Stop demanding authorization for an action. + + +
public entry fun clear_step_up(admin: &signer, policy: address, action: u8)
+
+ + + +
+Implementation + + +
public entry fun clear_step_up(admin: &signer, policy: address, action: u8) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let thresholds = &mut Policy[policy].step_up_above;
+    if (table::contains(thresholds, action)) {
+        table::remove(thresholds, action);
+    };
+    emit(SetStepUp { policy, action, threshold: 0 });
+}
+
+ + + +
+ + + +## Function `add_admins` + + + +
public entry fun add_admins(admin: &signer, policy: address, new_admins: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_admins(
+    admin: &signer, policy: address, new_admins: vector<address>
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let config = &mut Policy[policy];
+    add_members(&mut config.admins, &new_admins, policy);
+    emit(AddMembers { policy, role: ROLE_ADMIN, members: new_admins });
+}
+
+ + + +
+ + + +## Function `remove_admins` + + + +
public entry fun remove_admins(admin: &signer, policy: address, old_admins: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_admins(
+    admin: &signer, policy: address, old_admins: vector<address>
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let config = &mut Policy[policy];
+    remove_members(&mut config.admins, &old_admins);
+    assert!(
+        config.admins.length() >= 1,
+        error::invalid_state(EWOULD_REMOVE_ALL_ADMINS)
+    );
+    emit(RemoveMembers { policy, role: ROLE_ADMIN, members: old_admins });
+}
+
+ + + +
+ + + +## Function `add_guardians` + + + +
public entry fun add_guardians(admin: &signer, policy: address, new_guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun add_guardians(
+    admin: &signer, policy: address, new_guardians: vector<address>
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let config = &mut Policy[policy];
+    add_members(&mut config.guardians, &new_guardians, policy);
+    emit(AddMembers { policy, role: ROLE_GUARDIAN, members: new_guardians });
+}
+
+ + + +
+ + + +## Function `remove_guardians` + + + +
public entry fun remove_guardians(admin: &signer, policy: address, old_guardians: vector<address>)
+
+ + + +
+Implementation + + +
public entry fun remove_guardians(
+    admin: &signer, policy: address, old_guardians: vector<address>
+) acquires Policy {
+    assert_admin(policy, address_of(admin));
+    let config = &mut Policy[policy];
+    remove_members(&mut config.guardians, &old_guardians);
+    emit(RemoveMembers { policy, role: ROLE_GUARDIAN, members: old_guardians });
+}
+
+ + + +
+ + + +## Function `pause` + +Deny everything until unpaused. Denies loudly with REASON_POLICY_PAUSED rather than +silently allowing. + + +
public entry fun pause(guardian: &signer, policy: address)
+
+ + + +
+Implementation + + +
public entry fun pause(guardian: &signer, policy: address) acquires Policy {
+    set_paused(guardian, policy, true);
+}
+
+ + + +
+ + + +## Function `unpause` + + + +
public entry fun unpause(guardian: &signer, policy: address)
+
+ + + +
+Implementation + + +
public entry fun unpause(guardian: &signer, policy: address) acquires Policy {
+    set_paused(guardian, policy, false);
+}
+
+ + + +
+ + + +## Function `set_paused` + + + +
fun set_paused(guardian: &signer, policy: address, paused: bool)
+
+ + + +
+Implementation + + +
fun set_paused(guardian: &signer, policy: address, paused: bool) acquires Policy {
+    assert_policy_exists(policy);
+    assert!(
+        Policy[policy].guardians.contains(&address_of(guardian)),
+        error::permission_denied(ENOT_GUARDIAN)
+    );
+    Policy[policy].paused = paused;
+    emit(SetPaused { policy, paused });
+}
+
+ + + +
+ + + +## Function `require` + +The common case, one line inside an entry function. Aborts unless the subject may act +outright. + +@abort EDENIED when the policy refuses, ESTEP_UP_REQUIRED when it wants an authorization. + + +
public fun require(policy: address, subject: address, action: u8, amount: u64)
+
+ + + +
+Implementation + + +
public fun require(
+    policy: address, subject: address, action: u8, amount: u64
+) acquires Policy {
+    let (decision, _) = evaluate(policy, subject, action, amount);
+    assert!(decision != DECISION_DENY, error::permission_denied(EDENIED));
+    assert!(
+        decision != DECISION_STEP_UP,
+        error::permission_denied(ESTEP_UP_REQUIRED)
+    );
+}
+
+ + + +
+ + + +## Function `require_authorized` + +The step-up variant. The authorization is a transaction argument, which is the point: +unlike a view call, which leaves no trace, it lands in the ledger permanently and gives the +business an independently verifiable record of why it allowed this specific action. + +An allow decision consumes nothing, so a caller may always route through this function. + + +
public fun require_authorized(policy: address, subject: address, action: u8, amount: u64, authorization: vector<u8>)
+
+ + + +
+Implementation + + +
public fun require_authorized(
+    policy: address,
+    subject: address,
+    action: u8,
+    amount: u64,
+    authorization: vector<u8>
+) acquires Policy {
+    let (decision, _) = evaluate(policy, subject, action, amount);
+    assert!(decision != DECISION_DENY, error::permission_denied(EDENIED));
+    if (decision == DECISION_ALLOW) {
+        return
+    };
+    let config = &Policy[policy];
+    attestation_authorization::verify_and_consume(
+        policy,
+        config.authorizer_pubkey,
+        config.authorizer_max_ttl_secs,
+        subject,
+        action,
+        amount,
+        authorization
+    );
+}
+
+ + + +
+ + + +## Function `check` + +Non-aborting form, for a caller that wants to branch rather than fail. + + +
public fun check(policy: address, subject: address, action: u8, amount: u64): (u8, u16)
+
+ + + +
+Implementation + + +
public fun check(
+    policy: address, subject: address, action: u8, amount: u64
+): (u8, u16) acquires Policy {
+    evaluate(policy, subject, action, amount)
+}
+
+ + + +
+ + + +## Function `create_policy_account` + + + +
fun create_policy_account(deployer: &signer): (signer, account::SignerCapability)
+
+ + + +
+Implementation + + +
fun create_policy_account(deployer: &signer): (signer, SignerCapability) {
+    let deployer_nonce = account::get_sequence_number(address_of(deployer));
+    account::create_resource_account(
+        deployer, create_policy_seed(to_bytes(&deployer_nonce))
+    )
+}
+
+ + + +
+ + + +## Function `create_policy_seed` + + + +
fun create_policy_seed(seed: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
fun create_policy_seed(seed: vector<u8>): vector<u8> {
+    let account_seed = vector[];
+    account_seed.append(DOMAIN_SEPARATOR);
+    account_seed.append(seed);
+    account_seed
+}
+
+ + + +
+ + + +## Function `empty_body` + + + +
fun empty_body(): attestation_policy::Body
+
+ + + +
+Implementation + + +
fun empty_body(): Body {
+    Body {
+        require_any: vector[],
+        require_all: vector[],
+        deny_any: vector[],
+        attr_rules: vector[],
+        chain_deny: option::none()
+    }
+}
+
+ + + +
+ + + +## Function `build_body` + + + +
fun build_body(require_any_sources: vector<address>, require_any_levels: vector<u8>, require_all_sources: vector<address>, require_all_levels: vector<u8>, deny_any: vector<address>, chain_deny: option::Option<address>): attestation_policy::Body
+
+ + + +
+Implementation + + +
fun build_body(
+    require_any_sources: vector<address>,
+    require_any_levels: vector<u8>,
+    require_all_sources: vector<address>,
+    require_all_levels: vector<u8>,
+    deny_any: vector<address>,
+    chain_deny: Option<address>
+): Body {
+    assert!(
+        require_any_sources.length() <= MAX_SOURCES
+            && require_all_sources.length() <= MAX_SOURCES
+            && deny_any.length() <= MAX_SOURCES,
+        error::invalid_argument(ETOO_MANY_SOURCES)
+    );
+    assert!(
+        require_any_sources.length() == require_any_levels.length()
+            && require_all_sources.length() == require_all_levels.length(),
+        error::invalid_argument(ELENGTH_MISMATCH)
+    );
+
+    // A named source that does not exist would abort at evaluation time, which would brick the
+    // policy for every subject. Reject it at staging time instead, where it is one caller's
+    // problem rather than everyone's.
+    deny_any.for_each_ref(|source| {
+        assert!(attestation::is_source(*source), error::not_found(EUNKNOWN_SOURCE));
+    });
+    if (option::is_some(&chain_deny)) {
+        assert!(
+            attestation::is_source(*option::borrow(&chain_deny)),
+            error::not_found(EUNKNOWN_SOURCE)
+        );
+    };
+
+    Body {
+        require_any: build_source_refs(require_any_sources, require_any_levels),
+        require_all: build_source_refs(require_all_sources, require_all_levels),
+        deny_any,
+        attr_rules: vector[],
+        chain_deny
+    }
+}
+
+ + + +
+ + + +## Function `build_source_refs` + + + +
fun build_source_refs(sources: vector<address>, levels: vector<u8>): vector<attestation_policy::SourceRef>
+
+ + + +
+Implementation + + +
fun build_source_refs(
+    sources: vector<address>, levels: vector<u8>
+): vector<SourceRef> {
+    let refs = vector[];
+    let index = 0;
+    while (index < sources.length()) {
+        assert!(
+            attestation::is_source(sources[index]),
+            error::not_found(EUNKNOWN_SOURCE)
+        );
+        refs.push_back(SourceRef { source: sources[index], min_level: levels[index] });
+        index += 1;
+    };
+    refs
+}
+
+ + + +
+ + + +## Function `eval_attr_rule` + + + +
fun eval_attr_rule(rule: &attestation_policy::AttrRule, subject: address): bool
+
+ + + +
+Implementation + + +
fun eval_attr_rule(rule: &AttrRule, subject: address): bool {
+    let value = attestation::attribute_of(rule.source, subject, rule.key);
+    // An unset attribute satisfies only OP_NOT_IN: a subject the source says nothing about is
+    // not in any list, but neither does it meet a positive requirement.
+    if (value.is_empty()) {
+        return rule.op == OP_NOT_IN
+    };
+    if (rule.op == OP_IN) {
+        rule.values.contains(&value)
+    } else if (rule.op == OP_NOT_IN) {
+        !rule.values.contains(&value)
+    } else if (rule.op == OP_EQ) {
+        value == rule.values[0]
+    } else {
+        gte_bytes(&value, &rule.values[0])
+    }
+}
+
+ + + +
+ + + +## Function `gte_bytes` + +Big-endian unsigned comparison. Values of differing length are not comparable, so the +predicate fails rather than guessing an alignment. + + +
fun gte_bytes(left: &vector<u8>, right: &vector<u8>): bool
+
+ + + +
+Implementation + + +
fun gte_bytes(left: &vector<u8>, right: &vector<u8>): bool {
+    if (left.length() != right.length()) {
+        return false
+    };
+    let index = 0;
+    while (index < left.length()) {
+        if (left[index] > right[index]) {
+            return true
+        };
+        if (left[index] < right[index]) {
+            return false
+        };
+        index += 1;
+    };
+    true
+}
+
+ + + +
+ + + +## Function `validate_members` + + + +
fun validate_members(members: &vector<address>, policy_address: address)
+
+ + + +
+Implementation + + +
fun validate_members(members: &vector<address>, policy_address: address) {
+    let distinct: vector<address> = vector[];
+    members.for_each_ref(|member| {
+        assert!(
+            *member != policy_address,
+            error::invalid_argument(ESELF_CANNOT_BE_MEMBER)
+        );
+        assert!(
+            !distinct.contains(member),
+            error::invalid_argument(EDUPLICATE_MEMBER)
+        );
+        distinct.push_back(*member);
+    });
+}
+
+ + + +
+ + + +## Function `add_members` + + + +
fun add_members(list: &mut vector<address>, new_members: &vector<address>, policy_address: address)
+
+ + + +
+Implementation + + +
fun add_members(
+    list: &mut vector<address>, new_members: &vector<address>, policy_address: address
+) {
+    validate_members(new_members, policy_address);
+    new_members.for_each_ref(|member| {
+        assert!(
+            !list.contains(member),
+            error::invalid_argument(EDUPLICATE_MEMBER)
+        );
+        list.push_back(*member);
+    });
+}
+
+ + + +
+ + + +## Function `remove_members` + + + +
fun remove_members(list: &mut vector<address>, old_members: &vector<address>)
+
+ + + +
+Implementation + + +
fun remove_members(list: &mut vector<address>, old_members: &vector<address>) {
+    old_members.for_each_ref(|member| {
+        let (found, index) = list.index_of(member);
+        if (found) {
+            list.remove(index);
+        };
+    });
+}
+
+ + + +
+ + + +## Function `assert_policy_exists` + + + +
fun assert_policy_exists(policy: address)
+
+ + + +
+Implementation + + +
fun assert_policy_exists(policy: address) {
+    assert!(exists<Policy>(policy), error::not_found(EACCOUNT_NOT_POLICY));
+}
+
+ + + +
+ + + +## Function `assert_admin` + + + +
fun assert_admin(policy: address, addr: address)
+
+ + + +
+Implementation + + +
fun assert_admin(policy: address, addr: address) acquires Policy {
+    assert_policy_exists(policy);
+    assert!(
+        Policy[policy].admins.contains(&addr),
+        error::permission_denied(ENOT_ADMIN)
+    );
+}
+
+ + + +
+ + + +## Specification + + + + + + +### High-level Requirements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.RequirementCriticalityImplementationEnforcement
1A staged body has no effect before its activation time (INV-9). Staging, re-staging and cancelling never change the live body, and only activate_pending swaps it in, once its time has arrived.Criticalstage_body and stage_attr_rules write only Policy.pending. activate_pending asserts that a body is pending and that now_seconds() >= effective_at_secs before replacing Policy.body.Formally verified via stage_body, stage_attr_rules, cancel_pending and activate_pending.
2A denial always produces DECISION_DENY (INV-1). A chain-wide denial is consulted before any positive rule and wins over every vouching source.Criticalevaluate checks the chain denial source first, then every deny_any source, before any require_all or require_any rule.Formally verified for the chain denial via evaluate. The deny_any sources are consulted inside a for_each_ref loop, which the prover havocs, and are covered by unit tests (test_deny_source_beats_a_vouching_source).
3A paused policy denies with its own reason rather than silently allowing, and a policy with no positive rule allows nothing.Highevaluate returns (DECISION_DENY, REASON_POLICY_PAUSED) when paused, and (DECISION_DENY, REASON_EMPTY_BODY) when both require lists are empty.Formally verified via evaluate and require.
4Only an admin can stage, cancel, set step-up thresholds, set the authorizer or manage roles, only a guardian can pause, and a policy always has at least one admin.Criticalassert_admin and the guardian check run first in every entry function, and remove_admins asserts that at least one admin remains.Formally verified via stage_body, set_paused and remove_admins.
5A body can name at most one chain-wide denial source.Mediumstage_body asserts chain_deny has length 0 or 1.Formally verified via stage_body.
+ + + + + +### Module-level Specification + + +
pragma verify = true;
+pragma aborts_if_is_strict = false;
+
+ + + + + + + +
fun spec_now(): u64 {
+   aptos_framework::timestamp::spec_now_seconds()
+}
+
+ + + + + + + +
schema PolicyAdminAbortsIf {
+    policy: address;
+    admin: signer;
+    aborts_if !exists<Policy>(policy);
+    aborts_if !contains(global<Policy>(policy).admins, address_of(admin));
+}
+
+ + + + + +### Function `evaluate` + + +
#[view]
+public fun evaluate(policy: address, subject: address, action: u8, amount: u64): (u8, u16)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Policy>(policy);
+let config = global<Policy>(policy);
+let body = config.body;
+let empty = len(body.require_any) == 0 && len(body.require_all) == 0;
+// This enforces high-level requirement 3:
+ensures config.paused ==> result_1 == DECISION_DENY && result_2 == REASON_POLICY_PAUSED;
+ensures !config.paused && empty ==> result_1 == DECISION_DENY && result_2 == REASON_EMPTY_BODY;
+// This enforces high-level requirement 2:
+ensures !config.paused && !empty && option::is_some(body.chain_deny)
+    && aptos_framework::attestation::spec_is_denied(option::borrow(body.chain_deny), subject)
+    ==> result_1 == DECISION_DENY && result_2 == REASON_CHAIN_DENIED;
+ensures result_1 == DECISION_ALLOW ==> result_2 == REASON_OK;
+ensures result_1 == DECISION_ALLOW || result_1 == DECISION_DENY || result_1 == DECISION_STEP_UP;
+
+ + + + + +### Function `is_paused` + + +
#[view]
+public fun is_paused(policy: address): bool
+
+ + + + +
aborts_if !exists<Policy>(policy);
+ensures result == global<Policy>(policy).paused;
+
+ + + + + +### Function `step_up_for` + + +
#[view]
+public fun step_up_for(policy: address, action: u8): u64
+
+ + + + +
aborts_if !exists<Policy>(policy);
+let thresholds = global<Policy>(policy).step_up_above;
+ensures table::spec_contains(thresholds, action) ==> result == table::spec_get(thresholds, action);
+ensures !table::spec_contains(thresholds, action) ==> result == 0;
+
+ + + + + +### Function `has_pending` + + +
#[view]
+public fun has_pending(policy: address): bool
+
+ + + + +
aborts_if !exists<Policy>(policy);
+ensures result == option::is_some(global<Policy>(policy).pending);
+
+ + + + + +### Function `standard_version` + + +
#[view]
+public fun standard_version(): u64
+
+ + + + +
aborts_if false;
+ensures result == VERSION;
+
+ + + + + +### Function `create` + + +
public entry fun create(deployer: &signer, admins: vector<address>, guardians: vector<address>)
+
+ + + + +
pragma verify = false;
+
+ + + + + +### Function `stage_body` + + +
public entry fun stage_body(admin: &signer, policy: address, require_any_sources: vector<address>, require_any_levels: vector<u8>, require_all_sources: vector<address>, require_all_levels: vector<u8>, deny_any: vector<address>, chain_deny: vector<address>, effective_at_secs: u64)
+
+ + + + +
pragma aborts_if_is_partial;
+// This enforces high-level requirement 4:
+include PolicyAdminAbortsIf;
+// This enforces high-level requirement 5:
+aborts_if len(chain_deny) > 1;
+aborts_if len(require_any_sources) > MAX_SOURCES;
+aborts_if len(require_all_sources) > MAX_SOURCES;
+aborts_if len(deny_any) > MAX_SOURCES;
+aborts_if len(require_any_sources) != len(require_any_levels);
+aborts_if len(require_all_sources) != len(require_all_levels);
+let post config = global<Policy>(policy);
+// This enforces high-level requirement 1:
+ensures config.body == old(global<Policy>(policy).body);
+ensures option::is_some(config.pending);
+ensures option::borrow(config.pending).effective_at_secs == effective_at_secs;
+ensures option::borrow(config.pending).body.deny_any == deny_any;
+ensures len(chain_deny) == 0 ==> option::is_none(option::borrow(config.pending).body.chain_deny);
+ensures len(chain_deny) == 1 ==> option::borrow(config.pending).body.chain_deny == option::spec_some(chain_deny[0]);
+
+ + + + + +### Function `stage_attr_rules` + + +
public entry fun stage_attr_rules(admin: &signer, policy: address, sources: vector<address>, keys: vector<u16>, ops: vector<u8>, values: vector<vector<vector<u8>>>)
+
+ + + + +
pragma aborts_if_is_partial;
+include PolicyAdminAbortsIf;
+aborts_if len(sources) > MAX_RULES;
+aborts_if len(sources) != len(keys) || len(sources) != len(ops) || len(sources) != len(values);
+// This enforces high-level requirement 1:
+ensures global<Policy>(policy).body == old(global<Policy>(policy).body);
+ensures option::is_some(global<Policy>(policy).pending);
+
+ + + + + +### Function `activate_pending` + + +
public entry fun activate_pending(_anyone: &signer, policy: address)
+
+ + + + +
let config = global<Policy>(policy);
+aborts_if !exists<Policy>(policy);
+aborts_if option::is_none(config.pending);
+aborts_if !exists<aptos_framework::timestamp::CurrentTimeMicroseconds>(@aptos_framework);
+// This enforces high-level requirement 1:
+aborts_if spec_now() < option::borrow(config.pending).effective_at_secs;
+ensures global<Policy>(policy).body == option::borrow(config.pending).body;
+ensures option::is_none(global<Policy>(policy).pending);
+
+ + + + + +### Function `cancel_pending` + + +
public entry fun cancel_pending(admin: &signer, policy: address)
+
+ + + + +
include PolicyAdminAbortsIf;
+aborts_if option::is_none(global<Policy>(policy).pending);
+// This enforces high-level requirement 1:
+ensures global<Policy>(policy).body == old(global<Policy>(policy).body);
+ensures option::is_none(global<Policy>(policy).pending);
+
+ + + + + +### Function `set_step_up` + + +
public entry fun set_step_up(admin: &signer, policy: address, action: u8, threshold: u64)
+
+ + + + +
include PolicyAdminAbortsIf;
+ensures table::spec_get(global<Policy>(policy).step_up_above, action) == threshold;
+ensures global<Policy>(policy).body == old(global<Policy>(policy).body);
+
+ + + + + +### Function `set_authorizer` + + +
public entry fun set_authorizer(admin: &signer, policy: address, pubkey: vector<u8>, max_ttl_secs: u64)
+
+ + + + +
include PolicyAdminAbortsIf;
+aborts_if len(pubkey) != 0 && len(pubkey) != 32;
+ensures global<Policy>(policy).authorizer_pubkey == pubkey;
+ensures global<Policy>(policy).authorizer_max_ttl_secs == max_ttl_secs;
+
+ + + + + +### Function `remove_admins` + + +
public entry fun remove_admins(admin: &signer, policy: address, old_admins: vector<address>)
+
+ + + + +
pragma aborts_if_is_partial;
+include PolicyAdminAbortsIf;
+// This enforces high-level requirement 4:
+ensures len(global<Policy>(policy).admins) >= 1;
+
+ + + + + +### Function `set_paused` + + +
fun set_paused(guardian: &signer, policy: address, paused: bool)
+
+ + + + +
aborts_if !exists<Policy>(policy);
+// This enforces high-level requirement 4:
+aborts_if !contains(global<Policy>(policy).guardians, address_of(guardian));
+ensures global<Policy>(policy).paused == paused;
+ensures global<Policy>(policy).body == old(global<Policy>(policy).body);
+ensures global<Policy>(policy).pending == old(global<Policy>(policy).pending);
+
+ + + + + +### Function `require` + + +
public fun require(policy: address, subject: address, action: u8, amount: u64)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Policy>(policy);
+// This enforces high-level requirement 3:
+aborts_if global<Policy>(policy).paused;
+aborts_if len(global<Policy>(policy).body.require_any) == 0
+    && len(global<Policy>(policy).body.require_all) == 0;
+
+ + +[move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/doc/merkle_proof.md b/aptos-move/framework/aptos-framework/doc/merkle_proof.md new file mode 100644 index 00000000000..3a6d165485a --- /dev/null +++ b/aptos-move/framework/aptos-framework/doc/merkle_proof.md @@ -0,0 +1,387 @@ + + + +# Module `0x1::merkle_proof` + +OpenZeppelin-compatible Merkle membership verification. + +Matches @openzeppelin/merkle-tree and OpenZeppelin's MerkleProof.sol exactly: +leaf = keccak256(keccak256(preimage)) (standardLeafHash) +node = keccak256(min(a,b) || max(a,b)) (standardNodeHash / commutativeKeccak256) + +Sorted-pair hashing is what removes the leaf index from the API: the verifier never +needs to know whether a sibling is on the left or the right. The double-hashed leaf is +what replaces domain-separation prefixes; without it, the concatenation of a sorted pair +of internal nodes can be reinterpreted as a leaf. That is the 64-byte hazard +MerkleProof.sol warns about, and it is the whole reason the leaf is hashed twice. + +Written for Move 2.1: no resource index syntax, explicit vector:: calls. + + +- [Constants](#@Constants_0) +- [Function `hash_pair`](#0x1_merkle_proof_hash_pair) +- [Function `leaf_hash`](#0x1_merkle_proof_leaf_hash) +- [Function `process_proof`](#0x1_merkle_proof_process_proof) +- [Function `verify`](#0x1_merkle_proof_verify) +- [Function `subject_leaf`](#0x1_merkle_proof_subject_leaf) +- [Specification](#@Specification_1) + - [High-level Requirements](#high-level-req) + - [Module-level Specification](#module-level-spec) + - [Function `leaf_hash`](#@Specification_1_leaf_hash) + - [Function `process_proof`](#@Specification_1_process_proof) + - [Function `verify`](#@Specification_1_verify) + - [Function `subject_leaf`](#@Specification_1_subject_leaf) + + +
use 0x1::aptos_hash;
+use 0x1::bcs;
+use 0x1::comparator;
+use 0x1::error;
+use 0x1::vector;
+
+ + + + + +## Constants + + + + +A leaf or sibling is not exactly 32 bytes. + + +
const E_BAD_DIGEST_LEN: u64 = 2;
+
+ + + + + +The proof has more than MAX_PROOF_LEN siblings. + + +
const E_PROOF_TOO_LONG: u64 = 1;
+
+ + + + + +Proof length cap. OpenZeppelin trees are complete but not perfect, so leaves sit at +two different depths and proof length varies per leaf. Exact-depth checks are +therefore not available; cap instead. 32 covers 2^32 leaves. + + +
const MAX_PROOF_LEN: u64 = 32;
+
+ + + + + +## Function `hash_pair` + +commutativeKeccak256: sort the pair, concatenate, hash the 64 bytes. + + +
fun hash_pair(a: vector<u8>, b: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
fun hash_pair(a: vector<u8>, b: vector<u8>): vector<u8> {
+    let cmp = comparator::compare_u8_vector(copy a, copy b);
+    let buf = if (comparator::is_smaller_than(&cmp)) {
+        let t = a;
+        vector::append(&mut t, b);
+        t
+    } else {
+        let t = b;
+        vector::append(&mut t, a);
+        t
+    };
+    aptos_hash::keccak256(buf)
+}
+
+ + + +
+ + + +## Function `leaf_hash` + +standardLeafHash. preimage is the ABI encoding of the leaf tuple. Keep every field +a 32-byte value (bytes32 on the JavaScript side) so plain concatenation here matches +abi.encode there; see the note on encoding at the bottom of this file. + + +
public fun leaf_hash(preimage: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
public fun leaf_hash(preimage: vector<u8>): vector<u8> {
+    aptos_hash::keccak256(aptos_hash::keccak256(preimage))
+}
+
+ + + +
+ + + +## Function `process_proof` + +MerkleProof.processProof: fold the leaf upward through the sibling hashes. + + +
public fun process_proof(leaf: vector<u8>, proof: vector<vector<u8>>): vector<u8>
+
+ + + +
+Implementation + + +
public fun process_proof(leaf: vector<u8>, proof: vector<vector<u8>>): vector<u8> {
+    assert!(
+        vector::length(&proof) <= MAX_PROOF_LEN,
+        error::invalid_argument(E_PROOF_TOO_LONG)
+    );
+    assert!(vector::length(&leaf) == 32, error::invalid_argument(E_BAD_DIGEST_LEN));
+    let computed = leaf;
+    let i = 0;
+    let n = vector::length(&proof);
+    while ({
+        spec {
+            invariant n == len(proof);
+            invariant i <= n;
+            invariant i == 0 ==> computed == leaf;
+        };
+        i < n
+    }) {
+        let sibling = *vector::borrow(&proof, i);
+        assert!(
+            vector::length(&sibling) == 32,
+            error::invalid_argument(E_BAD_DIGEST_LEN)
+        );
+        computed = hash_pair(computed, sibling);
+        i = i + 1;
+    };
+    computed
+}
+
+ + + +
+ + + +## Function `verify` + +MerkleProof.verify. + + +
public fun verify(root: vector<u8>, leaf: vector<u8>, proof: vector<vector<u8>>): bool
+
+ + + +
+Implementation + + +
public fun verify(root: vector<u8>, leaf: vector<u8>, proof: vector<vector<u8>>): bool {
+    process_proof(leaf, proof) == root
+}
+
+ + + +
+ + + +## Function `subject_leaf` + +Bind the leaf to this registry so a proof issued for one registry cannot be replayed +against another that happens to adopt the same root. Both fields are 32 bytes, so the +preimage is bytes32 || bytes32 and the JS side is +StandardMerkleTree.of([[registry, subject]], ['bytes32','bytes32']) + +Deliberately NOT included: the epoch. Leaving it out lets the registry accept the +previous root during a rotation grace window without recomputing the leaf. + + +
public fun subject_leaf(registry: address, subject: address): vector<u8>
+
+ + + +
+Implementation + + +
public fun subject_leaf(registry: address, subject: address): vector<u8> {
+    let buf = std::bcs::to_bytes(®istry);
+    vector::append(&mut buf, std::bcs::to_bytes(&subject));
+    leaf_hash(buf)
+}
+
+ + + +
+ + + +## Specification + + + + + + +### High-level Requirements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.RequirementCriticalityImplementationEnforcement
1Leaves are double hashed, as in OpenZeppelin's standardLeafHash, so no 64-byte leaf preimage can be confused with an internal node.Criticalleaf_hash applies keccak256 twice; subject_leaf builds the preimage as bcs(registry) || bcs(subject), both 32 bytes, and hashes it with leaf_hash.Formally verified that leaf_hash and subject_leaf never abort. The hash values themselves are pinned against @openzeppelin/merkle-tree vectors in unit tests: the keccak256 native's abstract spec does not propagate through these call sites, so an ensures over spec_keccak256 cannot be proven here.
2Internal nodes hash the sorted pair, as in OpenZeppelin's commutativeKeccak256, so the verifier needs no leaf index.Highhash_pair concatenates the smaller operand first and hashes the 64 bytes.Audited in unit tests (test_two_leaf_tree checks commutativity, test_leaf_is_not_an_internal_node checks a node against OpenZeppelin). Not formally specified, for the same keccak256 reason as requirement 1.
3Proofs are bounded, and every leaf and sibling is a 32-byte digest.Mediumprocess_proof asserts len(proof) <= MAX_PROOF_LEN and a 32-byte leaf before folding, and a 32-byte sibling at each step.Formally verified via process_proof for the proof length and leaf length. The per-sibling check sits inside the fold loop, which the prover havocs, and is covered by unit tests.
4An empty proof verifies exactly when the leaf is the root, which is the one-leaf tree.Mediumprocess_proof returns the leaf unchanged when the proof is empty.Formally verified via verify.
+ + + + + +### Module-level Specification + + +
pragma verify = true;
+pragma aborts_if_is_strict = false;
+
+ + + + + +### Function `leaf_hash` + + +
public fun leaf_hash(preimage: vector<u8>): vector<u8>
+
+ + + + +
// This enforces high-level requirement 1:
+aborts_if false;
+
+ + + + + +### Function `process_proof` + + +
public fun process_proof(leaf: vector<u8>, proof: vector<vector<u8>>): vector<u8>
+
+ + + + +
pragma aborts_if_is_partial;
+// This enforces high-level requirement 3:
+aborts_if len(proof) > MAX_PROOF_LEN;
+aborts_if len(leaf) != 32;
+ensures len(proof) == 0 ==> result == leaf;
+
+ + + + + +### Function `verify` + + +
public fun verify(root: vector<u8>, leaf: vector<u8>, proof: vector<vector<u8>>): bool
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if len(proof) > MAX_PROOF_LEN;
+aborts_if len(leaf) != 32;
+// This enforces high-level requirement 4:
+ensures len(proof) == 0 ==> result == (leaf == root);
+
+ + + + + +### Function `subject_leaf` + + +
public fun subject_leaf(registry: address, subject: address): vector<u8>
+
+ + + + +
// This enforces high-level requirement 1:
+aborts_if false;
+
+ + +[move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/doc/overview.md b/aptos-move/framework/aptos-framework/doc/overview.md index b93066b72c1..9daf0613baa 100644 --- a/aptos-move/framework/aptos-framework/doc/overview.md +++ b/aptos-move/framework/aptos-framework/doc/overview.md @@ -25,6 +25,9 @@ This is the reference documentation of the Aptos framework. - [`0x1::atomic_bridge_counterparty`](atomic_bridge.md#0x1_atomic_bridge_counterparty) - [`0x1::atomic_bridge_initiator`](atomic_bridge.md#0x1_atomic_bridge_initiator) - [`0x1::atomic_bridge_store`](atomic_bridge.md#0x1_atomic_bridge_store) +- [`0x1::attestation`](attestation.md#0x1_attestation) +- [`0x1::attestation_authorization`](attestation_authorization.md#0x1_attestation_authorization) +- [`0x1::attestation_policy`](attestation_policy.md#0x1_attestation_policy) - [`0x1::auth_data`](auth_data.md#0x1_auth_data) - [`0x1::base16`](base16.md#0x1_base16) - [`0x1::big_ordered_map`](big_ordered_map.md#0x1_big_ordered_map) @@ -55,6 +58,7 @@ This is the reference documentation of the Aptos framework. - [`0x1::jwks`](jwks.md#0x1_jwks) - [`0x1::keyless_account`](keyless_account.md#0x1_keyless_account) - [`0x1::managed_coin`](managed_coin.md#0x1_managed_coin) +- [`0x1::merkle_proof`](merkle_proof.md#0x1_merkle_proof) - [`0x1::multisig_account`](multisig_account.md#0x1_multisig_account) - [`0x1::native_bridge`](native_bridge.md#0x1_native_bridge) - [`0x1::nonce_validation`](nonce_validation.md#0x1_nonce_validation) @@ -91,6 +95,7 @@ This is the reference documentation of the Aptos framework. - [`0x1::version`](version.md#0x1_version) - [`0x1::vesting`](vesting.md#0x1_vesting) - [`0x1::voting`](voting.md#0x1_voting) +- [`0x1::zktls`](zktls.md#0x1_zktls) [move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/doc/zktls.md b/aptos-move/framework/aptos-framework/doc/zktls.md new file mode 100644 index 00000000000..3a7db424ed5 --- /dev/null +++ b/aptos-move/framework/aptos-framework/doc/zktls.md @@ -0,0 +1,1853 @@ + + + +# Module `0x1::zktls` + +Onchain verification of zkTLS attestations, at enrollment. + +The chain verifies the attestation itself: it recomputes the claim digest with keccak256 and +recovers one signer per signature with secp256k1::ecdsa_recover against an epoch-registered +attestor set, rejecting duplicates and requiring a threshold. Both primitives are ungated +natives, so this module needs no Rust change, no new native and no feature flag. The shape is +borrowed from Reclaim's onchain verifier, which contains no zero-knowledge verification at all: +it is a digest recomputation plus N public-key recoveries plus a threshold and a duplicate +check. + +This is ENROLLMENT and must never sit in a per-action path. A proxy or MPC-TLS session takes +seconds, needs the user to log into the provider inside the flow, and depends on a provider +template that breaks when the provider changes its markup. Verify once at the boundary, write a +fact through aptos_framework::attestation, then read the fact from then on. The failure mode +of a broken template is then that new enrollment degrades, not that existing subjects break. + +Known offchain dependency, stated plainly because it cannot be fixed here: template_id is the +hash of a provider config (the request URL, the response regex, the redaction rules) that lives +in the provider's own registry. An attestor cannot run a session without resolving that hash to +the config, so if the provider delists it the onchain template becomes a dead pointer that +governance cannot repair. + + +- [Struct `AttestorSet`](#0x1_zktls_AttestorSet) +- [Struct `Template`](#0x1_zktls_Template) +- [Resource `Verifier`](#0x1_zktls_Verifier) +- [Struct `SetAttestorSet`](#0x1_zktls_SetAttestorSet) +- [Struct `RegisterTemplate`](#0x1_zktls_RegisterTemplate) +- [Struct `RevokeTemplate`](#0x1_zktls_RevokeTemplate) +- [Struct `Enroll`](#0x1_zktls_Enroll) +- [Constants](#@Constants_0) +- [Function `is_initialized`](#0x1_zktls_is_initialized) +- [Function `current_epoch`](#0x1_zktls_current_epoch) +- [Function `attestors`](#0x1_zktls_attestors) +- [Function `threshold`](#0x1_zktls_threshold) +- [Function `is_epoch_accepted`](#0x1_zktls_is_epoch_accepted) +- [Function `is_claim_consumed`](#0x1_zktls_is_claim_consumed) +- [Function `is_template_active`](#0x1_zktls_is_template_active) +- [Function `claim_digest`](#0x1_zktls_claim_digest) +- [Function `recover_attestor`](#0x1_zktls_recover_attestor) +- [Function `verify_claim`](#0x1_zktls_verify_claim) +- [Function `initialize`](#0x1_zktls_initialize) +- [Function `set_attestor_set`](#0x1_zktls_set_attestor_set) +- [Function `register_template`](#0x1_zktls_register_template) +- [Function `revoke_template`](#0x1_zktls_revoke_template) +- [Function `enroll`](#0x1_zktls_enroll) +- [Function `verify_claim_internal`](#0x1_zktls_verify_claim_internal) +- [Function `epoch_accepted`](#0x1_zktls_epoch_accepted) +- [Function `claim_binds`](#0x1_zktls_claim_binds) +- [Function `contains_bytes`](#0x1_zktls_contains_bytes) +- [Function `lowercase_hex`](#0x1_zktls_lowercase_hex) +- [Function `decimal_bytes`](#0x1_zktls_decimal_bytes) +- [Function `assert_initialized`](#0x1_zktls_assert_initialized) +- [Specification](#@Specification_1) + - [High-level Requirements](#high-level-req) + - [Module-level Specification](#module-level-spec) + - [Function `is_initialized`](#@Specification_1_is_initialized) + - [Function `current_epoch`](#@Specification_1_current_epoch) + - [Function `threshold`](#@Specification_1_threshold) + - [Function `is_epoch_accepted`](#@Specification_1_is_epoch_accepted) + - [Function `is_claim_consumed`](#@Specification_1_is_claim_consumed) + - [Function `is_template_active`](#@Specification_1_is_template_active) + - [Function `verify_claim`](#@Specification_1_verify_claim) + - [Function `set_attestor_set`](#@Specification_1_set_attestor_set) + - [Function `register_template`](#@Specification_1_register_template) + - [Function `revoke_template`](#@Specification_1_revoke_template) + - [Function `enroll`](#@Specification_1_enroll) + - [Function `epoch_accepted`](#@Specification_1_epoch_accepted) + + +
use 0x1::aptos_hash;
+use 0x1::attestation;
+use 0x1::bcs;
+use 0x1::error;
+use 0x1::event;
+use 0x1::option;
+use 0x1::secp256k1;
+use 0x1::signer;
+use 0x1::table;
+use 0x1::timestamp;
+use 0x1::vector;
+
+ + + + + +## Struct `AttestorSet` + +A set of attestors and how many of them must sign. + + +
struct AttestorSet has copy, drop, store
+
+ + + +
+Fields + + +
+
+attestors: vector<vector<u8>> +
+
+ +
+
+threshold: u64 +
+
+ +
+
+ + +
+ + + +## Struct `Template` + +What a successful claim under one provider template entitles the subject to. + + +
struct Template has copy, drop, store
+
+ + + +
+Fields + + +
+
+template_id: vector<u8> +
+
+ +
+
+grants_level: u8 +
+
+ +
+
+ttl_secs: u64 +
+
+ +
+
+active: bool +
+
+ +
+
+ + +
+ + + +## Resource `Verifier` + +Stored under the source's resource account address. + + +
struct Verifier has key
+
+ + + +
+Fields + + +
+
+sets: table::Table<u64, zktls::AttestorSet> +
+
+ +
+
+current_epoch: u64 +
+
+ +
+
+previous_deadline_secs: u64 +
+
+ +
+
+templates: table::Table<vector<u8>, zktls::Template> +
+
+ +
+
+consumed: table::Table<vector<u8>, bool> +
+
+ +
+
+ + +
+ + + +## Struct `SetAttestorSet` + + + +
#[event]
+struct SetAttestorSet has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+epoch: u64 +
+
+ +
+
+threshold: u64 +
+
+ +
+
+count: u64 +
+
+ +
+
+ + +
+ + + +## Struct `RegisterTemplate` + + + +
#[event]
+struct RegisterTemplate has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+template_id: vector<u8> +
+
+ +
+
+grants_level: u8 +
+
+ +
+
+ + +
+ + + +## Struct `RevokeTemplate` + + + +
#[event]
+struct RevokeTemplate has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+template_id: vector<u8> +
+
+ +
+
+ + +
+ + + +## Struct `Enroll` + + + +
#[event]
+struct Enroll has drop, store
+
+ + + +
+Fields + + +
+
+source: address +
+
+ +
+
+subject: address +
+
+ +
+
+template_id: vector<u8> +
+
+ +
+
+signers: u64 +
+
+ +
+
+ + +
+ + + +## Constants + + + + + + +
const MAX_U64: u64 = 18446744073709551615;
+
+ + + + + +A signature could not be recovered. + + +
const EBAD_SIGNATURE: u64 = 9;
+
+ + + + + +The caller is not an admin of the source. + + +
const ENOT_ADMIN: u64 = 1;
+
+ + + + + +r (32) plus s (32) plus the recovery id (1). + + +
const SIGNATURE_LENGTH: u64 = 65;
+
+ + + + + +A signature must be exactly 65 bytes: r, s, then the recovery id. + + +
const EBAD_SIGNATURE_LENGTH: u64 = 14;
+
+ + + + + +This source has no verifier configured. + + +
const ENOT_INITIALIZED: u64 = 12;
+
+ + + + + +Ethereum-style attestor address length. + + +
const ATTESTOR_LENGTH: u64 = 20;
+
+ + + + + +An attestor address must be exactly 20 bytes. + + +
const EBAD_ATTESTOR_LENGTH: u64 = 13;
+
+ + + + + +The threshold must be at least 1 and at most the attestor count. + + +
const EBAD_THRESHOLD: u64 = 10;
+
+ + + + + +Fewer distinct attestors signed than the threshold requires. + + +
const EBELOW_THRESHOLD: u64 = 4;
+
+ + + + + +This claim has already been used to enroll. + + +
const ECLAIM_CONSUMED: u64 = 16;
+
+ + + + + +The attestor list cannot contain duplicates. + + +
const EDUPLICATE_ATTESTOR: u64 = 11;
+
+ + + + + +The same attestor signed twice. + + +
const EDUPLICATE_SIGNER: u64 = 5;
+
+ + + + + +The claim does not bind this subject and this template. + + +
const EMALFORMED_CLAIM: u64 = 8;
+
+ + + + + +The attestor epoch has been rotated away and its grace window has ended. + + +
const ERETIRED_ATTESTOR_EPOCH: u64 = 15;
+
+ + + + + +The template has been revoked. + + +
const ETEMPLATE_REVOKED: u64 = 3;
+
+ + + + + +The prefix Ethereum wallets and attestor networks apply before signing. + + +
const ETH_PREFIX: vector<u8> = [25, 69, 116, 104, 101, 114, 101, 117, 109, 32, 83, 105, 103, 110, 101, 100, 32, 77, 101, 115, 115, 97, 103, 101, 58, 10];
+
+ + + + + +A recovered signer is not in the registered attestor set. + + +
const EUNKNOWN_ATTESTOR: u64 = 6;
+
+ + + + + +No attestor set is registered for the requested epoch. + + +
const EUNKNOWN_ATTESTOR_EPOCH: u64 = 7;
+
+ + + + + +No template is registered under the given id. + + +
const EUNKNOWN_TEMPLATE: u64 = 2;
+
+ + + + + +Largest number of signatures one claim may carry. + + +
const MAX_SIGNATURES: u64 = 16;
+
+ + + + + +## Function `is_initialized` + + + +
#[view]
+public fun is_initialized(source: address): bool
+
+ + + +
+Implementation + + +
public fun is_initialized(source: address): bool {
+    exists<Verifier>(source)
+}
+
+ + + +
+ + + +## Function `current_epoch` + + + +
#[view]
+public fun current_epoch(source: address): u64
+
+ + + +
+Implementation + + +
public fun current_epoch(source: address): u64 acquires Verifier {
+    assert_initialized(source);
+    Verifier[source].current_epoch
+}
+
+ + + +
+ + + +## Function `attestors` + +Attestor addresses registered for an epoch. + + +
#[view]
+public fun attestors(source: address, epoch: u64): vector<vector<u8>>
+
+ + + +
+Implementation + + +
public fun attestors(source: address, epoch: u64): vector<vector<u8>> acquires Verifier {
+    assert_initialized(source);
+    let verifier = &Verifier[source];
+    assert!(
+        table::contains(&verifier.sets, epoch),
+        error::not_found(EUNKNOWN_ATTESTOR_EPOCH)
+    );
+    table::borrow(&verifier.sets, epoch).attestors
+}
+
+ + + +
+ + + +## Function `threshold` + + + +
#[view]
+public fun threshold(source: address, epoch: u64): u64
+
+ + + +
+Implementation + + +
public fun threshold(source: address, epoch: u64): u64 acquires Verifier {
+    assert_initialized(source);
+    let verifier = &Verifier[source];
+    assert!(
+        table::contains(&verifier.sets, epoch),
+        error::not_found(EUNKNOWN_ATTESTOR_EPOCH)
+    );
+    table::borrow(&verifier.sets, epoch).threshold
+}
+
+ + + +
+ + + +## Function `is_epoch_accepted` + +Whether claims signed by the given epoch's attestor set are accepted right now. + + +
#[view]
+public fun is_epoch_accepted(source: address, epoch: u64): bool
+
+ + + +
+Implementation + + +
public fun is_epoch_accepted(source: address, epoch: u64): bool acquires Verifier {
+    if (!exists<Verifier>(source)) {
+        return false
+    };
+    epoch_accepted(&Verifier[source], epoch)
+}
+
+ + + +
+ + + +## Function `is_claim_consumed` + +Whether a claim has already been used to enroll in this source. + + +
#[view]
+public fun is_claim_consumed(source: address, claim: vector<u8>): bool
+
+ + + +
+Implementation + + +
public fun is_claim_consumed(source: address, claim: vector<u8>): bool acquires Verifier {
+    exists<Verifier>(source)
+        && table::contains(&Verifier[source].consumed, keccak256(claim))
+}
+
+ + + +
+ + + +## Function `is_template_active` + + + +
#[view]
+public fun is_template_active(source: address, template_id: vector<u8>): bool
+
+ + + +
+Implementation + + +
public fun is_template_active(source: address, template_id: vector<u8>): bool acquires Verifier {
+    if (!exists<Verifier>(source)) {
+        return false
+    };
+    let templates = &Verifier[source].templates;
+    table::contains(templates, template_id)
+        && table::borrow(templates, template_id).active
+}
+
+ + + +
+ + + +## Function `claim_digest` + +The digest an attestor signs: keccak256 over the Ethereum-prefixed claim. Published so an +attestor implementation can be checked against this module without reading it. + + +
#[view]
+public fun claim_digest(claim: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
public fun claim_digest(claim: vector<u8>): vector<u8> {
+    let prefixed = vector[];
+    prefixed.append(ETH_PREFIX);
+    prefixed.append(decimal_bytes(claim.length()));
+    prefixed.append(claim);
+    keccak256(prefixed)
+}
+
+ + + +
+ + + +## Function `recover_attestor` + +Recover the 20-byte attestor address that produced a signature over a claim. + + +
#[view]
+public fun recover_attestor(claim: vector<u8>, signature: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
public fun recover_attestor(claim: vector<u8>, signature: vector<u8>): vector<u8> {
+    assert!(
+        signature.length() == SIGNATURE_LENGTH,
+        error::invalid_argument(EBAD_SIGNATURE_LENGTH)
+    );
+    let digest = claim_digest(claim);
+    // Ethereum wallets and attestor networks emit v as 27 or 28; the native takes 0 to 3.
+    let v = signature[SIGNATURE_LENGTH - 1];
+    let recovery_id = if (v >= 27) { v - 27 } else { v };
+    assert!(recovery_id < 4, error::invalid_argument(EBAD_SIGNATURE));
+    let rs = vector[];
+    let index = 0;
+    while (index < SIGNATURE_LENGTH - 1) {
+        rs.push_back(signature[index]);
+        index += 1;
+    };
+    let recovered =
+        secp256k1::ecdsa_recover(
+            digest, recovery_id, &secp256k1::ecdsa_signature_from_bytes(rs)
+        );
+    assert!(option::is_some(&recovered), error::invalid_argument(EBAD_SIGNATURE));
+    let pubkey = secp256k1::ecdsa_raw_public_key_to_bytes(option::borrow(&recovered));
+    // Ethereum address: the low 20 bytes of keccak256 over the 64-byte public key.
+    let hashed = keccak256(pubkey);
+    let address_bytes = vector[];
+    let position = 12;
+    while (position < 32) {
+        address_bytes.push_back(hashed[position]);
+        position += 1;
+    };
+    address_bytes
+}
+
+ + + +
+ + + +## Function `verify_claim` + +Whether enroll would accept this claim right now, without writing anything: the +signatures meet the threshold, the template is active and the claim is unused. Aborts on +the same malformed inputs enroll aborts on. + + +
#[view]
+public fun verify_claim(source: address, template_id: vector<u8>, subject: address, claim: vector<u8>, signatures: vector<vector<u8>>, attestor_epoch: u64): bool
+
+ + + +
+Implementation + + +
public fun verify_claim(
+    source: address,
+    template_id: vector<u8>,
+    subject: address,
+    claim: vector<u8>,
+    signatures: vector<vector<u8>>,
+    attestor_epoch: u64
+): bool acquires Verifier {
+    let (met, active, consumed, _) =
+        verify_claim_internal(
+            source, template_id, subject, claim, signatures, attestor_epoch
+        );
+    met && active && !consumed
+}
+
+ + + +
+ + + +## Function `initialize` + +Create the verifier for a source. Requires an admin of that source, and obtains the +source's resource-account signer through attestation's friend accessor. + + +
public entry fun initialize(admin: &signer, source: address)
+
+ + + +
+Implementation + + +
public entry fun initialize(admin: &signer, source: address) {
+    assert!(
+        attestation::is_admin(address_of(admin), source),
+        error::permission_denied(ENOT_ADMIN)
+    );
+    let source_signer = attestation::source_signer(source);
+    move_to(
+        &source_signer,
+        Verifier {
+            sets: table::new<u64, AttestorSet>(),
+            current_epoch: 0,
+            previous_deadline_secs: 0,
+            templates: table::new<vector<u8>, Template>(),
+            consumed: table::new<vector<u8>, bool>()
+        }
+    );
+}
+
+ + + +
+ + + +## Function `set_attestor_set` + +Register a new attestor set under the next epoch. The set it replaces keeps verifying for +previous_grace_secs, so a claim signed moments before the rotation still verifies; every +older set stops verifying immediately. Rotating away a compromised set with a zero grace +window cuts it off at once. + +@param admin An admin of the source. +@param source The source address. +@param attestor_addresses 20-byte Ethereum-style addresses, no duplicates. +@param required How many distinct attestors must sign. At least 1, at most the count. +@param previous_grace_secs How long the replaced set keeps verifying. 0 for no grace. + + +
public entry fun set_attestor_set(admin: &signer, source: address, attestor_addresses: vector<vector<u8>>, required: u64, previous_grace_secs: u64)
+
+ + + +
+Implementation + + +
public entry fun set_attestor_set(
+    admin: &signer,
+    source: address,
+    attestor_addresses: vector<vector<u8>>,
+    required: u64,
+    previous_grace_secs: u64
+) acquires Verifier {
+    assert!(
+        attestation::is_admin(address_of(admin), source),
+        error::permission_denied(ENOT_ADMIN)
+    );
+    assert_initialized(source);
+    let count = attestor_addresses.length();
+    assert!(
+        required >= 1 && required <= count,
+        error::invalid_argument(EBAD_THRESHOLD)
+    );
+    let seen: vector<vector<u8>> = vector[];
+    attestor_addresses.for_each_ref(|attestor| {
+        assert!(
+            attestor.length() == ATTESTOR_LENGTH,
+            error::invalid_argument(EBAD_ATTESTOR_LENGTH)
+        );
+        assert!(
+            !seen.contains(attestor),
+            error::invalid_argument(EDUPLICATE_ATTESTOR)
+        );
+        seen.push_back(*attestor);
+    });
+
+    let now = now_seconds();
+    let verifier = &mut Verifier[source];
+    let epoch = verifier.current_epoch + 1;
+    verifier.current_epoch = epoch;
+    verifier.previous_deadline_secs =
+        if (previous_grace_secs > MAX_U64 - now) { MAX_U64 }
+        else { now + previous_grace_secs };
+    table::add(
+        &mut verifier.sets,
+        epoch,
+        AttestorSet { attestors: attestor_addresses, threshold: required }
+    );
+    emit(SetAttestorSet { source, epoch, threshold: required, count });
+}
+
+ + + +
+ + + +## Function `register_template` + +Allow a provider template and say what a claim under it grants. + + +
public entry fun register_template(admin: &signer, source: address, template_id: vector<u8>, grants_level: u8, ttl_secs: u64)
+
+ + + +
+Implementation + + +
public entry fun register_template(
+    admin: &signer,
+    source: address,
+    template_id: vector<u8>,
+    grants_level: u8,
+    ttl_secs: u64
+) acquires Verifier {
+    assert!(
+        attestation::is_admin(address_of(admin), source),
+        error::permission_denied(ENOT_ADMIN)
+    );
+    assert_initialized(source);
+    table::upsert(
+        &mut Verifier[source].templates,
+        template_id,
+        Template { template_id, grants_level, ttl_secs, active: true }
+    );
+    emit(RegisterTemplate { source, template_id, grants_level });
+}
+
+ + + +
+ + + +## Function `revoke_template` + +Stop accepting new claims under a template. Facts already recorded are untouched; use +attestation::bump_issuer_epoch with issuer id 0, which invalidates every zkTLS +enrollment in the source, or a denial per subject for those. + + +
public entry fun revoke_template(admin: &signer, source: address, template_id: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun revoke_template(
+    admin: &signer, source: address, template_id: vector<u8>
+) acquires Verifier {
+    assert!(
+        attestation::is_admin(address_of(admin), source),
+        error::permission_denied(ENOT_ADMIN)
+    );
+    assert_initialized(source);
+    let templates = &mut Verifier[source].templates;
+    assert!(
+        table::contains(templates, template_id),
+        error::not_found(EUNKNOWN_TEMPLATE)
+    );
+    table::borrow_mut(templates, template_id).active = false;
+    emit(RevokeTemplate { source, template_id });
+}
+
+ + + +
+ + + +## Function `enroll` + +Submit a verified claim about yourself. No issuer key is involved on this path: the trust +root is the attestor set plus the provider's TLS certificate, not an operator holding a key. + +@param user The subject. Must be the address the claim names. +@param source The source to record the fact in. +@param template_id Registered, active template the claim was produced under. +@param claim Canonically serialized claim. Must contain the subject and the template id. +@param signatures One 65-byte recoverable ECDSA signature per attestor. +@param attestor_epoch Epoch whose attestor set signed. +@param nullifier 32 bytes binding one identity to one subject, or empty to skip. When set, +its lowercase hex must appear in the signed claim, so the attestors vouch for it. +@abort If the claim does not bind the subject (or the nullifier), a signer is unknown or +repeated, the attestor epoch is retired, the template is revoked, the claim was +already used, or fewer than the threshold signed. + + +
public entry fun enroll(user: &signer, source: address, template_id: vector<u8>, claim: vector<u8>, signatures: vector<vector<u8>>, attestor_epoch: u64, nullifier: vector<u8>)
+
+ + + +
+Implementation + + +
public entry fun enroll(
+    user: &signer,
+    source: address,
+    template_id: vector<u8>,
+    claim: vector<u8>,
+    signatures: vector<vector<u8>>,
+    attestor_epoch: u64,
+    nullifier: vector<u8>
+) acquires Verifier {
+    let subject = address_of(user);
+    let (met, active, consumed, signers) =
+        verify_claim_internal(
+            source, template_id, subject, claim, signatures, attestor_epoch
+        );
+    assert!(active, error::invalid_state(ETEMPLATE_REVOKED));
+    assert!(!consumed, error::invalid_state(ECLAIM_CONSUMED));
+    assert!(met, error::invalid_argument(EBELOW_THRESHOLD));
+    // A nullifier the attestors did not sign is just a user-chosen value and gives no sybil
+    // resistance, so it must be carried by the claim itself.
+    assert!(
+        nullifier.is_empty() || contains_bytes(&claim, &lowercase_hex(nullifier)),
+        error::invalid_argument(EMALFORMED_CLAIM)
+    );
+
+    let verifier = &mut Verifier[source];
+    table::add(&mut verifier.consumed, keccak256(claim), true);
+    let template = *table::borrow(&verifier.templates, template_id);
+
+    attestation::record_verified_claim(
+        source,
+        subject,
+        template.grants_level,
+        now_seconds() + template.ttl_secs,
+        keccak256(claim),
+        nullifier
+    );
+
+    emit(Enroll { source, subject, template_id, signers });
+}
+
+ + + +
+ + + +## Function `verify_claim_internal` + +Structural checks abort. Returns whether the threshold is met, whether the template is +active, whether the claim was already consumed, and how many distinct attestors signed. + + +
fun verify_claim_internal(source: address, template_id: vector<u8>, subject: address, claim: vector<u8>, signatures: vector<vector<u8>>, attestor_epoch: u64): (bool, bool, bool, u64)
+
+ + + +
+Implementation + + +
fun verify_claim_internal(
+    source: address,
+    template_id: vector<u8>,
+    subject: address,
+    claim: vector<u8>,
+    signatures: vector<vector<u8>>,
+    attestor_epoch: u64
+): (bool, bool, bool, u64) acquires Verifier {
+    assert_initialized(source);
+    assert!(
+        signatures.length() <= MAX_SIGNATURES,
+        error::invalid_argument(EBELOW_THRESHOLD)
+    );
+
+    let verifier = &Verifier[source];
+    assert!(
+        table::contains(&verifier.templates, template_id),
+        error::not_found(EUNKNOWN_TEMPLATE)
+    );
+    assert!(
+        table::contains(&verifier.sets, attestor_epoch),
+        error::not_found(EUNKNOWN_ATTESTOR_EPOCH)
+    );
+    assert!(
+        epoch_accepted(verifier, attestor_epoch),
+        error::invalid_state(ERETIRED_ATTESTOR_EPOCH)
+    );
+    let set = table::borrow(&verifier.sets, attestor_epoch);
+
+    // The single most important check in this module, and the first thing an attacker will
+    // look for. Without it a valid attestation for one person is a valid attestation for
+    // whoever relays it, and one self-hosted endpoint mints unlimited verified addresses.
+    assert!(
+        claim_binds(&claim, subject, &template_id),
+        error::invalid_argument(EMALFORMED_CLAIM)
+    );
+
+    let seen: vector<vector<u8>> = vector[];
+    signatures.for_each_ref(|signature| {
+        let recovered = recover_attestor(claim, *signature);
+        assert!(
+            set.attestors.contains(&recovered),
+            error::invalid_argument(EUNKNOWN_ATTESTOR)
+        );
+        assert!(
+            !seen.contains(&recovered),
+            error::invalid_argument(EDUPLICATE_SIGNER)
+        );
+        seen.push_back(recovered);
+    });
+
+    (
+        seen.length() >= set.threshold,
+        table::borrow(&verifier.templates, template_id).active,
+        table::contains(&verifier.consumed, keccak256(claim)),
+        seen.length()
+    )
+}
+
+ + + +
+ + + +## Function `epoch_accepted` + + + +
fun epoch_accepted(verifier: &zktls::Verifier, epoch: u64): bool
+
+ + + +
+Implementation + + +
fun epoch_accepted(verifier: &Verifier, epoch: u64): bool {
+    epoch != 0
+        && (
+            epoch == verifier.current_epoch
+                || (
+                    // Written as a subtraction so an epoch of u64::MAX cannot overflow.
+                    epoch < verifier.current_epoch
+                        && verifier.current_epoch - epoch == 1
+                        && now_seconds() < verifier.previous_deadline_secs
+                )
+        )
+}
+
+ + + +
+ + + +## Function `claim_binds` + +Whether the claim names this subject and this template. The canonical serialization is the +provider's, so this checks containment of both binding values rather than parsing: a claim +that does not carry them is rejected outright. + +Reclaim compatibility means reproducing its ASCII claim serialization exactly, down to the +lowercase hex identifier, the decimal formatting of its integer fields and the newline +joins. One wrong byte fails every proof, so this must be covered by pinned conformance +vectors from a real attestor rather than by a test written from the documentation. + + +
fun claim_binds(claim: &vector<u8>, subject: address, template_id: &vector<u8>): bool
+
+ + + +
+Implementation + + +
fun claim_binds(
+    claim: &vector<u8>, subject: address, template_id: &vector<u8>
+): bool {
+    contains_bytes(claim, &lowercase_hex(std::bcs::to_bytes(&subject)))
+        && contains_bytes(claim, &lowercase_hex(*template_id))
+}
+
+ + + +
+ + + +## Function `contains_bytes` + +Whether needle appears in haystack. + + +
fun contains_bytes(haystack: &vector<u8>, needle: &vector<u8>): bool
+
+ + + +
+Implementation + + +
fun contains_bytes(haystack: &vector<u8>, needle: &vector<u8>): bool {
+    let needle_length = needle.length();
+    let haystack_length = haystack.length();
+    if (needle_length == 0 || needle_length > haystack_length) {
+        return needle_length == 0
+    };
+    let start = 0;
+    while (start + needle_length <= haystack_length) {
+        let offset = 0;
+        let matched = true;
+        while (offset < needle_length && matched) {
+            if (haystack[start + offset] != needle[offset]) {
+                matched = false;
+            };
+            offset += 1;
+        };
+        if (matched) {
+            return true
+        };
+        start += 1;
+    };
+    false
+}
+
+ + + +
+ + + +## Function `lowercase_hex` + +Lowercase hex encoding, matching the form attestor networks put in a claim. + + +
fun lowercase_hex(bytes: vector<u8>): vector<u8>
+
+ + + +
+Implementation + + +
fun lowercase_hex(bytes: vector<u8>): vector<u8> {
+    let digits = b"0123456789abcdef";
+    let out = vector[];
+    bytes.for_each(|byte| {
+        out.push_back(digits[((byte >> 4) as u64)]);
+        out.push_back(digits[((byte & 0x0f) as u64)]);
+    });
+    out
+}
+
+ + + +
+ + + +## Function `decimal_bytes` + +Decimal ASCII encoding of a length, for the Ethereum signing prefix. + + +
fun decimal_bytes(value: u64): vector<u8>
+
+ + + +
+Implementation + + +
fun decimal_bytes(value: u64): vector<u8> {
+    if (value == 0) {
+        return b"0"
+    };
+    let digits = vector[];
+    let remaining = value;
+    while (remaining > 0) {
+        digits.push_back(((remaining % 10) as u8) + 48);
+        remaining /= 10;
+    };
+    digits.reverse();
+    digits
+}
+
+ + + +
+ + + +## Function `assert_initialized` + + + +
fun assert_initialized(source: address)
+
+ + + +
+Implementation + + +
fun assert_initialized(source: address) {
+    assert!(exists<Verifier>(source), error::not_found(ENOT_INITIALIZED));
+}
+
+ + + +
+ + + +## Specification + + + + + + +### High-level Requirements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.RequirementCriticalityImplementationEnforcement
1Only an admin of the source can configure its verifier: create it, register attestor sets, and register or revoke templates.Criticalinitialize, set_attestor_set, register_template and revoke_template all assert attestation::is_admin before touching the Verifier.Formally verified via set_attestor_set and audited in unit tests for the remaining entry functions.
2An attestor set has a threshold of at least one and at most its size, and each rotation moves to a new epoch that is one higher than the last.Highset_attestor_set asserts 1 <= required <= count, increments current_epoch and adds the set under the new epoch.Formally verified via set_attestor_set.
3Only the current attestor epoch verifies, plus the immediately previous one until the grace deadline set at rotation. A rotated-away set stops verifying once the deadline passes, and an older one stops immediately.Criticalepoch_accepted accepts current_epoch, or current_epoch - 1 while now < previous_deadline_secs. verify_claim_internal asserts it.Formally verified via epoch_accepted and enroll.
4A signed claim is single use. Once a claim has been used to enroll it can never enroll again, so it can neither refresh an expiry indefinitely nor undo a revocation.Criticalenroll records keccak256(claim) in Verifier.consumed and aborts if it is already present. verify_claim returns false for a consumed claim.Formally verified via enroll and verify_claim.
5A claim under a revoked template never enrolls, and verify_claim never reports it as valid.Highverify_claim_internal returns the template's active flag; enroll aborts with ETEMPLATE_REVOKED and verify_claim folds it into its result.Formally verified via enroll and verify_claim.
6A claim is bound to the subject submitting it and to the template it is redeemed under, and a non-empty nullifier must be carried by the signed claim. A valid attestation for one person is not an attestation for whoever relays it.Criticalclaim_binds checks that the lowercase hex of bcs(subject) and of template_id both appear in the claim, and enroll checks the same for the nullifier.Audited in unit tests (test_claim_for_another_subject_fails, test_claim_under_another_template_fails, test_nullifier_not_in_claim_fails). The byte-search loops are not amenable to the prover.
7Distinct attestors, each a member of the epoch's set, must sign, and at least threshold of them.Criticalverify_claim_internal recovers each signer with secp256k1::ecdsa_recover, aborts on a signer outside the set or a repeated signer, and compares the distinct count with the threshold.Audited in unit tests with fixed secp256k1 vectors. The recovery loop runs inside for_each_ref, which the prover havocs.
+ + + + + +### Module-level Specification + + +
pragma verify = true;
+pragma aborts_if_is_strict = false;
+
+ + + + + + + +
fun spec_now(): u64 {
+   aptos_framework::timestamp::spec_now_seconds()
+}
+
+ + + + + + + +
fun spec_epoch_accepted(verifier: Verifier, epoch: u64): bool {
+   epoch != 0
+       && (epoch == verifier.current_epoch
+           || (epoch < verifier.current_epoch
+               && verifier.current_epoch - epoch == 1
+               && spec_now() < verifier.previous_deadline_secs))
+}
+
+ + + + + +### Function `is_initialized` + + +
#[view]
+public fun is_initialized(source: address): bool
+
+ + + + +
aborts_if false;
+ensures result == exists<Verifier>(source);
+
+ + + + + +### Function `current_epoch` + + +
#[view]
+public fun current_epoch(source: address): u64
+
+ + + + +
aborts_if !exists<Verifier>(source);
+ensures result == global<Verifier>(source).current_epoch;
+
+ + + + + +### Function `threshold` + + +
#[view]
+public fun threshold(source: address, epoch: u64): u64
+
+ + + + +
aborts_if !exists<Verifier>(source);
+aborts_if !table::spec_contains(global<Verifier>(source).sets, epoch);
+ensures result == table::spec_get(global<Verifier>(source).sets, epoch).threshold;
+
+ + + + + +### Function `is_epoch_accepted` + + +
#[view]
+public fun is_epoch_accepted(source: address, epoch: u64): bool
+
+ + + + +
pragma aborts_if_is_partial;
+ensures !exists<Verifier>(source) ==> !result;
+ensures exists<Verifier>(source) ==> result == spec_epoch_accepted(global<Verifier>(source), epoch);
+
+ + + + + +### Function `is_claim_consumed` + + +
#[view]
+public fun is_claim_consumed(source: address, claim: vector<u8>): bool
+
+ + + + +
aborts_if false;
+ensures result == (exists<Verifier>(source)
+    && table::spec_contains(
+        global<Verifier>(source).consumed, aptos_std::aptos_hash::spec_keccak256(claim)
+    ));
+
+ + + + + +### Function `is_template_active` + + +
#[view]
+public fun is_template_active(source: address, template_id: vector<u8>): bool
+
+ + + + +
aborts_if false;
+let templates = global<Verifier>(source).templates;
+ensures result == (exists<Verifier>(source)
+    && table::spec_contains(templates, template_id)
+    && table::spec_get(templates, template_id).active);
+
+ + + + + +### Function `verify_claim` + + +
#[view]
+public fun verify_claim(source: address, template_id: vector<u8>, subject: address, claim: vector<u8>, signatures: vector<vector<u8>>, attestor_epoch: u64): bool
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Verifier>(source);
+let verifier = global<Verifier>(source);
+// This enforces high-level requirement 4:
+ensures result ==> !table::spec_contains(verifier.consumed, aptos_std::aptos_hash::spec_keccak256(claim));
+// This enforces high-level requirement 5:
+ensures result ==> table::spec_get(verifier.templates, template_id).active;
+ensures result ==> spec_epoch_accepted(verifier, attestor_epoch);
+
+ + + + + +### Function `set_attestor_set` + + +
public entry fun set_attestor_set(admin: &signer, source: address, attestor_addresses: vector<vector<u8>>, required: u64, previous_grace_secs: u64)
+
+ + + + +
pragma aborts_if_is_partial;
+// This enforces high-level requirement 1:
+aborts_if !aptos_framework::attestation::spec_is_source(source);
+aborts_if !contains(global<aptos_framework::attestation::Source>(source).admins, address_of(admin));
+aborts_if !exists<Verifier>(source);
+// This enforces high-level requirement 2:
+aborts_if required < 1 || required > len(attestor_addresses);
+let post verifier = global<Verifier>(source);
+ensures verifier.current_epoch == old(global<Verifier>(source).current_epoch) + 1;
+ensures table::spec_contains(verifier.sets, verifier.current_epoch);
+ensures table::spec_get(verifier.sets, verifier.current_epoch).threshold == required;
+ensures table::spec_get(verifier.sets, verifier.current_epoch).attestors == attestor_addresses;
+ensures verifier.previous_deadline_secs >= spec_now();
+ensures verifier.consumed == old(global<Verifier>(source).consumed);
+ensures verifier.templates == old(global<Verifier>(source).templates);
+
+ + + + + +### Function `register_template` + + +
public entry fun register_template(admin: &signer, source: address, template_id: vector<u8>, grants_level: u8, ttl_secs: u64)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Verifier>(source);
+let post template = table::spec_get(global<Verifier>(source).templates, template_id);
+ensures template.active;
+ensures template.grants_level == grants_level;
+ensures template.ttl_secs == ttl_secs;
+
+ + + + + +### Function `revoke_template` + + +
public entry fun revoke_template(admin: &signer, source: address, template_id: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+aborts_if !exists<Verifier>(source);
+aborts_if !table::spec_contains(global<Verifier>(source).templates, template_id);
+ensures !table::spec_get(global<Verifier>(source).templates, template_id).active;
+ensures global<Verifier>(source).consumed == old(global<Verifier>(source).consumed);
+
+ + + + + +### Function `enroll` + + +
public entry fun enroll(user: &signer, source: address, template_id: vector<u8>, claim: vector<u8>, signatures: vector<vector<u8>>, attestor_epoch: u64, nullifier: vector<u8>)
+
+ + + + +
pragma aborts_if_is_partial;
+let verifier = global<Verifier>(source);
+let digest = aptos_std::aptos_hash::spec_keccak256(claim);
+aborts_if !exists<Verifier>(source);
+aborts_if !table::spec_contains(verifier.templates, template_id);
+aborts_if !table::spec_contains(verifier.sets, attestor_epoch);
+// This enforces high-level requirement 3:
+aborts_if !spec_epoch_accepted(verifier, attestor_epoch);
+// This enforces high-level requirement 5:
+aborts_if !table::spec_get(verifier.templates, template_id).active;
+// This enforces high-level requirement 4:
+aborts_if table::spec_contains(verifier.consumed, digest);
+ensures table::spec_contains(global<Verifier>(source).consumed, digest);
+ensures global<Verifier>(source).sets == old(global<Verifier>(source).sets);
+ensures global<Verifier>(source).templates == old(global<Verifier>(source).templates);
+
+ + + + + +### Function `epoch_accepted` + + +
fun epoch_accepted(verifier: &zktls::Verifier, epoch: u64): bool
+
+ + + + +
pragma aborts_if_is_partial;
+// This enforces high-level requirement 3:
+ensures result == spec_epoch_accepted(verifier, epoch);
+
+ + +[move-book]: https://aptos.dev/move/book/SUMMARY diff --git a/aptos-move/framework/aptos-framework/sources/attestation.move b/aptos-move/framework/aptos-framework/sources/attestation.move new file mode 100644 index 00000000000..683eb044fae --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation.move @@ -0,0 +1,2460 @@ +/// Attestation source module for Movement. A source is an independent namespace that asserts +/// facts about addresses: who is verified, at what level, with what attributes, and who is +/// excluded. Anyone may create one, and nothing here is globally trusted, so a consumer names the +/// sources it trusts or gets no answer. Two sources may disagree about the same subject and +/// nothing is broken, because a fact is never true globally, only true according to somebody. +/// +/// Each source is a resource account, following `aptos_framework::timelock`: the deployer +/// authorizes creation and pays gas but gains no role unless it is listed in the role arguments. +/// The account has no owner, so unlike an object it cannot be transferred or burned out from under +/// the integrators that hardcoded its address. +/// +/// Roles, and why each is separate: +/// - Admins configure the source, register and rotate issuers, and grant roles. +/// - Issuers write facts. This is the hot key, and it deliberately cannot touch denials or roles. +/// - Sentinels may ADD denials only, which is the fast path for sanctions screening. +/// - Removers may REMOVE denials only. Mistaken denial is the dominant operational failure mode in +/// production systems of this kind, so reversal is a designed path with a different key rather +/// than an afterthought. +/// - Guardians may pause writes without needing the admin path. +/// An address may hold several roles; the overlap is allowed on purpose so authority can be handed +/// over without a gap, exactly as `timelock` allows for its canceler role. +/// +/// Delayed governance comes for free by composition: an admin address may be a +/// `aptos_framework::timelock` account, in which case every configuration change inherits that +/// module's delay, multi-role and cancel semantics. This module deliberately does not reimplement +/// any of it. +/// +/// Properties: +/// - Denial is evaluated before anything else and no positive write can create, modify or clear it. +/// - A fact is never active past its expiry, below its issuer's epoch, or below the source floor. +/// - Bumping an issuer's epoch invalidates every fact that issuer wrote, in one write, which is +/// the remedy for a compromised issuer key and is O(1) in the size of the cohort. +/// - Pausing blocks writes and never changes the answer `is_verified` gives. A pause must not +/// silently flip a boolean that other protocols depend on. +/// - Configuration and facts live in two separate resources so the read path touches only +/// immutable state and per-subject table keys, and gated transactions therefore never conflict +/// with one another under Block-STM. +module aptos_framework::attestation { + use std::account::{Self, SignerCapability, create_resource_address}; + use std::bcs::to_bytes; + use std::chain_id; + use std::ed25519; + use std::error; + use std::event::emit; + use std::signer::address_of; + use std::simple_map::{Self, SimpleMap}; + use std::table::{Self, Table}; + use std::timestamp::now_seconds; + + friend aptos_framework::zktls; + + /// Domain separator used when deriving the resource account seed, to avoid collisions with + /// other modules that create resource accounts. + const DOMAIN_SEPARATOR: vector = b"aptos_framework::attestation"; + /// Domain separator for the message an issuer signs for the permissionless relay path. Keeps a + /// relay attestation from being reinterpreted as any other signed payload. + const DOMAIN_ATTESTATION: vector = b"aptos_framework::attestation::ATTEST"; + + /// No record exists. The default for every address. + const STATE_NONE: u8 = 0; + /// The source asserts this subject currently qualifies. + const STATE_ACTIVE: u8 = 1; + /// Temporarily withheld, reversible by the issuer. + const STATE_SUSPENDED: u8 = 2; + /// Terminal for this record; requires re-issuance rather than un-revocation. + const STATE_REVOKED: u8 = 3; + + /// Expiry value meaning the fact never expires on its own. + const NEVER_EXPIRES: u64 = 18446744073709551615; + /// Largest number of subjects one batch call may touch. + const MAX_BATCH: u64 = 1000; + /// Largest number of change entries retained per subject. Older entries are dropped. + const MAX_HISTORY: u64 = 32; + /// Largest number of attributes retained per subject. + const MAX_ATTRS: u64 = 32; + /// Required length of a 32-byte digest, nullifier or root. + const DIGEST_LENGTH: u64 = 32; + /// Required length of an ed25519 public key. + const PUBKEY_LENGTH: u64 = 32; + /// Required length of an ed25519 signature. + const SIGNATURE_LENGTH: u64 = 64; + /// Attribute keys below this value are reserved for the published vocabulary. + const RESERVED_ATTR_KEYS: u16 = 1024; + + /// Published version of this module's interface. + const VERSION: u64 = 1; + + /// Specified account is not an attestation source. + const EACCOUNT_NOT_SOURCE: u64 = 1; + /// The caller is not an admin. + const ENOT_ADMIN: u64 = 2; + /// The caller is not a registered, active issuer. + const ENOT_ISSUER: u64 = 3; + /// The caller is not a sentinel. + const ENOT_SENTINEL: u64 = 4; + /// The caller is not a remover. + const ENOT_REMOVER: u64 = 5; + /// The caller is not a guardian. + const ENOT_GUARDIAN: u64 = 6; + /// Writes are paused on this source. + const EPAUSED: u64 = 7; + /// A role list cannot contain duplicate addresses. + const EDUPLICATE_MEMBER: u64 = 8; + /// The source account itself cannot hold a role. + const ESELF_CANNOT_BE_MEMBER: u64 = 9; + /// A source must have at least one admin. + const ENOT_ENOUGH_ADMINS: u64 = 10; + /// Removing these admins would leave the source with zero admins. + const EWOULD_REMOVE_ALL_ADMINS: u64 = 11; + /// Batch argument vectors have differing lengths. + const ELENGTH_MISMATCH: u64 = 12; + /// The batch exceeds MAX_BATCH. + const EBATCH_TOO_LARGE: u64 = 13; + /// No issuer is registered under the given address or id. + const EUNKNOWN_ISSUER: u64 = 14; + /// An issuer is already registered under this address. + const EDUPLICATE_ISSUER: u64 = 15; + /// The issuer signature over the relayed attestation did not verify. + const EBAD_SIGNATURE: u64 = 16; + /// The attestation names an issuer epoch other than the issuer's current one. + const ESTALE_EPOCH: u64 = 17; + /// A newer attestation has already been recorded for this subject. + const ENOT_MONOTONIC: u64 = 18; + /// The subject is denied, so no positive fact may be written for it. + const ESUBJECT_DENIED: u64 = 19; + /// This nullifier is already bound to a different subject. + const ENULLIFIER_BOUND: u64 = 20; + /// No record exists for this subject. + const ERECORD_NOT_FOUND: u64 = 21; + /// The attribute key is in the reserved range but is not a known vocabulary key. + const EBAD_ATTRIBUTE: u64 = 22; + /// The provided digest, nullifier or root must be exactly 32 bytes. + const EINVALID_BYTES_LENGTH: u64 = 23; + /// No root has been published for the requested epoch. + const EROOT_NOT_FOUND: u64 = 24; + /// The record's current state does not allow this lifecycle change. + const EINVALID_TRANSITION: u64 = 25; + + /// One entry in a subject's change history. + struct Change has copy, drop, store { + // Unix timestamp (seconds) of the change. + at_secs: u64, + // State before the change. + prev_state: u8, + // State after the change. + new_state: u8, + // Caller-supplied reason code, for the source's own vocabulary. + reason: u16, + // Issuer that made the change, or 0 for a zkTLS enrollment with no issuer. + issuer_id: u16 + } + + /// What one source asserts about one subject. + struct Record has copy, drop, store { + state: u8, + // Tier, 0 to 255, with meaning defined and published by the source. + level: u8, + // Issuer that wrote this fact, or 0 for a zkTLS enrollment. + issuer_id: u16, + // The issuer's epoch at the time of writing. A later bump invalidates this fact. + issuer_epoch: u64, + issued_at_secs: u64, + // NEVER_EXPIRES means no expiry. + expires_at_secs: u64, + // Set when the state becomes STATE_REVOKED, otherwise 0. + revoked_at_secs: u64, + reason: u16, + // 32 bytes identifying the attestation this fact came from, or empty for a direct write. + attestation_digest: vector, + attrs: SimpleMap>, + history: vector + } + + /// An exclusion. Written only by a sentinel, removed only by a remover, and unreachable from + /// every positive write path. + struct DenyEntry has copy, drop, store { + reason: u16, + // Denial takes effect at this timestamp, allowing a pre-announced effective date. + effective_at_secs: u64, + added_at_secs: u64 + } + + /// A published set commitment. Present for interoperability, since an EVM contract can verify + /// the same root, and for audit, since a third party can check the published set matches the + /// claim. It is not a storage compression device. + struct Root has copy, drop, store { + digest: vector, + leaf_count: u64, + published_at_secs: u64, + issuer_id: u16 + } + + /// A registered issuer. `pubkey` is used only by the permissionless relay path. + struct Issuer has copy, drop, store { + id: u16, + pubkey: vector, + active: bool + } + + /// Configuration and governance. Mutable, and deliberately never read by the check path. + struct Source has key { + // Addresses allowed to configure the source. Must have at least 1. + admins: vector
, + // Addresses allowed to write facts. + issuers: vector
, + // Addresses allowed to add denials only. + sentinels: vector
, + // Addresses allowed to remove denials only. + removers: vector
, + // Addresses allowed to pause and unpause writes. + guardians: vector
, + // Issuer records, keyed by address, plus the reverse index used by the relay path. + issuer_info: Table, + issuer_by_id: Table, + next_issuer_id: u16, + // Blocks writes only. Never consulted by is_verified. See the module doc. + paused: bool, + // Incremented on every published root. + root_epoch: u64, + roots: Table, + // Signer capability for the resource account, retained so future resources can be added. + signer_cap: SignerCapability + } + + /// Facts. This resource is written once at creation and never again: only its table ENTRIES + /// change, and each entry is its own state key. That is what keeps gated transactions from + /// conflicting with one another under Block-STM. + struct Facts has key { + subjects: Table, + denied: Table, + // One real-world identity to one subject, when the source chooses to enforce it. + nullifiers: Table, address>, + // Per-issuer epoch, held here rather than in Source so the read path can check staleness + // without loading mutable governance state. Written only on a bump, so in steady state + // nothing writes it and there is no contention. After a bump the transactions that relied + // on that issuer do re-execute, which is the intended behaviour. + issuer_epochs: Table, + // Source-wide floor, under the single key 0. A table so that Facts itself stays immutable. + floor_epoch: Table + } + + // =============================== Events =============================== + + #[event] + struct CreateSource has drop, store { + source: address, + deployer: address, + admins: vector
, + issuers: vector
+ } + + #[event] + struct AddMembers has drop, store { + source: address, + role: u8, + members: vector
+ } + + #[event] + struct RemoveMembers has drop, store { + source: address, + role: u8, + members: vector
+ } + + #[event] + struct RegisterIssuer has drop, store { + source: address, + issuer: address, + id: u16 + } + + #[event] + struct RotateIssuerKey has drop, store { + source: address, + id: u16 + } + + #[event] + struct BumpIssuerEpoch has drop, store { + source: address, + id: u16, + epoch: u64 + } + + #[event] + struct SetFloorEpoch has drop, store { + source: address, + epoch: u64 + } + + #[event] + struct RecordFact has drop, store { + source: address, + subject: address, + state: u8, + level: u8, + issuer_id: u16, + expires_at_secs: u64, + reason: u16 + } + + #[event] + struct SetAttribute has drop, store { + source: address, + subject: address, + key: u16 + } + + #[event] + struct PublishRoot has drop, store { + source: address, + epoch: u64, + digest: vector, + leaf_count: u64 + } + + #[event] + struct Deny has drop, store { + source: address, + subject: address, + reason: u16, + effective_at_secs: u64 + } + + #[event] + struct Undeny has drop, store { + source: address, + subject: address + } + + #[event] + struct SetPaused has drop, store { + source: address, + paused: bool + } + + // Role discriminants, used only in events so an indexer can tell the lists apart. + const ROLE_ADMIN: u8 = 0; + const ROLE_ISSUER: u8 = 1; + const ROLE_SENTINEL: u8 = 2; + const ROLE_REMOVER: u8 = 3; + const ROLE_GUARDIAN: u8 = 4; + + // =============================== Views =============================== + + #[view] + /// Return the predicted address for the next source deployed by the given account. The + /// deployer authorizes resource-account creation but gains no role unless it is listed in the + /// role arguments to `create`. + public fun get_next_source_address(deployer: address): address { + let owner_nonce = account::get_sequence_number(deployer); + create_resource_address(&deployer, create_source_seed(to_bytes(&owner_nonce))) + } + + #[view] + /// The single mandatory conformance function: does this source currently vouch for this + /// subject at all. Denial, expiry and epoch staleness are all accounted for. + public fun is_verified(source: address, subject: address): bool acquires Facts { + let (active, _) = active_with_level(source, subject); + active + } + + #[view] + /// Lifecycle state as the check path sees it, so an expired or stale record reads as + /// STATE_NONE and a denied subject reads as STATE_REVOKED. + public fun state_of(source: address, subject: address): u8 acquires Facts { + let (active, _) = active_with_level(source, subject); + if (active) { + return STATE_ACTIVE + }; + let facts = &Facts[source]; + if (is_denied_internal(facts, subject)) { + return STATE_REVOKED + }; + if (!table::contains(&facts.subjects, subject)) { + return STATE_NONE + }; + let record = table::borrow(&facts.subjects, subject); + if (record.state == STATE_ACTIVE) { + // Active but not usable, so expired or stale. + return STATE_NONE + }; + record.state + } + + #[view] + /// Tier of a currently usable fact, or 0 when there is none. + public fun level_of(source: address, subject: address): u8 acquires Facts { + let (_, level) = active_with_level(source, subject); + level + } + + /// Whether this source currently vouches for the subject, and at what level, in one pass. + /// This is what `aptos_framework::attestation_policy` calls. Not a `#[view]` returning two + /// values by design: callers that want one value use `is_verified` or `level_of`. + /// + /// Order is load-bearing. Denial is checked before anything else and cannot be overridden. + public fun active_with_level(source: address, subject: address): (bool, u8) acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + + if (is_denied_internal(facts, subject)) { + return (false, 0) + }; + if (!table::contains(&facts.subjects, subject)) { + return (false, 0) + }; + + let record = table::borrow(&facts.subjects, subject); + if (record.state != STATE_ACTIVE) { + return (false, 0) + }; + if (record.expires_at_secs <= now_seconds()) { + return (false, 0) + }; + // A bump of this issuer's epoch, or a raise of the source floor, invalidates the fact. + if (table::contains(&facts.issuer_epochs, record.issuer_id) + && record.issuer_epoch < *table::borrow(&facts.issuer_epochs, record.issuer_id)) { + return (false, 0) + }; + if (record.issuer_epoch < *table::borrow(&facts.floor_epoch, 0)) { + return (false, 0) + }; + + (true, record.level) + } + + #[view] + /// The full record, including history and attributes. Aborts when there is none. + public fun record_of(source: address, subject: address): Record acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + assert!( + table::contains(&facts.subjects, subject), + error::not_found(ERECORD_NOT_FOUND) + ); + *table::borrow(&facts.subjects, subject) + } + + #[view] + /// An attribute value, or an empty vector when unset. + public fun attribute_of(source: address, subject: address, key: u16): vector acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + if (!table::contains(&facts.subjects, subject)) { + return vector[] + }; + let attrs = &table::borrow(&facts.subjects, subject).attrs; + if (simple_map::contains_key(attrs, &key)) { + *simple_map::borrow(attrs, &key) + } else { + vector[] + } + } + + #[view] + /// Whether the subject is excluded and the exclusion is in effect now. + public fun is_denied(source: address, subject: address): bool acquires Facts { + assert_source_exists(source); + is_denied_internal(&Facts[source], subject) + } + + #[view] + /// Reason code attached to an exclusion, or 0 when there is none. + public fun deny_reason(source: address, subject: address): u16 acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + if (table::contains(&facts.denied, subject)) { + table::borrow(&facts.denied, subject).reason + } else { 0 } + } + + #[view] + /// Expiry of the stored record, or 0 when there is none. + public fun expires_at(source: address, subject: address): u64 acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + if (table::contains(&facts.subjects, subject)) { + table::borrow(&facts.subjects, subject).expires_at_secs + } else { 0 } + } + + #[view] + /// Most recently published root. + public fun current_root(source: address): Root acquires Source { + assert_source_exists(source); + let config = &Source[source]; + assert!( + table::contains(&config.roots, config.root_epoch), + error::not_found(EROOT_NOT_FOUND) + ); + *table::borrow(&config.roots, config.root_epoch) + } + + #[view] + /// Membership of the subject in the most recently published root. OpenZeppelin shape: the + /// subject address and the proof, with no leaf index and no commitment argument. + public fun verify_membership( + source: address, subject: address, proof: vector> + ): bool acquires Source { + let root = current_root(source); + aptos_framework::merkle_proof::verify( + root.digest, + aptos_framework::merkle_proof::subject_leaf(source, subject), + proof + ) + } + + #[view] + public fun admins(source: address): vector
acquires Source { + assert_source_exists(source); + Source[source].admins + } + + #[view] + public fun issuers(source: address): vector
acquires Source { + assert_source_exists(source); + Source[source].issuers + } + + #[view] + public fun sentinels(source: address): vector
acquires Source { + assert_source_exists(source); + Source[source].sentinels + } + + #[view] + public fun removers(source: address): vector
acquires Source { + assert_source_exists(source); + Source[source].removers + } + + #[view] + public fun guardians(source: address): vector
acquires Source { + assert_source_exists(source); + Source[source].guardians + } + + #[view] + public fun is_admin(addr: address, source: address): bool acquires Source { + assert_source_exists(source); + Source[source].admins.contains(&addr) + } + + #[view] + public fun is_issuer(addr: address, source: address): bool acquires Source { + assert_source_exists(source); + Source[source].issuers.contains(&addr) + } + + #[view] + /// Stable id assigned to an issuer at registration. Aborts when unregistered. + public fun issuer_id_of(source: address, issuer: address): u16 acquires Source { + assert_source_exists(source); + let config = &Source[source]; + assert!( + table::contains(&config.issuer_info, issuer), + error::not_found(EUNKNOWN_ISSUER) + ); + table::borrow(&config.issuer_info, issuer).id + } + + #[view] + /// Effective epoch of an issuer: the larger of its own counter and the source floor. Facts + /// written below it are no longer usable, and new writes by that issuer are stamped with it. + /// Issuer id 0 is the zkTLS enrollment cohort, which has no registered issuer. + public fun issuer_epoch_of(source: address, issuer_id: u16): u64 acquires Facts { + assert_source_exists(source); + let facts = &Facts[source]; + let own = + if (table::contains(&facts.issuer_epochs, issuer_id)) { + *table::borrow(&facts.issuer_epochs, issuer_id) + } else { 0 }; + let floor = *table::borrow(&facts.floor_epoch, 0); + if (own > floor) { own } else { floor } + } + + #[view] + public fun floor_epoch(source: address): u64 acquires Facts { + assert_source_exists(source); + *table::borrow(&Facts[source].floor_epoch, 0) + } + + #[view] + public fun is_paused(source: address): bool acquires Source { + assert_source_exists(source); + Source[source].paused + } + + #[view] + /// Whether an address is an attestation source. Consulted by `attestation_policy` at staging + /// time, so a policy cannot be configured to name a source that does not exist and then abort + /// for every subject at evaluation time. + public fun is_source(source: address): bool { + exists(source) && exists(source) + } + + #[view] + public fun standard_version(): u64 { + VERSION + } + + #[view] + /// The message an issuer signs for the permissionless relay path. Published so an issuing + /// service can be implemented in any language without reading this module. + public fun attestation_message( + source: address, + subject: address, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: vector + ): vector { + let message = vector[]; + message.append(DOMAIN_ATTESTATION); + message.append(to_bytes(&chain_id::get())); + message.append(to_bytes(&source)); + message.append(to_bytes(&subject)); + message.append(to_bytes(&issuer_id)); + message.append(to_bytes(&issuer_epoch)); + message.append(to_bytes(&level)); + message.append(to_bytes(&expires_at_secs)); + message.append(to_bytes(&issued_at_secs)); + message.append(to_bytes(&nullifier)); + message + } + + // =============================== Source creation =============================== + + /// Create a new attestation source. The deployer only authorizes resource-account creation and + /// pays gas; it gains no role unless listed in the role arguments. + /// + /// @param deployer Signer that authorizes resource-account creation and pays gas. + /// @param admins Addresses allowed to configure. At least one, no duplicates, not the source. + /// @param issuers Addresses allowed to write facts. May be empty and filled in later. + /// @param sentinels Addresses allowed to add denials only. May be empty. + /// @param removers Addresses allowed to remove denials only. May be empty. + /// @param guardians Addresses allowed to pause writes. May be empty. + /// @abort If a list has duplicates, names the source itself, or there is no admin. + public entry fun create( + deployer: &signer, + admins: vector
, + issuers: vector
, + sentinels: vector
, + removers: vector
, + guardians: vector
+ ) { + let (source_signer, source_signer_cap) = create_source_account(deployer); + create_source_internal( + &source_signer, + address_of(deployer), + admins, + issuers, + sentinels, + removers, + guardians, + source_signer_cap + ); + } + + fun create_source_internal( + source_account: &signer, + deployer: address, + admins: vector
, + issuers: vector
, + sentinels: vector
, + removers: vector
, + guardians: vector
, + signer_cap: SignerCapability + ) { + let source_address = address_of(source_account); + assert!(admins.length() >= 1, error::invalid_argument(ENOT_ENOUGH_ADMINS)); + validate_members(&admins, source_address); + validate_members(&issuers, source_address); + validate_members(&sentinels, source_address); + validate_members(&removers, source_address); + validate_members(&guardians, source_address); + + let floor_epoch = table::new(); + table::add(&mut floor_epoch, 0, 0); + + move_to( + source_account, + Source { + admins, + issuers, + sentinels, + removers, + guardians, + issuer_info: table::new(), + issuer_by_id: table::new(), + next_issuer_id: 1, + paused: false, + root_epoch: 0, + roots: table::new(), + signer_cap + } + ); + move_to( + source_account, + Facts { + subjects: table::new(), + denied: table::new(), + nullifiers: table::new, address>(), + issuer_epochs: table::new(), + floor_epoch + } + ); + + emit(CreateSource { source: source_address, deployer, admins, issuers }); + } + + // =============================== Role management =============================== + // Every function here requires an admin. An admin may itself be a `timelock` account, in which + // case these calls inherit that module's delay and cancel semantics for free. + + /// Add admins. + public entry fun add_admins( + admin: &signer, source: address, new_admins: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + add_members(&mut config.admins, &new_admins, source); + emit(AddMembers { source, role: ROLE_ADMIN, members: new_admins }); + } + + /// Remove admins. A source may never be left with zero admins. + public entry fun remove_admins( + admin: &signer, source: address, old_admins: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + remove_members(&mut config.admins, &old_admins); + assert!( + config.admins.length() >= 1, + error::invalid_state(EWOULD_REMOVE_ALL_ADMINS) + ); + emit(RemoveMembers { source, role: ROLE_ADMIN, members: old_admins }); + } + + public entry fun add_issuers( + admin: &signer, source: address, new_issuers: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + add_members(&mut config.issuers, &new_issuers, source); + emit(AddMembers { source, role: ROLE_ISSUER, members: new_issuers }); + } + + public entry fun remove_issuers( + admin: &signer, source: address, old_issuers: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + remove_members(&mut config.issuers, &old_issuers); + emit(RemoveMembers { source, role: ROLE_ISSUER, members: old_issuers }); + } + + public entry fun add_sentinels( + admin: &signer, source: address, new_sentinels: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + add_members(&mut config.sentinels, &new_sentinels, source); + emit(AddMembers { source, role: ROLE_SENTINEL, members: new_sentinels }); + } + + public entry fun remove_sentinels( + admin: &signer, source: address, old_sentinels: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + remove_members(&mut config.sentinels, &old_sentinels); + emit(RemoveMembers { source, role: ROLE_SENTINEL, members: old_sentinels }); + } + + public entry fun add_removers( + admin: &signer, source: address, new_removers: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + add_members(&mut config.removers, &new_removers, source); + emit(AddMembers { source, role: ROLE_REMOVER, members: new_removers }); + } + + public entry fun remove_removers( + admin: &signer, source: address, old_removers: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + remove_members(&mut config.removers, &old_removers); + emit(RemoveMembers { source, role: ROLE_REMOVER, members: old_removers }); + } + + public entry fun add_guardians( + admin: &signer, source: address, new_guardians: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + add_members(&mut config.guardians, &new_guardians, source); + emit(AddMembers { source, role: ROLE_GUARDIAN, members: new_guardians }); + } + + public entry fun remove_guardians( + admin: &signer, source: address, old_guardians: vector
+ ) acquires Source { + assert_admin(source, address_of(admin)); + let config = &mut Source[source]; + remove_members(&mut config.guardians, &old_guardians); + emit(RemoveMembers { source, role: ROLE_GUARDIAN, members: old_guardians }); + } + + /// Pause writes. Never changes the answer `is_verified` gives. + public entry fun pause(guardian: &signer, source: address) acquires Source { + set_paused(guardian, source, true); + } + + public entry fun unpause(guardian: &signer, source: address) acquires Source { + set_paused(guardian, source, false); + } + + fun set_paused(guardian: &signer, source: address, paused: bool) acquires Source { + assert_source_exists(source); + assert!( + Source[source].guardians.contains(&address_of(guardian)), + error::permission_denied(ENOT_GUARDIAN) + ); + Source[source].paused = paused; + emit(SetPaused { source, paused }); + } + + // =============================== Issuer management =============================== + + /// Register an issuer and assign it a stable id. The public key is used only by the + /// permissionless relay path, and may be empty for an issuer that only writes directly. + /// + /// @param admin An admin of the source. + /// @param source The source address. + /// @param issuer Address to register. + /// @param pubkey 32-byte ed25519 public key, or empty. + /// @abort If the issuer is already registered or the key length is wrong. + public entry fun register_issuer( + admin: &signer, source: address, issuer: address, pubkey: vector + ) acquires Source, Facts { + assert_admin(source, address_of(admin)); + assert!( + pubkey.is_empty() || pubkey.length() == PUBKEY_LENGTH, + error::invalid_argument(EINVALID_BYTES_LENGTH) + ); + let config = &mut Source[source]; + assert!( + !table::contains(&config.issuer_info, issuer), + error::already_exists(EDUPLICATE_ISSUER) + ); + let id = config.next_issuer_id; + config.next_issuer_id = id + 1; + table::add(&mut config.issuer_info, issuer, Issuer { id, pubkey, active: true }); + table::add(&mut config.issuer_by_id, id, issuer); + if (!config.issuers.contains(&issuer)) { + config.issuers.push_back(issuer); + }; + table::add(&mut Facts[source].issuer_epochs, id, 0); + emit(RegisterIssuer { source, issuer, id }); + } + + /// Replace an issuer's signing key. Facts already written stay valid; use + /// `bump_issuer_epoch` to invalidate them. + public entry fun rotate_issuer_key( + admin: &signer, source: address, issuer: address, new_pubkey: vector + ) acquires Source { + assert_admin(source, address_of(admin)); + assert!( + new_pubkey.is_empty() || new_pubkey.length() == PUBKEY_LENGTH, + error::invalid_argument(EINVALID_BYTES_LENGTH) + ); + let config = &mut Source[source]; + assert!( + table::contains(&config.issuer_info, issuer), + error::not_found(EUNKNOWN_ISSUER) + ); + let info = table::borrow_mut(&mut config.issuer_info, issuer); + info.pubkey = new_pubkey; + emit(RotateIssuerKey { source, id: info.id }); + } + + /// Invalidate every fact an issuer has written, in one write. This is the remedy for a + /// compromised issuer key and it is O(1) in the size of the cohort. Issuer id 0 bumps the + /// zkTLS enrollment cohort, which has no registered issuer. The new epoch is one above the + /// issuer's effective epoch, so a bump always takes effect even below a raised floor. + public entry fun bump_issuer_epoch( + admin: &signer, source: address, issuer_id: u16 + ) acquires Source, Facts { + assert_admin(source, address_of(admin)); + assert!( + issuer_id == 0 || table::contains(&Source[source].issuer_by_id, issuer_id), + error::not_found(EUNKNOWN_ISSUER) + ); + let epoch = issuer_epoch_of(source, issuer_id) + 1; + table::upsert(&mut Facts[source].issuer_epochs, issuer_id, epoch); + emit(BumpIssuerEpoch { source, id: issuer_id, epoch }); + } + + /// Invalidate every fact written below the given epoch, across all issuers including the zkTLS + /// cohort. Strictly increasing, so lowering the floor can never resurrect a fact. + public entry fun set_floor_epoch( + admin: &signer, source: address, epoch: u64 + ) acquires Source, Facts { + assert_admin(source, address_of(admin)); + let floor = table::borrow_mut(&mut Facts[source].floor_epoch, 0); + assert!(epoch > *floor, error::invalid_argument(ENOT_MONOTONIC)); + *floor = epoch; + emit(SetFloorEpoch { source, epoch }); + } + + // =============================== Write path 1: issuer batch =============================== + // The only path that can be seeded at genesis and grandfathered across an existing population + // with no action from the subjects. + + /// Record or refresh facts for many subjects at once. + /// + /// @param issuer A registered, active issuer of the source. + /// @param source The source address. + /// @param subjects Subjects to write. + /// @param levels Tier per subject, same length as `subjects`. + /// @param expires_at_secs Expiry per subject, same length as `subjects`. + /// @param reason Reason code recorded in each subject's history. + /// @abort If paused, the caller is not an issuer, the lengths differ, the batch is too large, + /// or any subject is denied. + public entry fun issue_batch( + issuer: &signer, + source: address, + subjects: vector
, + levels: vector, + expires_at_secs: vector, + reason: u16 + ) acquires Source, Facts { + let count = subjects.length(); + assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE)); + assert!( + count == levels.length() && count == expires_at_secs.length(), + error::invalid_argument(ELENGTH_MISMATCH) + ); + let (issuer_id, issuer_epoch) = assert_issuer(source, address_of(issuer)); + let index = 0; + while (index < count) { + record_fact( + source, + subjects[index], + STATE_ACTIVE, + levels[index], + issuer_id, + issuer_epoch, + expires_at_secs[index], + now_seconds(), + reason, + vector[] + ); + index += 1; + }; + } + + /// Move many subjects to STATE_REVOKED. A subject that is already revoked is skipped, so one + /// stale entry cannot brick a batch. + public entry fun revoke_batch( + issuer: &signer, source: address, subjects: vector
, reason: u16 + ) acquires Source, Facts { + let count = subjects.length(); + assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE)); + let (issuer_id, _) = assert_issuer(source, address_of(issuer)); + let index = 0; + while (index < count) { + transition(source, subjects[index], STATE_REVOKED, issuer_id, reason); + index += 1; + }; + } + + /// Temporarily withhold an active subject's fact, reversibly. + public entry fun suspend( + issuer: &signer, source: address, subject: address, reason: u16 + ) acquires Source, Facts { + let (issuer_id, _) = assert_issuer(source, address_of(issuer)); + transition(source, subject, STATE_SUSPENDED, issuer_id, reason); + } + + /// Reverse a suspension. Only a suspended record can be reactivated: a revoked one needs + /// re-issuance, and a denied subject cannot be reactivated at all. The record keeps the issuer + /// and epoch it was issued under, so a fact killed by an epoch bump stays dead. + public entry fun unsuspend( + issuer: &signer, source: address, subject: address, reason: u16 + ) acquires Source, Facts { + let (issuer_id, _) = assert_issuer(source, address_of(issuer)); + transition(source, subject, STATE_ACTIVE, issuer_id, reason); + } + + /// Set an attribute on a subject. Every attribute written is public forever, so a source that + /// writes jurisdiction data has made a disclosure decision on behalf of its subjects. + public entry fun set_attribute( + issuer: &signer, source: address, subject: address, key: u16, value: vector + ) acquires Source, Facts { + assert_not_paused(source); + assert_issuer(source, address_of(issuer)); + let facts = &mut Facts[source]; + assert!( + table::contains(&facts.subjects, subject), + error::not_found(ERECORD_NOT_FOUND) + ); + let record = table::borrow_mut(&mut facts.subjects, subject); + if (simple_map::contains_key(&record.attrs, &key)) { + *simple_map::borrow_mut(&mut record.attrs, &key) = value; + } else { + assert!( + simple_map::length(&record.attrs) < MAX_ATTRS, + error::invalid_state(EBAD_ATTRIBUTE) + ); + simple_map::add(&mut record.attrs, key, value); + }; + emit(SetAttribute { source, subject, key }); + } + + public entry fun remove_attribute( + issuer: &signer, source: address, subject: address, key: u16 + ) acquires Source, Facts { + assert_not_paused(source); + assert_issuer(source, address_of(issuer)); + let facts = &mut Facts[source]; + assert!( + table::contains(&facts.subjects, subject), + error::not_found(ERECORD_NOT_FOUND) + ); + let record = table::borrow_mut(&mut facts.subjects, subject); + if (simple_map::contains_key(&record.attrs, &key)) { + simple_map::remove(&mut record.attrs, &key); + }; + } + + // ====================== Write path 2: permissionless relay ====================== + // Anyone submits, the chain verifies the issuer's signature, and the submitter pays. This is + // what keeps the issuer's writer service off the liveness path. + + /// Record a fact from an attestation the issuer signed off chain. The caller need not be the + /// subject or the issuer. + /// + /// @param source The source address. + /// @param subject Subject the attestation is about. + /// @param issuer_id Issuer that signed. + /// @param issuer_epoch Must equal the issuer's current epoch, so an attestation signed before + /// a compromise bump is refused and one signed for a future epoch is too. + /// @param nullifier 32 bytes binding one real-world identity to one subject, or empty to skip. + /// @param signature 64-byte ed25519 signature over `attestation_message`. + /// @abort If paused, the epoch is stale, the signature fails, a newer attestation is already + /// recorded, the nullifier is bound elsewhere, or the subject is denied. + public entry fun redeem_attestation( + _relayer: &signer, + source: address, + subject: address, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: vector, + signature: vector + ) acquires Source, Facts { + assert_not_paused(source); + let config = &Source[source]; + assert!( + table::contains(&config.issuer_by_id, issuer_id), + error::not_found(EUNKNOWN_ISSUER) + ); + let issuer_address = *table::borrow(&config.issuer_by_id, issuer_id); + // Same rule as assert_issuer: removing an issuer's role also stops its relayed + // attestations. + assert!( + config.issuers.contains(&issuer_address), + error::permission_denied(ENOT_ISSUER) + ); + let info = table::borrow(&config.issuer_info, issuer_address); + assert!(info.active, error::invalid_state(EUNKNOWN_ISSUER)); + assert!( + info.pubkey.length() == PUBKEY_LENGTH, + error::invalid_state(EUNKNOWN_ISSUER) + ); + + // Equality, not "at least": refuse an epoch the issuer has not entered, and refuse one + // signed before a bump. + assert!( + issuer_epoch == issuer_epoch_of(source, issuer_id), + error::invalid_state(ESTALE_EPOCH) + ); + + let message = + attestation_message( + source, + subject, + issuer_id, + issuer_epoch, + level, + expires_at_secs, + issued_at_secs, + nullifier + ); + assert!( + ed25519::signature_verify_strict( + &ed25519::new_signature_from_bytes(signature), + &ed25519::new_unvalidated_public_key_from_bytes(info.pubkey), + message + ), + error::invalid_argument(EBAD_SIGNATURE) + ); + + // Monotonicity: a kept attestation must not be replayable to push an expiry back out. + assert_newer(source, subject, issued_at_secs); + bind_nullifier(source, subject, nullifier); + + record_fact( + source, + subject, + STATE_ACTIVE, + level, + issuer_id, + issuer_epoch, + expires_at_secs, + issued_at_secs, + 0, + std::aptos_hash::keccak256(message) + ); + } + + // ====================== Write path 3: zkTLS enrollment ====================== + + /// Return a signer for the source's resource account. Restricted to the `friend` list, which + /// is the security boundary: `zktls` needs it to store its attestor set and template + /// allowlist under the source address. Nothing outside the friend list can obtain it. + public(friend) fun source_signer(source: address): signer acquires Source { + assert_source_exists(source); + account::create_signer_with_capability(&Source[source].signer_cap) + } + + /// Record a fact from a claim `aptos_framework::zktls` has already verified against its + /// attestor set. No issuer key is involved on this path at all. + public(friend) fun record_verified_claim( + source: address, + subject: address, + level: u8, + expires_at_secs: u64, + attestation_digest: vector, + nullifier: vector + ) acquires Source, Facts { + assert_not_paused(source); + assert!( + attestation_digest.length() == DIGEST_LENGTH, + error::invalid_argument(EINVALID_BYTES_LENGTH) + ); + bind_nullifier(source, subject, nullifier); + // Issuer id 0 is the zkTLS cohort, stamped with its effective epoch so a raised floor does + // not silently kill new enrollments and a bump of id 0 kills the whole cohort. + let cohort_epoch = issuer_epoch_of(source, 0); + record_fact( + source, + subject, + STATE_ACTIVE, + level, + 0, + cohort_epoch, + expires_at_secs, + now_seconds(), + 0, + attestation_digest + ); + } + + // =============================== Write path 4: exclusion =============================== + // A separate role, a separate table, evaluated before everything else, and unreachable from + // every positive write path. + + /// Exclude a subject. Takes effect at `effective_at_secs`, which may be in the future so a + /// denial can be announced before it bites. + public entry fun deny( + sentinel: &signer, + source: address, + subject: address, + reason: u16, + effective_at_secs: u64 + ) acquires Source, Facts { + assert_source_exists(source); + assert!( + Source[source].sentinels.contains(&address_of(sentinel)), + error::permission_denied(ENOT_SENTINEL) + ); + deny_internal(source, subject, reason, effective_at_secs); + } + + /// Exclude many subjects at once, with a shared reason and immediate effect. + public entry fun deny_batch( + sentinel: &signer, source: address, subjects: vector
, reason: u16 + ) acquires Source, Facts { + assert_source_exists(source); + assert!( + Source[source].sentinels.contains(&address_of(sentinel)), + error::permission_denied(ENOT_SENTINEL) + ); + let count = subjects.length(); + assert!(count <= MAX_BATCH, error::invalid_argument(EBATCH_TOO_LARGE)); + let index = 0; + let now = now_seconds(); + while (index < count) { + deny_internal(source, subjects[index], reason, now); + index += 1; + }; + } + + /// Remove an exclusion. Deliberately a different role from `deny`. + public entry fun undeny( + remover: &signer, source: address, subject: address + ) acquires Source, Facts { + assert_source_exists(source); + assert!( + Source[source].removers.contains(&address_of(remover)), + error::permission_denied(ENOT_REMOVER) + ); + let denied = &mut Facts[source].denied; + if (table::contains(denied, subject)) { + table::remove(denied, subject); + emit(Undeny { source, subject }); + }; + } + + fun deny_internal( + source: address, subject: address, reason: u16, effective_at_secs: u64 + ) acquires Facts { + // upsert, never add: a repeated denial must be idempotent rather than abort a batch. + table::upsert( + &mut Facts[source].denied, + subject, + DenyEntry { reason, effective_at_secs, added_at_secs: now_seconds() } + ); + emit(Deny { source, subject, reason, effective_at_secs }); + } + + // =============================== Roots =============================== + + /// Publish a set commitment for the next epoch. Rotation invalidates outstanding proofs, so + /// publish on a fixed low-frequency cadence: it is a privacy measure, because cohort timing + /// leaks, and a throughput one, because every gated transaction reads this slot. + public entry fun publish_root( + issuer: &signer, source: address, digest: vector, leaf_count: u64 + ) acquires Source, Facts { + assert!( + digest.length() == DIGEST_LENGTH, + error::invalid_argument(EINVALID_BYTES_LENGTH) + ); + let (issuer_id, _) = assert_issuer(source, address_of(issuer)); + let config = &mut Source[source]; + let epoch = config.root_epoch + 1; + config.root_epoch = epoch; + table::add( + &mut config.roots, + epoch, + Root { digest, leaf_count, published_at_secs: now_seconds(), issuer_id } + ); + emit(PublishRoot { source, epoch, digest, leaf_count }); + } + + // =============================== The write funnel =============================== + // Every path above ends here, so precedence and the invariants live in one place. + + fun record_fact( + source: address, + subject: address, + state: u8, + level: u8, + issuer_id: u16, + issuer_epoch: u64, + expires_at_secs: u64, + issued_at_secs: u64, + reason: u16, + attestation_digest: vector + ) acquires Facts { + let now = now_seconds(); + let facts = &mut Facts[source]; + + // A positive write can never overwrite, clear or ignore an exclusion. + assert!( + !table::contains(&facts.denied, subject), + error::invalid_state(ESUBJECT_DENIED) + ); + + let subjects = &mut facts.subjects; + if (table::contains(subjects, subject)) { + let record = table::borrow_mut(subjects, subject); + let change = Change { + at_secs: now, + prev_state: record.state, + new_state: state, + reason, + issuer_id + }; + record.state = state; + record.level = level; + record.issuer_id = issuer_id; + record.issuer_epoch = issuer_epoch; + record.expires_at_secs = expires_at_secs; + record.issued_at_secs = issued_at_secs; + record.reason = reason; + record.attestation_digest = attestation_digest; + if (state == STATE_REVOKED) { + record.revoked_at_secs = now; + }; + push_history(&mut record.history, change); + } else { + table::add( + subjects, + subject, + Record { + state, + level, + issuer_id, + issuer_epoch, + issued_at_secs, + expires_at_secs, + revoked_at_secs: if (state == STATE_REVOKED) { now } else { 0 }, + reason, + attestation_digest, + attrs: simple_map::create>(), + history: vector[ + Change { + at_secs: now, + prev_state: STATE_NONE, + new_state: state, + reason, + issuer_id + } + ] + } + ); + }; + + emit(RecordFact { source, subject, state, level, issuer_id, expires_at_secs, reason }); + } + + /// Change the state of an existing record. Used by revoke, suspend and unsuspend, all of which + /// must not silently create a record. The record keeps the issuer id and epoch it was issued + /// under, because validity comes from the original issuance; the acting issuer is recorded in + /// the history entry only. + /// + /// Allowed: ACTIVE to SUSPENDED, SUSPENDED to ACTIVE, and ACTIVE or SUSPENDED to REVOKED. + /// Revoking an already revoked record is a silent no-op so a batch is not bricked by one entry. + fun transition( + source: address, + subject: address, + state: u8, + acting_issuer_id: u16, + reason: u16 + ) acquires Facts { + let now = now_seconds(); + let facts = &mut Facts[source]; + assert!( + table::contains(&facts.subjects, subject), + error::not_found(ERECORD_NOT_FOUND) + ); + let denied = table::contains(&facts.denied, subject); + let record = table::borrow_mut(&mut facts.subjects, subject); + let prev_state = record.state; + if (state == STATE_REVOKED) { + if (prev_state == STATE_REVOKED) { + return + }; + assert!( + prev_state == STATE_ACTIVE || prev_state == STATE_SUSPENDED, + error::invalid_state(EINVALID_TRANSITION) + ); + } else if (state == STATE_SUSPENDED) { + assert!( + prev_state == STATE_ACTIVE, + error::invalid_state(EINVALID_TRANSITION) + ); + } else { + assert!( + state == STATE_ACTIVE && prev_state == STATE_SUSPENDED, + error::invalid_state(EINVALID_TRANSITION) + ); + // Reactivation is a positive write, so it obeys the same exclusion rule as record_fact. + assert!(!denied, error::invalid_state(ESUBJECT_DENIED)); + }; + let change = Change { + at_secs: now, + prev_state, + new_state: state, + reason, + issuer_id: acting_issuer_id + }; + record.state = state; + record.reason = reason; + if (state == STATE_REVOKED) { + record.revoked_at_secs = now; + }; + push_history(&mut record.history, change); + emit( + RecordFact { + source, + subject, + state, + level: record.level, + issuer_id: acting_issuer_id, + expires_at_secs: record.expires_at_secs, + reason + } + ); + } + + // =============================== Helpers =============================== + + fun create_source_account(deployer: &signer): (signer, SignerCapability) { + let deployer_nonce = account::get_sequence_number(address_of(deployer)); + account::create_resource_account( + deployer, create_source_seed(to_bytes(&deployer_nonce)) + ) + } + + fun create_source_seed(seed: vector): vector { + let account_seed = vector[]; + account_seed.append(DOMAIN_SEPARATOR); + account_seed.append(seed); + account_seed + } + + /// Validate that a role list has no duplicates and does not name the source itself. + fun validate_members(members: &vector
, source_address: address) { + let distinct: vector
= vector[]; + members.for_each_ref(|member| { + assert!( + *member != source_address, + error::invalid_argument(ESELF_CANNOT_BE_MEMBER) + ); + assert!( + !distinct.contains(member), + error::invalid_argument(EDUPLICATE_MEMBER) + ); + distinct.push_back(*member); + }); + } + + fun add_members( + list: &mut vector
, new_members: &vector
, source_address: address + ) { + validate_members(new_members, source_address); + new_members.for_each_ref(|member| { + assert!( + !list.contains(member), + error::invalid_argument(EDUPLICATE_MEMBER) + ); + list.push_back(*member); + }); + } + + fun remove_members(list: &mut vector
, old_members: &vector
) { + old_members.for_each_ref(|member| { + let (found, index) = list.index_of(member); + if (found) { + list.remove(index); + }; + }); + } + + fun push_history(history: &mut vector, change: Change) { + if (history.length() >= MAX_HISTORY) { + history.remove(0); + }; + history.push_back(change); + } + + fun bind_nullifier( + source: address, subject: address, nullifier: vector + ) acquires Facts { + if (nullifier.is_empty()) { + return + }; + assert!( + nullifier.length() == DIGEST_LENGTH, + error::invalid_argument(EINVALID_BYTES_LENGTH) + ); + let nullifiers = &mut Facts[source].nullifiers; + if (table::contains(nullifiers, nullifier)) { + assert!( + *table::borrow(nullifiers, nullifier) == subject, + error::invalid_state(ENULLIFIER_BOUND) + ); + } else { + table::add(nullifiers, nullifier, subject); + }; + } + + fun assert_newer( + source: address, subject: address, issued_at_secs: u64 + ) acquires Facts { + let subjects = &Facts[source].subjects; + if (table::contains(subjects, subject)) { + assert!( + issued_at_secs > table::borrow(subjects, subject).issued_at_secs, + error::invalid_argument(ENOT_MONOTONIC) + ); + }; + } + + fun is_denied_internal(facts: &Facts, subject: address): bool { + table::contains(&facts.denied, subject) + && now_seconds() >= table::borrow(&facts.denied, subject).effective_at_secs + } + + fun assert_source_exists(source: address) { + assert!(exists(source), error::not_found(EACCOUNT_NOT_SOURCE)); + assert!(exists(source), error::not_found(EACCOUNT_NOT_SOURCE)); + } + + fun assert_admin(source: address, addr: address) acquires Source { + assert_source_exists(source); + assert!( + Source[source].admins.contains(&addr), + error::permission_denied(ENOT_ADMIN) + ); + } + + fun assert_not_paused(source: address) acquires Source { + assert_source_exists(source); + assert!(!Source[source].paused, error::invalid_state(EPAUSED)); + } + + /// Assert the caller is a registered, active issuer and return its id and current epoch. + fun assert_issuer(source: address, addr: address): (u16, u64) acquires Source, Facts { + assert_not_paused(source); + let config = &Source[source]; + assert!( + config.issuers.contains(&addr) && table::contains(&config.issuer_info, addr), + error::permission_denied(ENOT_ISSUER) + ); + let info = table::borrow(&config.issuer_info, addr); + assert!(info.active, error::permission_denied(ENOT_ISSUER)); + (info.id, issuer_epoch_of(source, info.id)) + } + + // =============================== Tests =============================== + + #[test_only] + use std::account::create_account_for_test; + #[test_only] + use std::timestamp; + + #[test_only] + const SUBJECT_A: address = @0xa11; + #[test_only] + const SUBJECT_B: address = @0xb22; + #[test_only] + const SUBJECT_C: address = @0xc33; + #[test_only] + const NULLIFIER: vector = x"1111111111111111111111111111111111111111111111111111111111111111"; + #[test_only] + const NULLIFIER_2: vector = x"2222222222222222222222222222222222222222222222222222222222222222"; + #[test_only] + const ROOT: vector = x"0573ec1d2c71abd9d936aca283796fc8a9fbaddc3266ecd0c390aa1106c3df3f"; + #[test_only] + const ONE_YEAR: u64 = 31536000; + #[test_only] + const LEVEL_BASIC: u8 = 1; + #[test_only] + const LEVEL_ENHANCED: u8 = 2; + + #[test_only] + fun setup(framework: &signer) { + timestamp::set_time_has_started_for_testing(framework); + chain_id::initialize_for_test(framework, 4); + } + + // Create a source whose deployer holds every role, which is the shape most tests want. + #[test_only] + fun create_for_test(deployer: &signer): address { + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = get_next_source_address(deployer_address); + create( + deployer, + vector[deployer_address], + vector[], + vector[deployer_address], + vector[deployer_address], + vector[deployer_address] + ); + source + } + + #[test_only] + fun expiry() : u64 { + now_seconds() + ONE_YEAR + } + + // --- Creation --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_create(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let predicted = get_next_source_address(deployer_address); + create(deployer, vector[deployer_address], vector[], vector[], vector[], vector[]); + + // The predicted address is where the source actually landed, so a client can derive it. + assert!(admins(predicted) == vector[deployer_address], 0); + assert!(issuers(predicted) == vector[], 1); + assert!(!is_paused(predicted), 2); + assert!(floor_epoch(predicted) == 0, 3); + // The deployer gains no role it was not listed for. + assert!(!is_issuer(deployer_address, predicted), 4); + // No subject is verified in a fresh source. + assert!(!is_verified(predicted, SUBJECT_A), 5); + assert!(state_of(predicted, SUBJECT_A) == STATE_NONE, 6); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000A, location = Self)] + fun test_create_without_admin_fails(framework: &signer, deployer: &signer) { + setup(framework); + create_account_for_test(address_of(deployer)); + create(deployer, vector[], vector[], vector[], vector[], vector[]); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10008, location = Self)] + fun test_create_with_duplicate_admin_fails(framework: &signer, deployer: &signer) { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + create( + deployer, + vector[deployer_address, deployer_address], + vector[], + vector[], + vector[], + vector[] + ); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x3000B, location = Self)] + fun test_cannot_remove_last_admin(framework: &signer, deployer: &signer) acquires Source { + setup(framework); + let source = create_for_test(deployer); + remove_admins(deployer, source, vector[address_of(deployer)]); + } + + // --- Issuing --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_issue_and_read(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let issuer_address = address_of(deployer); + register_issuer(deployer, source, issuer_address, vector[]); + assert!(issuer_id_of(source, issuer_address) == 1, 0); + + issue_batch( + deployer, + source, + vector[SUBJECT_A, SUBJECT_B], + vector[LEVEL_BASIC, LEVEL_ENHANCED], + vector[expiry(), expiry()], + 7 + ); + + assert!(is_verified(source, SUBJECT_A), 1); + assert!(is_verified(source, SUBJECT_B), 2); + assert!(level_of(source, SUBJECT_A) == LEVEL_BASIC, 3); + assert!(level_of(source, SUBJECT_B) == LEVEL_ENHANCED, 4); + assert!(state_of(source, SUBJECT_A) == STATE_ACTIVE, 5); + assert!(!is_verified(source, SUBJECT_C), 6); + + let record = record_of(source, SUBJECT_A); + assert!(record.issuer_id == 1, 7); + assert!(record.reason == 7, 8); + assert!(record.history.length() == 1, 9); + assert!(record.history[0].prev_state == STATE_NONE, 10); + assert!(record.history[0].new_state == STATE_ACTIVE, 11); + } + + #[test(framework = @0x1, deployer = @0x123, stranger = @0x456)] + #[expected_failure(abort_code = 0x50003, location = Self)] + fun test_non_issuer_cannot_issue( + framework: &signer, deployer: &signer, stranger: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + issue_batch(stranger, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000C, location = Self)] + fun test_issue_length_mismatch_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch( + deployer, + source, + vector[SUBJECT_A, SUBJECT_B], + vector[LEVEL_BASIC], + vector[expiry()], + 0 + ); + } + + // --- Expiry and staleness (INV-3) --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_expiry_stops_verification(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[100], 0); + + timestamp::update_global_time_for_test_secs(99); + assert!(is_verified(source, SUBJECT_A), 0); + timestamp::update_global_time_for_test_secs(100); + assert!(!is_verified(source, SUBJECT_A), 1); + // The record still exists, it is simply not usable. + assert!(state_of(source, SUBJECT_A) == STATE_NONE, 2); + assert!(record_of(source, SUBJECT_A).state == STATE_ACTIVE, 3); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_bump_issuer_epoch_kills_cohort(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch( + deployer, + source, + vector[SUBJECT_A, SUBJECT_B], + vector[LEVEL_BASIC, LEVEL_BASIC], + vector[expiry(), expiry()], + 0 + ); + assert!(is_verified(source, SUBJECT_A), 0); + assert!(is_verified(source, SUBJECT_B), 1); + + // One write invalidates every fact this issuer wrote. + bump_issuer_epoch(deployer, source, 1); + assert!(!is_verified(source, SUBJECT_A), 2); + assert!(!is_verified(source, SUBJECT_B), 3); + assert!(issuer_epoch_of(source, 1) == 1, 4); + + // Re-issuing under the new epoch restores them. + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + assert!(is_verified(source, SUBJECT_A), 5); + assert!(!is_verified(source, SUBJECT_B), 6); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_floor_epoch_kills_everything(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + assert!(is_verified(source, SUBJECT_A), 0); + set_floor_epoch(deployer, source, 1); + assert!(!is_verified(source, SUBJECT_A), 1); + } + + // --- Revocation and suspension --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_revoke_and_suspend(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch( + deployer, + source, + vector[SUBJECT_A, SUBJECT_B], + vector[LEVEL_BASIC, LEVEL_BASIC], + vector[expiry(), expiry()], + 0 + ); + + suspend(deployer, source, SUBJECT_A, 11); + assert!(!is_verified(source, SUBJECT_A), 0); + assert!(state_of(source, SUBJECT_A) == STATE_SUSPENDED, 1); + unsuspend(deployer, source, SUBJECT_A, 12); + assert!(is_verified(source, SUBJECT_A), 2); + + revoke_batch(deployer, source, vector[SUBJECT_B], 13); + assert!(!is_verified(source, SUBJECT_B), 3); + assert!(state_of(source, SUBJECT_B) == STATE_REVOKED, 4); + assert!(record_of(source, SUBJECT_B).revoked_at_secs == now_seconds(), 5); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x60015, location = Self)] + fun test_suspend_unknown_subject_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + suspend(deployer, source, SUBJECT_A, 0); + } + + // --- Exclusion (INV-1, INV-2, INV-5) --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_deny_beats_a_valid_fact(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_ENHANCED], vector[expiry()], 0); + assert!(is_verified(source, SUBJECT_A), 0); + + deny(deployer, source, SUBJECT_A, 42, now_seconds()); + assert!(!is_verified(source, SUBJECT_A), 1); + assert!(is_denied(source, SUBJECT_A), 2); + assert!(deny_reason(source, SUBJECT_A) == 42, 3); + assert!(state_of(source, SUBJECT_A) == STATE_REVOKED, 4); + assert!(level_of(source, SUBJECT_A) == 0, 5); + + // A different role reverses it, and the underlying fact is still intact. + undeny(deployer, source, SUBJECT_A); + assert!(!is_denied(source, SUBJECT_A), 6); + assert!(is_verified(source, SUBJECT_A), 7); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_deny_effective_in_future(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + deny(deployer, source, SUBJECT_A, 1, 500); + // Announced but not yet in force. + assert!(is_verified(source, SUBJECT_A), 0); + timestamp::update_global_time_for_test_secs(500); + assert!(!is_verified(source, SUBJECT_A), 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30013, location = Self)] + fun test_issue_over_denial_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + deny(deployer, source, SUBJECT_A, 1, now_seconds()); + // INV-2: no positive write may clear or ignore a denial, even a valid one from an issuer. + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + } + + #[test(framework = @0x1, deployer = @0x123, sentinel = @0x456)] + #[expected_failure(abort_code = 0x50005, location = Self)] + fun test_sentinel_cannot_undeny( + framework: &signer, deployer: &signer, sentinel: &signer + ) acquires Source, Facts { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = get_next_source_address(deployer_address); + // A sentinel that is deliberately NOT a remover. INV-5. + create( + deployer, + vector[deployer_address], + vector[], + vector[address_of(sentinel)], + vector[], + vector[] + ); + deny(sentinel, source, SUBJECT_A, 1, now_seconds()); + undeny(sentinel, source, SUBJECT_A); + } + + #[test(framework = @0x1, deployer = @0x123, remover = @0x456)] + #[expected_failure(abort_code = 0x50004, location = Self)] + fun test_remover_cannot_deny( + framework: &signer, deployer: &signer, remover: &signer + ) acquires Source, Facts { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = get_next_source_address(deployer_address); + create( + deployer, + vector[deployer_address], + vector[], + vector[], + vector[address_of(remover)], + vector[] + ); + deny(remover, source, SUBJECT_A, 1, now_seconds()); + } + + // --- Pausing (INV-4) --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_pause_blocks_writes_not_reads(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + + pause(deployer, source); + assert!(is_paused(source), 0); + // INV-4: the answer does not change when a source is paused. + assert!(is_verified(source, SUBJECT_A), 1); + unpause(deployer, source); + assert!(!is_paused(source), 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30007, location = Self)] + fun test_paused_source_rejects_writes(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + pause(deployer, source); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + } + + // --- Attributes --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_attributes(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + + assert!(attribute_of(source, SUBJECT_A, 1) == vector[], 0); + set_attribute(deployer, source, SUBJECT_A, 1, x"0348"); + assert!(attribute_of(source, SUBJECT_A, 1) == x"0348", 1); + set_attribute(deployer, source, SUBJECT_A, 1, x"0250"); + assert!(attribute_of(source, SUBJECT_A, 1) == x"0250", 2); + remove_attribute(deployer, source, SUBJECT_A, 1); + assert!(attribute_of(source, SUBJECT_A, 1) == vector[], 3); + // An attribute survives a re-issue, because the record is updated rather than replaced. + set_attribute(deployer, source, SUBJECT_A, 2, x"01"); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_ENHANCED], vector[expiry()], 0); + assert!(attribute_of(source, SUBJECT_A, 2) == x"01", 4); + } + + // --- Permissionless relay path --- + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + fun test_redeem_attestation( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + let pubkey = ed25519::validated_public_key_to_bytes(&validated_key); + register_issuer(deployer, source, address_of(deployer), pubkey); + + let issued_at = 10; + timestamp::update_global_time_for_test_secs(issued_at); + let expires_at = 1000; + let message = + attestation_message( + source, SUBJECT_A, 1, 0, LEVEL_ENHANCED, expires_at, issued_at, NULLIFIER + ); + let signature = + ed25519::signature_to_bytes( + &ed25519::sign_arbitrary_bytes(&secret_key, message) + ); + + // Anyone may submit it. The relayer is neither the subject nor the issuer. + redeem_attestation( + relayer, + source, + SUBJECT_A, + 1, + 0, + LEVEL_ENHANCED, + expires_at, + issued_at, + NULLIFIER, + signature + ); + + assert!(is_verified(source, SUBJECT_A), 0); + assert!(level_of(source, SUBJECT_A) == LEVEL_ENHANCED, 1); + assert!(record_of(source, SUBJECT_A).attestation_digest.length() == 32, 2); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + #[expected_failure(abort_code = 0x10010, location = Self)] + fun test_redeem_with_tampered_level_fails( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + register_issuer( + deployer, + source, + address_of(deployer), + ed25519::validated_public_key_to_bytes(&validated_key) + ); + let message = + attestation_message(source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + let signature = + ed25519::signature_to_bytes( + &ed25519::sign_arbitrary_bytes(&secret_key, message) + ); + // The relayer raises the level it submits. Every field is covered by the signature. + redeem_attestation( + relayer, source, SUBJECT_A, 1, 0, LEVEL_ENHANCED, 1000, 10, NULLIFIER, signature + ); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + #[expected_failure(abort_code = 0x30011, location = Self)] + fun test_redeem_with_stale_epoch_fails( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + register_issuer( + deployer, + source, + address_of(deployer), + ed25519::validated_public_key_to_bytes(&validated_key) + ); + let message = + attestation_message(source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + let signature = + ed25519::signature_to_bytes( + &ed25519::sign_arbitrary_bytes(&secret_key, message) + ); + // A compromise bump lands before the attestation is redeemed. + bump_issuer_epoch(deployer, source, 1); + redeem_attestation( + relayer, source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER, signature + ); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + #[expected_failure(abort_code = 0x10012, location = Self)] + fun test_redeem_replay_fails( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + register_issuer( + deployer, + source, + address_of(deployer), + ed25519::validated_public_key_to_bytes(&validated_key) + ); + let message = + attestation_message(source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + let signature = + ed25519::signature_to_bytes( + &ed25519::sign_arbitrary_bytes(&secret_key, message) + ); + redeem_attestation( + relayer, source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER, signature + ); + // Replaying the same attestation would push the expiry back out after a revocation. + redeem_attestation( + relayer, source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER, signature + ); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + #[expected_failure(abort_code = 0x30014, location = Self)] + fun test_nullifier_cannot_bind_two_subjects( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + register_issuer( + deployer, + source, + address_of(deployer), + ed25519::validated_public_key_to_bytes(&validated_key) + ); + let first = + attestation_message(source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + redeem_attestation( + relayer, + source, + SUBJECT_A, + 1, + 0, + LEVEL_BASIC, + 1000, + 10, + NULLIFIER, + ed25519::signature_to_bytes(&ed25519::sign_arbitrary_bytes(&secret_key, first)) + ); + // Same identity, second address. One identity binds to one subject. + let second = + attestation_message(source, SUBJECT_B, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + redeem_attestation( + relayer, + source, + SUBJECT_B, + 1, + 0, + LEVEL_BASIC, + 1000, + 10, + NULLIFIER, + ed25519::signature_to_bytes(&ed25519::sign_arbitrary_bytes(&secret_key, second)) + ); + } + + // --- Roots --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_publish_root_and_verify_membership( + framework: &signer, deployer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + publish_root(deployer, source, ROOT, 5); + + let root = current_root(source); + assert!(root.digest == ROOT, 0); + assert!(root.leaf_count == 5, 1); + assert!(root.issuer_id == 1, 2); + + // A garbage proof does not verify against a real root. The vectors that DO verify live in + // merkle_proof::oz_vectors, which pins them against OpenZeppelin's construction; here the + // leaf is bound to this source address so those vectors deliberately do not apply. + assert!( + !verify_membership( + source, + SUBJECT_A, + vector[x"0000000000000000000000000000000000000000000000000000000000000001"] + ), + 3 + ); + } + + // --- Role management --- + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_role_add_and_remove( + framework: &signer, deployer: &signer, other: &signer + ) acquires Source { + setup(framework); + let source = create_for_test(deployer); + let other_address = address_of(other); + + add_admins(deployer, source, vector[other_address]); + assert!(is_admin(other_address, source), 0); + add_issuers(deployer, source, vector[other_address]); + assert!(is_issuer(other_address, source), 1); + add_sentinels(deployer, source, vector[other_address]); + assert!(sentinels(source).contains(&other_address), 2); + add_removers(deployer, source, vector[other_address]); + assert!(removers(source).contains(&other_address), 3); + add_guardians(deployer, source, vector[other_address]); + assert!(guardians(source).contains(&other_address), 4); + + remove_issuers(deployer, source, vector[other_address]); + assert!(!is_issuer(other_address, source), 5); + remove_admins(deployer, source, vector[other_address]); + assert!(!is_admin(other_address, source), 6); + } + + #[test(framework = @0x1, deployer = @0x123, stranger = @0x456)] + #[expected_failure(abort_code = 0x50002, location = Self)] + fun test_non_admin_cannot_grant_roles( + framework: &signer, deployer: &signer, stranger: &signer + ) acquires Source { + setup(framework); + let source = create_for_test(deployer); + add_issuers(stranger, source, vector[address_of(stranger)]); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x8000F, location = Self)] + fun test_duplicate_issuer_registration_fails( + framework: &signer, deployer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + register_issuer(deployer, source, address_of(deployer), vector[]); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x60001, location = Self)] + fun test_reads_on_non_source_fail(framework: &signer, deployer: &signer) acquires Facts { + setup(framework); + create_account_for_test(address_of(deployer)); + is_verified(@0xdead, SUBJECT_A); + } + + // --- Two sources disagree, which is the point --- + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_two_sources_are_independent( + framework: &signer, deployer: &signer, other: &signer + ) acquires Source, Facts { + setup(framework); + let first = create_for_test(deployer); + let second = create_for_test(other); + register_issuer(deployer, first, address_of(deployer), vector[]); + register_issuer(other, second, address_of(other), vector[]); + + issue_batch(deployer, first, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + deny(other, second, SUBJECT_A, 1, now_seconds()); + + // A fact is never true globally, only true according to somebody. + assert!(is_verified(first, SUBJECT_A), 0); + assert!(!is_verified(second, SUBJECT_A), 1); + assert!(!is_denied(first, SUBJECT_A), 2); + assert!(is_denied(second, SUBJECT_A), 3); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30019, location = Self)] + fun test_unsuspend_revoked_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + revoke_batch(deployer, source, vector[SUBJECT_A], 1); + // Revocation is terminal: only re-issuance brings the subject back. + unsuspend(deployer, source, SUBJECT_A, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30019, location = Self)] + fun test_suspend_revoked_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + revoke_batch(deployer, source, vector[SUBJECT_A], 1); + suspend(deployer, source, SUBJECT_A, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30019, location = Self)] + fun test_suspend_twice_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + suspend(deployer, source, SUBJECT_A, 1); + suspend(deployer, source, SUBJECT_A, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30019, location = Self)] + fun test_unsuspend_active_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + unsuspend(deployer, source, SUBJECT_A, 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_revoke_batch_skips_revoked_and_revokes_suspended( + framework: &signer, deployer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch( + deployer, + source, + vector[SUBJECT_A, SUBJECT_B], + vector[LEVEL_BASIC, LEVEL_BASIC], + vector[expiry(), expiry()], + 0 + ); + revoke_batch(deployer, source, vector[SUBJECT_A], 1); + suspend(deployer, source, SUBJECT_B, 2); + let history_before = record_of(source, SUBJECT_A).history.length(); + + // A is already revoked and is skipped without an abort or a history entry. + revoke_batch(deployer, source, vector[SUBJECT_A, SUBJECT_B], 3); + assert!(record_of(source, SUBJECT_A).history.length() == history_before, 0); + assert!(record_of(source, SUBJECT_A).reason == 1, 1); + assert!(state_of(source, SUBJECT_B) == STATE_REVOKED, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30013, location = Self)] + fun test_unsuspend_denied_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + suspend(deployer, source, SUBJECT_A, 1); + deny(deployer, source, SUBJECT_A, 9, now_seconds()); + unsuspend(deployer, source, SUBJECT_A, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_unsuspend_after_bump_stays_dead(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + suspend(deployer, source, SUBJECT_A, 1); + bump_issuer_epoch(deployer, source, 1); + // Reactivating keeps the epoch the fact was issued under, which the bump killed. + unsuspend(deployer, source, SUBJECT_A, 2); + assert!(!is_verified(source, SUBJECT_A), 0); + assert!(record_of(source, SUBJECT_A).issuer_epoch == 0, 1); + } + + #[test(framework = @0x1, deployer = @0x123, second = @0x456)] + fun test_transition_keeps_original_issuer( + framework: &signer, deployer: &signer, second: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + register_issuer(deployer, source, address_of(second), vector[]); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + + suspend(second, source, SUBJECT_A, 1); + unsuspend(second, source, SUBJECT_A, 2); + let record = record_of(source, SUBJECT_A); + assert!(record.issuer_id == 1, 0); + // The acting issuer is still on the record, in the history. + assert!(record.history[record.history.length() - 1].issuer_id == 2, 1); + + // So bumping the original issuer still kills the fact. + bump_issuer_epoch(deployer, source, 1); + assert!(!is_verified(source, SUBJECT_A), 2); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0x456)] + #[expected_failure(abort_code = 0x50003, location = Self)] + fun test_removed_issuer_cannot_relay( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + let (secret_key, validated_key) = ed25519::generate_keys(); + register_issuer( + deployer, + source, + address_of(deployer), + ed25519::validated_public_key_to_bytes(&validated_key) + ); + let message = + attestation_message(source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER); + let signature = + ed25519::signature_to_bytes( + &ed25519::sign_arbitrary_bytes(&secret_key, message) + ); + remove_issuers(deployer, source, vector[address_of(deployer)]); + redeem_attestation( + relayer, source, SUBJECT_A, 1, 0, LEVEL_BASIC, 1000, 10, NULLIFIER, signature + ); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_writes_after_floor_raise_are_usable(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + set_floor_epoch(deployer, source, 3); + // The issuer's effective epoch follows the floor, so new writes are stamped with it. + assert!(issuer_epoch_of(source, 1) == 3, 0); + assert!(issuer_epoch_of(source, 0) == 3, 1); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + assert!(is_verified(source, SUBJECT_A), 2); + assert!(record_of(source, SUBJECT_A).issuer_epoch == 3, 3); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_bump_below_floor_still_takes_effect(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + register_issuer(deployer, source, address_of(deployer), vector[]); + set_floor_epoch(deployer, source, 5); + issue_batch(deployer, source, vector[SUBJECT_A], vector[LEVEL_BASIC], vector[expiry()], 0); + assert!(is_verified(source, SUBJECT_A), 0); + // The issuer's own counter is 0, below the floor; a bump still moves past the floor. + bump_issuer_epoch(deployer, source, 1); + assert!(issuer_epoch_of(source, 1) == 6, 1); + assert!(!is_verified(source, SUBJECT_A), 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10012, location = Self)] + fun test_floor_cannot_be_lowered(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + set_floor_epoch(deployer, source, 2); + set_floor_epoch(deployer, source, 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10012, location = Self)] + fun test_floor_cannot_be_reset_to_same(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + set_floor_epoch(deployer, source, 2); + set_floor_epoch(deployer, source, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_bump_zktls_cohort(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + // Issuer id 0 is the zkTLS cohort and needs no registration. + assert!(issuer_epoch_of(source, 0) == 0, 0); + bump_issuer_epoch(deployer, source, 0); + assert!(issuer_epoch_of(source, 0) == 1, 1); + bump_issuer_epoch(deployer, source, 0); + assert!(issuer_epoch_of(source, 0) == 2, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x6000e, location = Self)] + fun test_bump_unknown_issuer_fails(framework: &signer, deployer: &signer) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + bump_issuer_epoch(deployer, source, 7); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_verify_membership_against_an_oz_root( + framework: &signer, deployer: &signer + ) acquires Source, Facts { + setup(framework); + let source = create_for_test(deployer); + // Deterministic, so the root below could be built off chain before the source existed. + assert!(source == @0x94d1a87048840daa2ccb24dd50b1176d808ab8dc170a2492845f416a7c5801b1, 0); + register_issuer(deployer, source, address_of(deployer), vector[]); + // StandardMerkleTree.of([[source, s] for s in a11, b22, c33, d44, e55, f66, 777], + // ['bytes32', 'bytes32']) from @openzeppelin/merkle-tree. + publish_root( + deployer, + source, + x"6b0f31d23e28de5e4a07f4ff973ece0ecd3f044616eae7984a664250a2abdc19", + 7 + ); + let proof_a = vector[ + x"c186e553e4a1243744c5c6ebfb79c16707bd5d312b56362f596dde270a133bff", + x"acf871e7d55582aae9aae3304b30209eae259960aa7fca4428019394da24a1b9", + x"4faffc94448d88f74e4a8258458427d33bbdef40c08f92a3f6781d5287547d53" + ]; + assert!(verify_membership(source, SUBJECT_A, proof_a), 1); + assert!(!verify_membership(source, SUBJECT_B, proof_a), 2); + + // Rotating the root invalidates proofs against the old one. + publish_root(deployer, source, ROOT, 5); + assert!(!verify_membership(source, SUBJECT_A, proof_a), 3); + } +} diff --git a/aptos-move/framework/aptos-framework/sources/attestation.spec.move b/aptos-move/framework/aptos-framework/sources/attestation.spec.move new file mode 100644 index 00000000000..41943ebc331 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation.spec.move @@ -0,0 +1,500 @@ +spec aptos_framework::attestation { + /// + /// No.: 1 + /// Requirement: A denial always wins. While a denial is in effect for a subject, the source never reports that + /// subject as verified, regardless of any fact recorded for it (INV-1). + /// Criticality: Critical + /// Implementation: active_with_level checks is_denied_internal before reading the subject's record and returns + /// (false, 0) when the denial is in effect. is_verified, level_of and state_of all route through it, and + /// attestation_policy consults is_denied before any positive rule. + /// Enforcement: Formally verified via [high-level-req-1](active_with_level). + /// + /// No.: 2 + /// Requirement: No positive write path can create, modify or clear a denial (INV-2), and no positive write can be + /// recorded for a subject that has a denial entry. + /// Criticality: Critical + /// Implementation: Every positive write ends in record_fact, which aborts when the subject has a denial entry and + /// never touches the denied table. transition refuses to reactivate a denied subject. + /// Enforcement: Formally verified via [high-level-req-2.1](record_fact) and [high-level-req-2.2](transition). + /// + /// No.: 3 + /// Requirement: A fact whose expiry has passed, whose epoch is below its issuer's current epoch, or whose epoch is + /// below the source floor is never treated as active (INV-3). A floor raise or an epoch bump always takes effect, + /// and new writes are stamped with the issuer's effective epoch so they are not born stale. + /// Criticality: Critical + /// Implementation: active_with_level compares the record against now_seconds(), the per-issuer epoch and the + /// floor. issuer_epoch_of returns max(own counter, floor), which is what assert_issuer and record_verified_claim + /// stamp and what bump_issuer_epoch increments. set_floor_epoch is strictly increasing. + /// Enforcement: Formally verified via [high-level-req-3.1](active_with_level), [high-level-req-3.2](issuer_epoch_of), + /// [high-level-req-3.3](bump_issuer_epoch), [high-level-req-3.4](set_floor_epoch) and + /// [high-level-req-3.5](record_verified_claim). + /// + /// No.: 4 + /// Requirement: Pausing a source blocks writes and never changes any fact or denial, so it never changes what + /// is_verified reports (INV-4). + /// Criticality: High + /// Implementation: set_paused writes only Source.paused. Facts is a separate resource that the read path uses + /// exclusively; every write path calls assert_not_paused. + /// Enforcement: Formally verified via [high-level-req-4](set_paused). + /// + /// No.: 5 + /// Requirement: Only a sentinel can add a denial and only a remover can remove one (INV-5). Neither operation + /// touches any fact. + /// Criticality: Critical + /// Implementation: deny and deny_batch check the sentinel list, undeny checks the remover list, and both write only + /// the denied table. + /// Enforcement: Formally verified via [high-level-req-5.1](deny), [high-level-req-5.2](deny_batch) and + /// [high-level-req-5.3](undeny). + /// + /// No.: 6 + /// Requirement: Lifecycle changes follow ACTIVE <-> SUSPENDED and ACTIVE or SUSPENDED -> REVOKED only. A revoked + /// record is terminal until re-issued, and a lifecycle change never alters the issuer or epoch a fact was issued + /// under, so it cannot resurrect a fact an epoch bump killed. + /// Criticality: Critical + /// Implementation: transition asserts the allowed transitions, treats a repeated revocation as a no-op, and + /// leaves issuer_id and issuer_epoch unchanged; the acting issuer is recorded in the history entry only. + /// Enforcement: Formally verified via [high-level-req-6](transition). + /// + /// No.: 7 + /// Requirement: Configuration and facts live in separate resources, and the read path touches only Facts and + /// per-subject table entries, so gated transactions never conflict with one another under Block-STM (INV-7). + /// Criticality: Medium + /// Implementation: Facts is published once at creation and only its table entries change afterwards. The read + /// functions (is_verified, active_with_level, is_denied) acquire Facts only. + /// Enforcement: Enforced by the `acquires` annotations of the read functions, which the compiler checks. Audited + /// that no write path replaces the Facts resource itself. + /// + /// No.: 8 + /// Requirement: Every role list is duplicate free, never contains the source itself, and a source always has at + /// least one admin. + /// Criticality: High + /// Implementation: validate_members runs on creation and on every add, and remove_admins asserts that at least one + /// admin remains. + /// Enforcement: Formally verified via [high-level-req-8.1](create_source_internal) and + /// [high-level-req-8.2](remove_admins). + /// + /// No.: 9 + /// Requirement: Only a current issuer can write facts, directly or through the permissionless relay path. Removing + /// an issuer's role also stops its relayed attestations. + /// Criticality: Critical + /// Implementation: assert_issuer and redeem_attestation both require the issuer to be in Source.issuers. + /// Enforcement: Formally verified via [high-level-req-9.1](suspend) and [high-level-req-9.2](redeem_attestation). + /// + /// + /// INV-6 of the functional specification (every keyed resource an enum with a V1 variant) was not adopted: the + /// module follows the plain-struct style of the rest of the framework, so it is not specified here. INV-8 and INV-9 + /// belong to attestation_authorization and attestation_policy respectively. + spec module { + pragma verify = true; + pragma aborts_if_is_strict = false; + } + + spec fun spec_now(): u64 { + aptos_framework::timestamp::spec_now_seconds() + } + + spec fun spec_has_time(): bool { + exists(@aptos_framework) + } + + spec fun spec_is_source(source: address): bool { + exists(source) && exists(source) + } + + /// A denial is in effect for the subject. + spec fun spec_is_denied(source: address, subject: address): bool { + let denied = global(source).denied; + table::spec_contains(denied, subject) + && spec_now() >= table::spec_get(denied, subject).effective_at_secs + } + + spec fun spec_floor(source: address): u64 { + table::spec_get(global(source).floor_epoch, 0) + } + + spec fun spec_own_epoch(source: address, issuer_id: u16): u64 { + let epochs = global(source).issuer_epochs; + if (table::spec_contains(epochs, issuer_id)) { + table::spec_get(epochs, issuer_id) + } else { 0 } + } + + /// Effective epoch of an issuer: the larger of its own counter and the source floor. + spec fun spec_effective_epoch(source: address, issuer_id: u16): u64 { + let own = spec_own_epoch(source, issuer_id); + let floor = spec_floor(source); + if (own > floor) { own } else { floor } + } + + spec schema SourceExistsAbortsIf { + source: address; + aborts_if !spec_is_source(source); + } + + spec schema AdminAbortsIf { + source: address; + admin: signer; + include SourceExistsAbortsIf; + aborts_if !contains(global(source).admins, address_of(admin)); + } + + // =============================== Views =============================== + + spec is_source(source: address): bool { + aborts_if false; + ensures result == spec_is_source(source); + } + + spec standard_version(): u64 { + aborts_if false; + ensures result == VERSION; + } + + spec is_paused(source: address): bool { + include SourceExistsAbortsIf; + ensures result == global(source).paused; + } + + spec admins(source: address): vector
{ + include SourceExistsAbortsIf; + ensures result == global(source).admins; + } + + spec is_admin(addr: address, source: address): bool { + include SourceExistsAbortsIf; + ensures result == contains(global(source).admins, addr); + } + + spec is_issuer(addr: address, source: address): bool { + include SourceExistsAbortsIf; + ensures result == contains(global(source).issuers, addr); + } + + spec is_denied(source: address, subject: address): bool { + include SourceExistsAbortsIf; + aborts_if table::spec_contains(global(source).denied, subject) && !spec_has_time(); + ensures result == spec_is_denied(source, subject); + } + + spec floor_epoch(source: address): u64 { + include SourceExistsAbortsIf; + aborts_if !table::spec_contains(global(source).floor_epoch, 0); + ensures result == spec_floor(source); + } + + spec issuer_epoch_of(source: address, issuer_id: u16): u64 { + include SourceExistsAbortsIf; + aborts_if !table::spec_contains(global(source).floor_epoch, 0); + /// [high-level-req-3.2] + ensures result == spec_effective_epoch(source, issuer_id); + ensures result >= spec_floor(source); + ensures result >= spec_own_epoch(source, issuer_id); + } + + spec active_with_level(source: address, subject: address): (bool, u8) { + // The abort conditions depend on which early return is taken (a missing clock is only + // reached through a denial entry or an active record), so only the unconditional one is + // listed; the properties that matter are the ensures below. + pragma aborts_if_is_partial; + include SourceExistsAbortsIf; + let facts = global(source); + let record = table::spec_get(facts.subjects, subject); + /// [high-level-req-1] + ensures spec_is_denied(source, subject) ==> !result_1 && result_2 == 0; + /// [high-level-req-3.1] + ensures result_1 ==> table::spec_contains(facts.subjects, subject) + && record.state == STATE_ACTIVE + && record.expires_at_secs > spec_now() + && record.issuer_epoch >= spec_effective_epoch(source, record.issuer_id); + ensures result_1 ==> result_2 == record.level; + ensures !result_1 ==> result_2 == 0; + } + + // =============================== Creation and roles =============================== + + spec create( + deployer: &signer, + admins: vector
, + issuers: vector
, + sentinels: vector
, + removers: vector
, + guardians: vector
+ ) { + // create_resource_account has cross-module side effects (account creation, coin + // registration, sequence-number seed derivation) that the prover cannot model. The + // invariants of the published state are verified on create_source_internal instead. + pragma verify = false; + } + + spec create_source_internal( + source_account: &signer, + deployer: address, + admins: vector
, + issuers: vector
, + sentinels: vector
, + removers: vector
, + guardians: vector
, + signer_cap: account::SignerCapability + ) { + let addr = address_of(source_account); + // Duplicate and self-membership rejection happens inside for_each_ref loops, which the + // prover havocs; those abort paths are covered by unit tests instead. + pragma aborts_if_is_partial; + /// [high-level-req-8.1] + aborts_if len(admins) < 1; + aborts_if exists(addr); + aborts_if exists(addr); + ensures spec_is_source(addr); + ensures global(addr).admins == admins; + ensures global(addr).issuers == issuers; + ensures global(addr).sentinels == sentinels; + ensures global(addr).removers == removers; + ensures global(addr).guardians == guardians; + ensures !global(addr).paused; + ensures global(addr).next_issuer_id == 1; + ensures table::spec_contains(global(addr).floor_epoch, 0); + ensures spec_floor(addr) == 0; + } + + spec remove_admins(admin: &signer, source: address, old_admins: vector
) { + pragma aborts_if_is_partial; + include AdminAbortsIf; + /// [high-level-req-8.2] + ensures len(global(source).admins) >= 1; + } + + spec add_admins(admin: &signer, source: address, new_admins: vector
) { + pragma aborts_if_is_partial; + include AdminAbortsIf; + } + + // =============================== Pause =============================== + + spec set_paused(guardian: &signer, source: address, paused: bool) { + include SourceExistsAbortsIf; + aborts_if !contains(global(source).guardians, address_of(guardian)); + ensures global(source).paused == paused; + /// [high-level-req-4] + ensures global(source) == old(global(source)); + } + + spec pause(guardian: &signer, source: address) { + include SourceExistsAbortsIf; + aborts_if !contains(global(source).guardians, address_of(guardian)); + ensures global(source).paused; + ensures global(source) == old(global(source)); + } + + spec unpause(guardian: &signer, source: address) { + include SourceExistsAbortsIf; + aborts_if !contains(global(source).guardians, address_of(guardian)); + ensures !global(source).paused; + ensures global(source) == old(global(source)); + } + + // =============================== Epochs =============================== + + spec bump_issuer_epoch(admin: &signer, source: address, issuer_id: u16) { + include AdminAbortsIf; + aborts_if issuer_id != 0 && !table::spec_contains(global(source).issuer_by_id, issuer_id); + aborts_if !table::spec_contains(global(source).floor_epoch, 0); + aborts_if spec_effective_epoch(source, issuer_id) + 1 > MAX_U64; + /// [high-level-req-3.3] + ensures spec_own_epoch(source, issuer_id) == old(spec_effective_epoch(source, issuer_id)) + 1; + ensures spec_effective_epoch(source, issuer_id) > old(spec_effective_epoch(source, issuer_id)); + ensures global(source).subjects == old(global(source).subjects); + ensures global(source).denied == old(global(source).denied); + ensures global(source).floor_epoch == old(global(source).floor_epoch); + } + + spec set_floor_epoch(admin: &signer, source: address, epoch: u64) { + include AdminAbortsIf; + aborts_if !table::spec_contains(global(source).floor_epoch, 0); + /// [high-level-req-3.4] + aborts_if epoch <= spec_floor(source); + ensures spec_floor(source) == epoch; + ensures spec_floor(source) > old(spec_floor(source)); + ensures global(source).subjects == old(global(source).subjects); + ensures global(source).denied == old(global(source).denied); + } + + // =============================== Positive writes =============================== + + spec record_fact( + source: address, + subject: address, + state: u8, + level: u8, + issuer_id: u16, + issuer_epoch: u64, + expires_at_secs: u64, + issued_at_secs: u64, + reason: u16, + attestation_digest: vector + ) { + pragma aborts_if_is_partial; + aborts_if !exists(source); + aborts_if !spec_has_time(); + /// [high-level-req-2.1] + aborts_if table::spec_contains(global(source).denied, subject); + ensures global(source).denied == old(global(source).denied); + ensures global(source).issuer_epochs == old(global(source).issuer_epochs); + ensures global(source).floor_epoch == old(global(source).floor_epoch); + ensures global(source).nullifiers == old(global(source).nullifiers); + let post record = table::spec_get(global(source).subjects, subject); + ensures table::spec_contains(global(source).subjects, subject); + ensures record.state == state; + ensures record.level == level; + ensures record.issuer_id == issuer_id; + ensures record.issuer_epoch == issuer_epoch; + ensures record.expires_at_secs == expires_at_secs; + } + + spec transition( + source: address, + subject: address, + state: u8, + acting_issuer_id: u16, + reason: u16 + ) { + pragma aborts_if_is_partial; + let facts = global(source); + let record = table::spec_get(facts.subjects, subject); + let post post_record = table::spec_get(global(source).subjects, subject); + aborts_if !exists(source); + aborts_if !table::spec_contains(facts.subjects, subject); + /// [high-level-req-6] + aborts_if state == STATE_SUSPENDED && record.state != STATE_ACTIVE; + aborts_if state == STATE_ACTIVE && record.state != STATE_SUSPENDED; + aborts_if state == STATE_REVOKED && record.state != STATE_ACTIVE + && record.state != STATE_SUSPENDED && record.state != STATE_REVOKED; + aborts_if state != STATE_ACTIVE && state != STATE_SUSPENDED && state != STATE_REVOKED; + /// [high-level-req-2.2] + aborts_if state == STATE_ACTIVE && table::spec_contains(facts.denied, subject); + ensures post_record.issuer_id == record.issuer_id; + ensures post_record.issuer_epoch == record.issuer_epoch; + ensures post_record.level == record.level; + ensures post_record.expires_at_secs == record.expires_at_secs; + ensures post_record.state == state; + ensures record.state == STATE_REVOKED ==> post_record == record; + ensures global(source).denied == old(global(source).denied); + ensures global(source).issuer_epochs == old(global(source).issuer_epochs); + ensures global(source).floor_epoch == old(global(source).floor_epoch); + } + + spec suspend(issuer: &signer, source: address, subject: address, reason: u16) { + pragma aborts_if_is_partial; + include SourceExistsAbortsIf; + aborts_if global(source).paused; + /// [high-level-req-9.1] + aborts_if !contains(global(source).issuers, address_of(issuer)); + aborts_if !table::spec_contains(global(source).issuer_info, address_of(issuer)); + ensures table::spec_get(global(source).subjects, subject).state == STATE_SUSPENDED; + ensures global(source).denied == old(global(source).denied); + } + + spec unsuspend(issuer: &signer, source: address, subject: address, reason: u16) { + pragma aborts_if_is_partial; + include SourceExistsAbortsIf; + aborts_if global(source).paused; + aborts_if !contains(global(source).issuers, address_of(issuer)); + aborts_if table::spec_contains(global(source).denied, subject); + aborts_if table::spec_contains(global(source).subjects, subject) + && table::spec_get(global(source).subjects, subject).state != STATE_SUSPENDED; + ensures table::spec_get(global(source).subjects, subject).issuer_epoch + == old(table::spec_get(global(source).subjects, subject).issuer_epoch); + ensures global(source).denied == old(global(source).denied); + } + + spec record_verified_claim( + source: address, + subject: address, + level: u8, + expires_at_secs: u64, + attestation_digest: vector, + nullifier: vector + ) { + pragma aborts_if_is_partial; + include SourceExistsAbortsIf; + aborts_if global(source).paused; + aborts_if len(attestation_digest) != DIGEST_LENGTH; + aborts_if table::spec_contains(global(source).denied, subject); + let post record = table::spec_get(global(source).subjects, subject); + ensures record.issuer_id == 0; + /// [high-level-req-3.5] + ensures record.issuer_epoch == old(spec_effective_epoch(source, 0)); + ensures record.level == level; + ensures record.state == STATE_ACTIVE; + ensures global(source).denied == old(global(source).denied); + } + + spec redeem_attestation( + _relayer: &signer, + source: address, + subject: address, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: vector, + signature: vector + ) { + pragma aborts_if_is_partial; + let config = global(source); + let issuer_address = table::spec_get(config.issuer_by_id, issuer_id); + include SourceExistsAbortsIf; + aborts_if config.paused; + aborts_if !table::spec_contains(config.issuer_by_id, issuer_id); + /// [high-level-req-9.2] + aborts_if !contains(config.issuers, issuer_address); + aborts_if table::spec_contains(global(source).denied, subject); + ensures global(source).denied == old(global(source).denied); + ensures table::spec_get(global(source).subjects, subject).issuer_epoch == issuer_epoch; + } + + // =============================== Exclusion =============================== + + spec deny( + sentinel: &signer, + source: address, + subject: address, + reason: u16, + effective_at_secs: u64 + ) { + include SourceExistsAbortsIf; + /// [high-level-req-5.1] + aborts_if !contains(global(source).sentinels, address_of(sentinel)); + aborts_if !spec_has_time(); + ensures table::spec_contains(global(source).denied, subject); + ensures table::spec_get(global(source).denied, subject).effective_at_secs == effective_at_secs; + ensures global(source).subjects == old(global(source).subjects); + } + + spec deny_batch(sentinel: &signer, source: address, subjects: vector
, reason: u16) { + pragma aborts_if_is_partial; + include SourceExistsAbortsIf; + /// [high-level-req-5.2] + aborts_if !contains(global(source).sentinels, address_of(sentinel)); + aborts_if len(subjects) > MAX_BATCH; + } + + spec undeny(remover: &signer, source: address, subject: address) { + include SourceExistsAbortsIf; + /// [high-level-req-5.3] + aborts_if !contains(global(source).removers, address_of(remover)); + ensures !table::spec_contains(global(source).denied, subject); + ensures global(source).subjects == old(global(source).subjects); + } + + // =============================== Roots =============================== + + spec publish_root(issuer: &signer, source: address, digest: vector, leaf_count: u64) { + pragma aborts_if_is_partial; + aborts_if len(digest) != DIGEST_LENGTH; + include SourceExistsAbortsIf; + aborts_if global(source).paused; + ensures global(source).root_epoch == old(global(source).root_epoch) + 1; + ensures global(source) == old(global(source)); + } +} diff --git a/aptos-move/framework/aptos-framework/sources/attestation_authorization.move b/aptos-move/framework/aptos-framework/sources/attestation_authorization.move new file mode 100644 index 00000000000..1b1de567045 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation_authorization.move @@ -0,0 +1,332 @@ +/// Per-action authorization: a short-lived signed capability that satisfies one step-up decision +/// for one action. +/// +/// The point is not freshness, although a sixty-second capability does make revocation trivial, +/// since revoking means declining to issue the next one and no denylist, epoch or status list is +/// involved on this path. The point is the AUDIT RECORD. A view call leaves no trace and a +/// transaction-prologue rejection is a discard with no ledger record at all, so a design built only +/// on persistent facts cannot answer "prove this address was authorized at the moment this exact +/// transaction executed, and show the basis". An authorization is a transaction argument, so it is +/// in the ledger permanently, naming the policy, the action and the moment. +/// +/// The symmetric cost, stated because it is a disclosure decision rather than an oversight: +/// everything an authorization names is public forever. That is why the amount is committed as a +/// BUCKET rather than a value, so the authorizer commits to a ceiling instead of publishing a +/// customer's exact transaction size. +/// +/// Replay protection is a nonce table. The clever alternative is to bind the capability to the +/// account's sequence number, which costs no storage because the number advances when the +/// transaction lands, but orderless transactions carry a `Nonce` replay protector instead and +/// leave the sequence number untouched, so a capability bound to it would stay reusable there. +/// Orderless transactions are in this fork's genesis default features, so the clever version is a +/// footgun and this module does not use it. +/// +/// Batched pre-authorization needs no separate mechanism: a batch is N authorizations with distinct +/// nonces over the same window, which drops the liveness dependency on the authorizer from +/// per-transaction to per-window. This note exists so nobody builds a second code path for it. +module aptos_framework::attestation_authorization { + use std::bcs::to_bytes; + use std::chain_id; + use std::ed25519; + use std::error; + use std::event::emit; + use std::table::{Self, Table}; + use std::timestamp::now_seconds; + + friend aptos_framework::attestation_policy; + + /// Domain separator for the signed message, so an authorization cannot be reinterpreted as any + /// other signed payload. + const DOMAIN_AUTHORIZE: vector = b"aptos_framework::attestation_authorization::AUTH"; + + /// Largest bucket exponent. Bucket b covers amounts up to 10^b, saturating at u64 max. + const MAX_BUCKET: u8 = 20; + /// Required length of a nonce. + const NONCE_LENGTH: u64 = 32; + /// Required length of an ed25519 public key. + const PUBKEY_LENGTH: u64 = 32; + /// Required length of an ed25519 signature. + const SIGNATURE_LENGTH: u64 = 64; + /// Largest number of nonces one prune call may release. + const MAX_PRUNE: u64 = 1000; + + /// This policy has no nonce store, so it was not created by `attestation_policy`. + const ENOT_INITIALIZED: u64 = 1; + /// The authorizer signature did not verify. + const EBAD_SIGNATURE: u64 = 2; + /// The authorization has expired. + const EEXPIRED: u64 = 3; + /// This nonce has already been consumed. + const ENONCE_USED: u64 = 4; + /// The amount exceeds the ceiling the authorizer committed to. + const EAMOUNT_OVER_BUCKET: u64 = 5; + /// The authorization's validity window is longer than the policy permits. + const ETTL_TOO_LONG: u64 = 6; + /// The policy has no authorizer key configured. + const ENO_AUTHORIZER: u64 = 7; + /// A nonce must be exactly 32 bytes. + const EBAD_NONCE_LENGTH: u64 = 8; + /// A signature must be exactly 64 bytes. + const EBAD_SIGNATURE_LENGTH: u64 = 9; + /// The bucket exponent exceeds MAX_BUCKET. + const EBAD_BUCKET: u64 = 10; + /// The authorization was issued in the future. + const ENOT_YET_VALID: u64 = 11; + /// The batch exceeds MAX_PRUNE. + const EBATCH_TOO_LARGE: u64 = 12; + + /// Consumed nonces, stored at the policy's address. The value is the expiry, retained so an + /// entry can be pruned once it can no longer be replayed. + struct Nonces has key { + used: Table, u64> + } + + #[event] + struct ConsumeAuthorization has drop, store { + policy: address, + subject: address, + action: u8, + amount_bucket: u8, + nonce: vector, + issued_at_secs: u64, + expires_at_secs: u64 + } + + #[event] + struct PruneNonces has drop, store { + policy: address, + released: u64 + } + + // =============================== Views =============================== + + #[view] + public fun is_initialized(policy: address): bool { + exists(policy) + } + + #[view] + public fun is_nonce_used(policy: address, nonce: vector): bool acquires Nonces { + exists(policy) && table::contains(&Nonces[policy].used, nonce) + } + + #[view] + /// Ceiling a bucket exponent commits to: 10^bucket, saturating at u64 max. + public fun bucket_ceiling(bucket: u8): u64 { + assert!(bucket <= MAX_BUCKET, error::invalid_argument(EBAD_BUCKET)); + let ceiling = 1u64; + let step = 0; + while (step < bucket) { + // 10^20 overflows u64, so saturate rather than abort. + if (ceiling > 1844674407370955161) { + return 18446744073709551615 + }; + ceiling *= 10; + step += 1; + }; + ceiling + } + + #[view] + /// The message an authorizer signs. Published so an authorizing service can be implemented in + /// any language without reading this module. + public fun authorization_message( + policy: address, + subject: address, + action: u8, + amount_bucket: u8, + nonce: vector, + issued_at_secs: u64, + expires_at_secs: u64 + ): vector { + let message = vector[]; + message.append(DOMAIN_AUTHORIZE); + message.append(to_bytes(&chain_id::get())); + message.append(to_bytes(&policy)); + message.append(to_bytes(&subject)); + message.append(to_bytes(&action)); + message.append(to_bytes(&amount_bucket)); + message.append(to_bytes(&nonce)); + message.append(to_bytes(&issued_at_secs)); + message.append(to_bytes(&expires_at_secs)); + message + } + + // =============================== Lifecycle =============================== + + /// Create the nonce store. Called by `attestation_policy::create` with the policy's own + /// resource-account signer, which is why this needs no permission check of its own. + public(friend) fun initialize(policy_account: &signer) { + move_to(policy_account, Nonces { used: table::new, u64>() }); + } + + /// Verify an authorization and consume its nonce. + /// + /// Every field is checked against the call it is being used for; none is advisory. The policy + /// supplies the authorizer key and the longest window it will accept, so a compromised + /// authorizer cannot mint a long-lived capability by setting a distant expiry. + /// + /// @param policy The policy the authorization was issued for. + /// @param authorizer_pubkey 32-byte ed25519 key the policy trusts. + /// @param max_ttl_secs Longest validity window the policy accepts. + /// @param subject The subject taking the action. + /// @param action The action being taken. + /// @param amount The actual amount, which must be within the committed bucket ceiling. + /// @param authorization BCS of (action, amount_bucket, nonce, issued_at, expires_at) followed + /// by the 64-byte signature. + /// @abort If any field disagrees with the call, the window is too long, the signature fails, + /// or the nonce has already been consumed. + public(friend) fun verify_and_consume( + policy: address, + authorizer_pubkey: vector, + max_ttl_secs: u64, + subject: address, + action: u8, + amount: u64, + authorization: vector + ) acquires Nonces { + assert!(exists(policy), error::not_found(ENOT_INITIALIZED)); + assert!( + authorizer_pubkey.length() == PUBKEY_LENGTH, + error::invalid_state(ENO_AUTHORIZER) + ); + + let (signed_action, amount_bucket, nonce, issued_at_secs, expires_at_secs, signature) = + decode(authorization); + + assert!(signed_action == action, error::invalid_argument(EBAD_SIGNATURE)); + assert!( + amount <= bucket_ceiling(amount_bucket), + error::invalid_argument(EAMOUNT_OVER_BUCKET) + ); + + let now = now_seconds(); + assert!(issued_at_secs <= now, error::invalid_argument(ENOT_YET_VALID)); + assert!(now < expires_at_secs, error::invalid_state(EEXPIRED)); + assert!( + expires_at_secs - issued_at_secs <= max_ttl_secs, + error::invalid_argument(ETTL_TOO_LONG) + ); + + let message = + authorization_message( + policy, + subject, + action, + amount_bucket, + nonce, + issued_at_secs, + expires_at_secs + ); + assert!( + ed25519::signature_verify_strict( + &ed25519::new_signature_from_bytes(signature), + &ed25519::new_unvalidated_public_key_from_bytes(authorizer_pubkey), + message + ), + error::invalid_argument(EBAD_SIGNATURE) + ); + + let used = &mut Nonces[policy].used; + assert!(!table::contains(used, nonce), error::invalid_state(ENONCE_USED)); + table::add(used, nonce, expires_at_secs); + + emit( + ConsumeAuthorization { + policy, + subject, + action, + amount_bucket, + nonce, + issued_at_secs, + expires_at_secs + } + ); + } + + /// Release the storage held by nonces that can no longer be replayed. Permissionless, because + /// it is pure cleanup and nobody has a reason to withhold it other than the fee, which is the + /// caller's to pay. + public entry fun prune_nonces( + _anyone: &signer, policy: address, nonces: vector> + ) acquires Nonces { + assert!(exists(policy), error::not_found(ENOT_INITIALIZED)); + assert!(nonces.length() <= MAX_PRUNE, error::invalid_argument(EBATCH_TOO_LARGE)); + let now = now_seconds(); + let used = &mut Nonces[policy].used; + let released = 0; + nonces.for_each(|nonce| { + if (table::contains(used, nonce) && *table::borrow(used, nonce) <= now) { + table::remove(used, nonce); + released += 1; + }; + }); + emit(PruneNonces { policy, released }); + } + + // =============================== Helpers =============================== + + /// Split an authorization blob into its fields and the signature. The layout is fixed width up + /// to the signature so it can be parsed without a length prefix: + /// action (1) || bucket (1) || nonce (32) || issued_at (8) || expires_at (8) || signature (64). + /// Integers are little-endian, matching BCS. + fun decode(authorization: vector): (u8, u8, vector, u64, u64, vector) { + let expected = 1 + 1 + NONCE_LENGTH + 8 + 8 + SIGNATURE_LENGTH; + assert!( + authorization.length() == expected, + error::invalid_argument(EBAD_SIGNATURE_LENGTH) + ); + let action = authorization[0]; + let amount_bucket = authorization[1]; + + let nonce = vector[]; + let index = 2; + while (index < 2 + NONCE_LENGTH) { + nonce.push_back(authorization[index]); + index += 1; + }; + assert!(nonce.length() == NONCE_LENGTH, error::invalid_argument(EBAD_NONCE_LENGTH)); + + let issued_at_secs = read_u64_le(&authorization, 2 + NONCE_LENGTH); + let expires_at_secs = read_u64_le(&authorization, 2 + NONCE_LENGTH + 8); + + let signature = vector[]; + index = 2 + NONCE_LENGTH + 16; + while (index < expected) { + signature.push_back(authorization[index]); + index += 1; + }; + + (action, amount_bucket, nonce, issued_at_secs, expires_at_secs, signature) + } + + fun read_u64_le(bytes: &vector, offset: u64): u64 { + let value = 0u64; + let index = 0; + while (index < 8) { + value += (bytes[offset + index] as u64) << ((index * 8) as u8); + index += 1; + }; + value + } + + #[test_only] + /// Build an authorization blob the way an authorizing service would. + public fun encode_for_test( + action: u8, + amount_bucket: u8, + nonce: vector, + issued_at_secs: u64, + expires_at_secs: u64, + signature: vector + ): vector { + let blob = vector[]; + blob.push_back(action); + blob.push_back(amount_bucket); + blob.append(nonce); + blob.append(to_bytes(&issued_at_secs)); + blob.append(to_bytes(&expires_at_secs)); + blob.append(signature); + blob + } +} diff --git a/aptos-move/framework/aptos-framework/sources/attestation_authorization.spec.move b/aptos-move/framework/aptos-framework/sources/attestation_authorization.spec.move new file mode 100644 index 00000000000..6d5307a8d56 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation_authorization.spec.move @@ -0,0 +1,102 @@ +spec aptos_framework::attestation_authorization { + /// + /// No.: 1 + /// Requirement: An authorization is usable at most once, and only inside its validity window (INV-8). + /// Criticality: Critical + /// Implementation: verify_and_consume aborts if the nonce is already in Nonces.used, if now is before issued_at or + /// at or after expires_at, and otherwise inserts the nonce before returning. + /// Enforcement: Formally verified via [high-level-req-1](verify_and_consume) that every successful call inserts a + /// nonce that was not present before. The window checks are audited in unit tests, because the fields are decoded + /// by loops the prover havocs. + /// + /// No.: 2 + /// Requirement: Only a nonce that can no longer be replayed is ever pruned. + /// Criticality: High + /// Implementation: prune_nonces removes an entry only when its stored expiry is at or before now; replaying an + /// authorization with that nonce would fail the expiry check anyway. + /// Enforcement: Audited in unit tests (test_prune_expired_nonces). The removal loop is havocked by the prover. + /// + /// No.: 3 + /// Requirement: The nonce store can only be created by attestation_policy, with the policy's own signer, exactly + /// once per policy. + /// Criticality: High + /// Implementation: initialize and verify_and_consume are public(friend) with attestation_policy as the only friend. + /// Enforcement: Enforced by the friend declaration, which the compiler checks. Formally verified via + /// [high-level-req-3](initialize) that a second initialization aborts. + /// + /// No.: 4 + /// Requirement: An authorization requires a configured 32-byte authorizer key and a blob of the exact fixed + /// layout. + /// Criticality: Medium + /// Implementation: verify_and_consume asserts the key length and decode asserts the blob length. + /// Enforcement: Formally verified via [high-level-req-4](verify_and_consume). + /// + spec module { + pragma verify = true; + pragma aborts_if_is_strict = false; + } + + spec is_initialized(policy: address): bool { + aborts_if false; + ensures result == exists(policy); + } + + spec is_nonce_used(policy: address, nonce: vector): bool { + aborts_if false; + ensures result == (exists(policy) + && table::spec_contains(global(policy).used, nonce)); + } + + spec bucket_ceiling(bucket: u8): u64 { + pragma aborts_if_is_partial; + aborts_if bucket > MAX_BUCKET; + } + + spec authorization_message( + policy: address, + subject: address, + action: u8, + amount_bucket: u8, + nonce: vector, + issued_at_secs: u64, + expires_at_secs: u64 + ): vector { + aborts_if !exists(@aptos_framework); + } + + spec initialize(policy_account: &signer) { + /// [high-level-req-3] + aborts_if exists(std::signer::address_of(policy_account)); + ensures exists(std::signer::address_of(policy_account)); + } + + spec verify_and_consume( + policy: address, + authorizer_pubkey: vector, + max_ttl_secs: u64, + subject: address, + action: u8, + amount: u64, + authorization: vector + ) { + // The blob is decoded by while loops that the prover havocs, so the decoded fields, and + // therefore the expiry, TTL and signature checks, cannot be named here. Those are + // covered by unit tests. + pragma aborts_if_is_partial; + aborts_if !exists(policy); + /// [high-level-req-4] + aborts_if len(authorizer_pubkey) != PUBKEY_LENGTH; + aborts_if len(authorization) != 2 + NONCE_LENGTH + 16 + SIGNATURE_LENGTH; + let used = global(policy).used; + let post post_used = global(policy).used; + /// [high-level-req-1] + ensures exists nonce: vector: + !table::spec_contains(used, nonce) && table::spec_contains(post_used, nonce); + } + + spec prune_nonces(_anyone: &signer, policy: address, nonces: vector>) { + pragma aborts_if_is_partial; + aborts_if !exists(policy); + aborts_if len(nonces) > MAX_PRUNE; + } +} diff --git a/aptos-move/framework/aptos-framework/sources/attestation_policy.move b/aptos-move/framework/aptos-framework/sources/attestation_policy.move new file mode 100644 index 00000000000..f96123ff27b --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation_policy.move @@ -0,0 +1,1926 @@ +/// A business's rules over attestation sources, without deploying anything. +/// +/// A policy is a resource account, created the same way `aptos_framework::attestation` creates a +/// source and `aptos_framework::timelock` creates a timelock account: the deployer authorizes +/// creation and pays gas but gains no role unless listed. A policy names the sources it trusts, +/// how to combine them, predicates over their attributes, and a per-action amount above which a +/// fresh authorization is demanded. +/// +/// The consequence is the reason this module exists rather than a single registry. A new business +/// does not onboard subjects: it creates a policy pointing at sources that already have them, so +/// its marginal onboarding cost is zero, and the network effect sits on the source side. +/// +/// Evaluation returns three values, not two. Two-valued authorization forces one global threshold +/// on every consumer. The third value lets a policy be permissive for ordinary activity and strict +/// where the business actually cares, and it is what joins persistent facts and per-action +/// authorization into one product instead of two. +/// +/// Properties: +/// - An empty body denies everything. A policy that allows nothing is obvious in testing; one that +/// allows everything is not. +/// - Denial is evaluated before anything positive, and no source can override another's denial. +/// - Rule changes are staged with an activation time, so a change never breaks a transaction that +/// is already in flight, and anyone may push a staged body live once its time arrives. +/// - A paused policy denies with its own reason rather than silently allowing. +/// - Source lists are bounded, because unbounded iteration over rules is a denial-of-service +/// vector. `ERC-3643` caps its module list at 25 for the same reason. +/// +/// Delayed governance comes by composition: an admin may be a `aptos_framework::timelock` account, +/// in which case every rule change inherits that module's delay and cancel semantics. +module aptos_framework::attestation_policy { + use std::account::{Self, SignerCapability, create_resource_address}; + use std::bcs::to_bytes; + use std::error; + use std::event::emit; + use std::option::{Self, Option}; + use std::signer::address_of; + use std::table::{Self, Table}; + use std::timestamp::now_seconds; + use aptos_framework::attestation; + use aptos_framework::attestation_authorization; + + /// Domain separator used when deriving the resource account seed, to avoid collisions with + /// other modules that create resource accounts. + const DOMAIN_SEPARATOR: vector = b"aptos_framework::attestation_policy"; + + /// The subject may take the action. + const DECISION_ALLOW: u8 = 0; + /// The subject may not take the action. + const DECISION_DENY: u8 = 1; + /// The subject may take the action only with a fresh authorization attached. + const DECISION_STEP_UP: u8 = 2; + + /// Allowed. + const REASON_OK: u16 = 0; + /// Excluded by the chain-wide denial source. + const REASON_CHAIN_DENIED: u16 = 1; + /// Excluded by one of the policy's denial sources. + const REASON_SOURCE_DENIED: u16 = 2; + /// A source the policy requires does not vouch for the subject. + const REASON_MISSING_REQUIRED: u16 = 3; + /// No source among the alternatives vouches for the subject. + const REASON_NO_QUALIFYING: u16 = 4; + /// A source vouches for the subject but below the level the policy requires. + const REASON_LEVEL_TOO_LOW: u16 = 5; + /// An attribute predicate failed. + const REASON_ATTR_FAILED: u16 = 6; + /// The amount is above the policy's step-up threshold for this action. + const REASON_AMOUNT_THRESHOLD: u16 = 7; + /// The policy is paused. + const REASON_POLICY_PAUSED: u16 = 8; + /// The policy has no rules, so it allows nothing. + const REASON_EMPTY_BODY: u16 = 9; + + /// The attribute value must be one of the listed values. + const OP_IN: u8 = 0; + /// The attribute value must not be any of the listed values. + const OP_NOT_IN: u8 = 1; + /// The attribute value must equal the single listed value. + const OP_EQ: u8 = 2; + /// The attribute value must be greater than or equal to the single listed value, compared as a + /// big-endian unsigned integer of the same length. + const OP_GTE: u8 = 3; + + /// Transfer value out of the subject's control. + const ACTION_TRANSFER: u8 = 1; + /// Receive value. + const ACTION_RECEIVE: u8 = 2; + /// Mint. + const ACTION_MINT: u8 = 3; + /// Redeem or burn. + const ACTION_REDEEM: u8 = 4; + /// Borrow. + const ACTION_BORROW: u8 = 5; + /// Vote. + const ACTION_VOTE: u8 = 6; + // Action ids 64 and above are application-defined. + + /// Largest number of sources any one list in a body may name. + const MAX_SOURCES: u64 = 16; + /// Largest number of attribute predicates a body may carry. + const MAX_RULES: u64 = 16; + + /// Published version of this module's interface. + const VERSION: u64 = 1; + + /// Specified account is not a policy. + const EACCOUNT_NOT_POLICY: u64 = 1; + /// The caller is not an admin. + const ENOT_ADMIN: u64 = 2; + /// The caller is not a guardian. + const ENOT_GUARDIAN: u64 = 3; + /// A role list cannot contain duplicate addresses. + const EDUPLICATE_MEMBER: u64 = 4; + /// The policy account itself cannot hold a role. + const ESELF_CANNOT_BE_MEMBER: u64 = 5; + /// A policy must have at least one admin. + const ENOT_ENOUGH_ADMINS: u64 = 6; + /// Removing these admins would leave the policy with zero admins. + const EWOULD_REMOVE_ALL_ADMINS: u64 = 7; + /// A list names more sources than MAX_SOURCES. + const ETOO_MANY_SOURCES: u64 = 8; + /// A body carries more predicates than MAX_RULES. + const ETOO_MANY_RULES: u64 = 9; + /// Argument vectors have differing lengths. + const ELENGTH_MISMATCH: u64 = 10; + /// No staged body is waiting. + const ENO_PENDING: u64 = 11; + /// The staged body's activation time has not arrived. + const ENOT_EFFECTIVE: u64 = 12; + /// The subject may not take this action. + const EDENIED: u64 = 13; + /// The action needs a fresh authorization, which was not supplied. + const ESTEP_UP_REQUIRED: u64 = 14; + /// A named source does not exist. + const EUNKNOWN_SOURCE: u64 = 15; + /// An attribute predicate is malformed. + const EBAD_RULE: u64 = 16; + /// The authorizer key must be 32 bytes or empty. + const EBAD_AUTHORIZER: u64 = 17; + /// At most one chain-wide denial source may be named. + const ETOO_MANY_CHAIN_DENY: u64 = 18; + + /// One source the policy consults, and the minimum level it must vouch at. + struct SourceRef has copy, drop, store { + source: address, + min_level: u8 + } + + /// A predicate over one attribute of one source. + struct AttrRule has copy, drop, store { + source: address, + key: u16, + op: u8, + values: vector> + } + + /// The rules themselves, swapped atomically when a staged body activates. + struct Body has copy, drop, store { + // At least one of these must vouch at or above its min_level. + require_any: vector, + // All of these must vouch at or above their min_level. + require_all: vector, + // A denial in any of these denies, unconditionally. + deny_any: vector
, + attr_rules: vector, + // Optional chain-wide denial source, consulted before anything else. + chain_deny: Option
+ } + + /// A body waiting for its activation time. + struct Staged has copy, drop, store { + body: Body, + effective_at_secs: u64 + } + + /// Stored at the policy's resource account address. + struct Policy has key { + // Addresses allowed to stage rules and grant roles. Must have at least 1. + admins: vector
, + // Addresses allowed to pause and unpause evaluation. + guardians: vector
, + // Denies with REASON_POLICY_PAUSED rather than allowing. See the module doc. + paused: bool, + body: Body, + pending: Option, + // action to the amount above which the decision becomes step-up. Absent means never, so + // nobody enables a liveness dependency by accident. + step_up_above: Table, + // Key that signs authorizations for this policy, and the longest window it may issue. The + // policy owns both, so a compromised authorizer cannot mint a long-lived capability by + // setting a distant expiry. + authorizer_pubkey: vector, + authorizer_max_ttl_secs: u64, + // Signer capability for the resource account, retained so future resources can be added. + signer_cap: SignerCapability + } + + #[event] + struct CreatePolicy has drop, store { + policy: address, + deployer: address, + admins: vector
+ } + + #[event] + struct AddMembers has drop, store { + policy: address, + role: u8, + members: vector
+ } + + #[event] + struct RemoveMembers has drop, store { + policy: address, + role: u8, + members: vector
+ } + + #[event] + struct StageBody has drop, store { + policy: address, + effective_at_secs: u64 + } + + #[event] + struct ActivateBody has drop, store { + policy: address, + at_secs: u64 + } + + #[event] + struct CancelPending has drop, store { + policy: address + } + + #[event] + struct SetStepUp has drop, store { + policy: address, + action: u8, + threshold: u64 + } + + #[event] + struct SetAuthorizer has drop, store { + policy: address, + max_ttl_secs: u64 + } + + #[event] + struct SetPaused has drop, store { + policy: address, + paused: bool + } + + const ROLE_ADMIN: u8 = 0; + const ROLE_GUARDIAN: u8 = 1; + + // =============================== Views =============================== + + #[view] + /// Return the predicted address for the next policy deployed by the given account. + public fun get_next_policy_address(deployer: address): address { + let owner_nonce = account::get_sequence_number(deployer); + create_resource_address(&deployer, create_policy_seed(to_bytes(&owner_nonce))) + } + + #[view] + /// Evaluate the policy for a subject, action and amount, returning a decision and a reason. + /// + /// The order below is the load-bearing part of this module. Denial comes before anything + /// positive and cannot be outvoted by a source that vouches. + public fun evaluate( + policy: address, subject: address, action: u8, amount: u64 + ): (u8, u16) acquires Policy { + assert_policy_exists(policy); + let config = &Policy[policy]; + + if (config.paused) { + return (DECISION_DENY, REASON_POLICY_PAUSED) + }; + + let body = &config.body; + + // A policy with no positive rule allows nothing. Stated explicitly so an unconfigured + // policy is a loud failure rather than an open door. + if (body.require_any.is_empty() && body.require_all.is_empty()) { + return (DECISION_DENY, REASON_EMPTY_BODY) + }; + + // 1. chain-wide denial + if (option::is_some(&body.chain_deny)) { + let chain_source = *option::borrow(&body.chain_deny); + if (attestation::is_denied(chain_source, subject)) { + return (DECISION_DENY, REASON_CHAIN_DENIED) + }; + }; + + // 2. per-source denials + let denied = false; + body.deny_any.for_each_ref(|source| { + if (!denied && attestation::is_denied(*source, subject)) { + denied = true; + }; + }); + if (denied) { + return (DECISION_DENY, REASON_SOURCE_DENIED) + }; + + // 3. every required source must vouch, at or above its level + let missing = false; + let too_low = false; + body.require_all.for_each_ref(|entry| { + if (!missing && !too_low) { + let (active, level) = attestation::active_with_level(entry.source, subject); + if (!active) { + missing = true; + } else if (level < entry.min_level) { + too_low = true; + }; + }; + }); + if (missing) { + return (DECISION_DENY, REASON_MISSING_REQUIRED) + }; + if (too_low) { + return (DECISION_DENY, REASON_LEVEL_TOO_LOW) + }; + + // 4. at least one alternative must vouch, when any are configured + if (!body.require_any.is_empty()) { + let qualified = false; + body.require_any.for_each_ref(|entry| { + if (!qualified) { + let (active, level) = attestation::active_with_level(entry.source, subject); + if (active && level >= entry.min_level) { + qualified = true; + }; + }; + }); + if (!qualified) { + return (DECISION_DENY, REASON_NO_QUALIFYING) + }; + }; + + // 5. attribute predicates + let failed = false; + body.attr_rules.for_each_ref(|rule| { + if (!failed && !eval_attr_rule(rule, subject)) { + failed = true; + }; + }); + if (failed) { + return (DECISION_DENY, REASON_ATTR_FAILED) + }; + + // 6. step-up threshold for this action + if (table::contains(&config.step_up_above, action) + && amount > *table::borrow(&config.step_up_above, action)) { + return (DECISION_STEP_UP, REASON_AMOUNT_THRESHOLD) + }; + + (DECISION_ALLOW, REASON_OK) + } + + #[view] + /// Whether the subject may take the action outright, with no authorization needed. + public fun is_allowed( + policy: address, subject: address, action: u8, amount: u64 + ): bool acquires Policy { + let (decision, _) = evaluate(policy, subject, action, amount); + decision == DECISION_ALLOW + } + + #[view] + /// Decision only, for a caller that does not need the reason. + public fun decision_of( + policy: address, subject: address, action: u8, amount: u64 + ): u8 acquires Policy { + let (decision, _) = evaluate(policy, subject, action, amount); + decision + } + + #[view] + /// Reason only, for a wallet that wants to explain a refusal rather than show a revert. + public fun reason_of( + policy: address, subject: address, action: u8, amount: u64 + ): u16 acquires Policy { + let (_, reason) = evaluate(policy, subject, action, amount); + reason + } + + #[view] + /// Dry-run the policy against a population, returning one decision per subject in order. + /// A business uses this to see what a staged rule change will do before its time arrives. + public fun simulate( + policy: address, subjects: vector
, action: u8, amount: u64 + ): vector acquires Policy { + let decisions = vector[]; + subjects.for_each(|subject| { + let (decision, _) = evaluate(policy, subject, action, amount); + decisions.push_back(decision); + }); + decisions + } + + #[view] + /// Counts of allow, deny and step-up over a population, in that order. + public fun simulate_counts( + policy: address, subjects: vector
, action: u8, amount: u64 + ): vector acquires Policy { + let counts = vector[0, 0, 0]; + simulate(policy, subjects, action, amount).for_each(|decision| { + let slot = counts.borrow_mut((decision as u64)); + *slot += 1; + }); + counts + } + + #[view] + public fun admins(policy: address): vector
acquires Policy { + assert_policy_exists(policy); + Policy[policy].admins + } + + #[view] + public fun guardians(policy: address): vector
acquires Policy { + assert_policy_exists(policy); + Policy[policy].guardians + } + + #[view] + public fun is_admin(addr: address, policy: address): bool acquires Policy { + assert_policy_exists(policy); + Policy[policy].admins.contains(&addr) + } + + #[view] + public fun is_paused(policy: address): bool acquires Policy { + assert_policy_exists(policy); + Policy[policy].paused + } + + #[view] + /// Sources named in `require_any`, with their minimum levels alongside in `require_any_levels`. + public fun require_any_sources(policy: address): vector
acquires Policy { + assert_policy_exists(policy); + let sources = vector[]; + Policy[policy].body.require_any.for_each_ref(|entry| { + sources.push_back(entry.source); + }); + sources + } + + #[view] + public fun require_all_sources(policy: address): vector
acquires Policy { + assert_policy_exists(policy); + let sources = vector[]; + Policy[policy].body.require_all.for_each_ref(|entry| { + sources.push_back(entry.source); + }); + sources + } + + #[view] + public fun deny_any_sources(policy: address): vector
acquires Policy { + assert_policy_exists(policy); + Policy[policy].body.deny_any + } + + #[view] + /// Amount above which this action needs an authorization, or 0 when it never does. + public fun step_up_for(policy: address, action: u8): u64 acquires Policy { + assert_policy_exists(policy); + let thresholds = &Policy[policy].step_up_above; + if (table::contains(thresholds, action)) { + *table::borrow(thresholds, action) + } else { 0 } + } + + #[view] + public fun has_pending(policy: address): bool acquires Policy { + assert_policy_exists(policy); + option::is_some(&Policy[policy].pending) + } + + #[view] + /// When the staged body becomes active, or 0 when nothing is staged. + public fun pending_effective_at(policy: address): u64 acquires Policy { + assert_policy_exists(policy); + let pending = &Policy[policy].pending; + if (option::is_some(pending)) { + option::borrow(pending).effective_at_secs + } else { 0 } + } + + #[view] + /// The key that signs authorizations for this policy, or empty when step-up is disabled. + public fun authorizer_pubkey(policy: address): vector acquires Policy { + assert_policy_exists(policy); + Policy[policy].authorizer_pubkey + } + + #[view] + public fun authorizer_max_ttl_secs(policy: address): u64 acquires Policy { + assert_policy_exists(policy); + Policy[policy].authorizer_max_ttl_secs + } + + #[view] + public fun standard_version(): u64 { + VERSION + } + + // =============================== Policy creation =============================== + + /// Create a new policy. The deployer only authorizes resource-account creation and pays gas; + /// it gains no role unless listed. The body starts empty, which denies everything until rules + /// are staged and activated. + /// + /// @param deployer Signer that authorizes resource-account creation and pays gas. + /// @param admins Addresses allowed to stage rules. At least one, no duplicates. + /// @param guardians Addresses allowed to pause evaluation. May be empty. + /// @abort If a list has duplicates, names the policy itself, or there is no admin. + public entry fun create( + deployer: &signer, admins: vector
, guardians: vector
+ ) { + let (policy_signer, policy_signer_cap) = create_policy_account(deployer); + let policy_address = address_of(&policy_signer); + assert!(admins.length() >= 1, error::invalid_argument(ENOT_ENOUGH_ADMINS)); + validate_members(&admins, policy_address); + validate_members(&guardians, policy_address); + + move_to( + &policy_signer, + Policy { + admins, + guardians, + paused: false, + body: empty_body(), + pending: option::none(), + step_up_above: table::new(), + authorizer_pubkey: vector[], + authorizer_max_ttl_secs: 0, + signer_cap: policy_signer_cap + } + ); + attestation_authorization::initialize(&policy_signer); + emit( + CreatePolicy { + policy: policy_address, + deployer: address_of(deployer), + admins + } + ); + } + + // =============================== Rules =============================== + + /// Stage a new body, to take effect at `effective_at_secs`. Staging rather than applying + /// immediately is what keeps a rule change from breaking a transaction already in flight. + /// + /// @param admin An admin of the policy. + /// @param policy The policy address. + /// @param require_any_sources Sources of which at least one must vouch. May be empty. + /// @param require_any_levels Minimum level per entry, same length as require_any_sources. + /// @param require_all_sources Sources that must all vouch. May be empty. + /// @param require_all_levels Minimum level per entry, same length as require_all_sources. + /// @param deny_any Sources whose denial denies. May be empty. + /// @param chain_deny Optional chain-wide denial source: empty for none, or exactly one address. + /// A vector rather than an `Option` because entry functions cannot take `Option` arguments. + /// @param effective_at_secs When the body becomes active. + /// @abort If the lengths differ, a list is over MAX_SOURCES, chain_deny names more than one + /// source, or a named source does not exist. + public entry fun stage_body( + admin: &signer, + policy: address, + require_any_sources: vector
, + require_any_levels: vector, + require_all_sources: vector
, + require_all_levels: vector, + deny_any: vector
, + chain_deny: vector
, + effective_at_secs: u64 + ) acquires Policy { + assert_admin(policy, address_of(admin)); + assert!(chain_deny.length() <= 1, error::invalid_argument(ETOO_MANY_CHAIN_DENY)); + let chain_deny = + if (chain_deny.is_empty()) option::none() + else option::some(chain_deny[0]); + let body = + build_body( + require_any_sources, + require_any_levels, + require_all_sources, + require_all_levels, + deny_any, + chain_deny + ); + // Keep any predicates already staged, so the two staging calls compose in either order. + let config = &mut Policy[policy]; + if (option::is_some(&config.pending)) { + body.attr_rules = option::borrow(&config.pending).body.attr_rules; + }; + config.pending = option::some(Staged { body, effective_at_secs }); + emit(StageBody { policy, effective_at_secs }); + } + + /// Stage attribute predicates onto the pending body. Call `stage_body` first. + /// + /// @param sources Source whose attribute each predicate reads. + /// @param keys Attribute key per predicate. + /// @param ops One of OP_IN, OP_NOT_IN, OP_EQ, OP_GTE. + /// @param values Candidate values per predicate. OP_EQ and OP_GTE take exactly one. + public entry fun stage_attr_rules( + admin: &signer, + policy: address, + sources: vector
, + keys: vector, + ops: vector, + values: vector>> + ) acquires Policy { + assert_admin(policy, address_of(admin)); + let count = sources.length(); + assert!(count <= MAX_RULES, error::invalid_argument(ETOO_MANY_RULES)); + assert!( + count == keys.length() && count == ops.length() && count == values.length(), + error::invalid_argument(ELENGTH_MISMATCH) + ); + + let rules = vector[]; + let index = 0; + while (index < count) { + let op = ops[index]; + assert!(op <= OP_GTE, error::invalid_argument(EBAD_RULE)); + let candidates = values[index]; + // A comparison against a set is meaningless with no members, and OP_EQ and OP_GTE + // compare against exactly one. + assert!(!candidates.is_empty(), error::invalid_argument(EBAD_RULE)); + if (op == OP_EQ || op == OP_GTE) { + assert!(candidates.length() == 1, error::invalid_argument(EBAD_RULE)); + }; + assert!( + attestation::is_source(sources[index]), + error::not_found(EUNKNOWN_SOURCE) + ); + rules.push_back( + AttrRule { source: sources[index], key: keys[index], op, values: candidates } + ); + index += 1; + }; + + let config = &mut Policy[policy]; + let staged = + if (option::is_some(&config.pending)) { + option::extract(&mut config.pending) + } else { + Staged { body: config.body, effective_at_secs: now_seconds() } + }; + staged.body.attr_rules = rules; + let effective_at_secs = staged.effective_at_secs; + config.pending = option::some(staged); + emit(StageBody { policy, effective_at_secs }); + } + + /// Push the staged body live. Permissionless once its time has arrived, so the business does + /// not have to be online at the moment its own rule change takes effect. + public entry fun activate_pending(_anyone: &signer, policy: address) acquires Policy { + assert_policy_exists(policy); + let config = &mut Policy[policy]; + assert!(option::is_some(&config.pending), error::invalid_state(ENO_PENDING)); + let staged = option::extract(&mut config.pending); + assert!( + now_seconds() >= staged.effective_at_secs, + error::invalid_state(ENOT_EFFECTIVE) + ); + config.body = staged.body; + emit(ActivateBody { policy, at_secs: now_seconds() }); + } + + /// Discard a staged body that has not activated yet. + public entry fun cancel_pending(admin: &signer, policy: address) acquires Policy { + assert_admin(policy, address_of(admin)); + let config = &mut Policy[policy]; + assert!(option::is_some(&config.pending), error::invalid_state(ENO_PENDING)); + config.pending = option::none(); + emit(CancelPending { policy }); + } + + /// Set the amount above which an action needs a fresh authorization. Absent by default, so a + /// liveness dependency is never enabled by accident. + public entry fun set_step_up( + admin: &signer, policy: address, action: u8, threshold: u64 + ) acquires Policy { + assert_admin(policy, address_of(admin)); + table::upsert(&mut Policy[policy].step_up_above, action, threshold); + emit(SetStepUp { policy, action, threshold }); + } + + /// Set the key that signs authorizations for this policy, and the longest window it may + /// issue. A window of 0 with an empty key disables the step-up path entirely. + public entry fun set_authorizer( + admin: &signer, policy: address, pubkey: vector, max_ttl_secs: u64 + ) acquires Policy { + assert_admin(policy, address_of(admin)); + assert!( + pubkey.is_empty() || pubkey.length() == 32, + error::invalid_argument(EBAD_AUTHORIZER) + ); + let config = &mut Policy[policy]; + config.authorizer_pubkey = pubkey; + config.authorizer_max_ttl_secs = max_ttl_secs; + emit(SetAuthorizer { policy, max_ttl_secs }); + } + + /// Stop demanding authorization for an action. + public entry fun clear_step_up(admin: &signer, policy: address, action: u8) acquires Policy { + assert_admin(policy, address_of(admin)); + let thresholds = &mut Policy[policy].step_up_above; + if (table::contains(thresholds, action)) { + table::remove(thresholds, action); + }; + emit(SetStepUp { policy, action, threshold: 0 }); + } + + // =============================== Roles =============================== + + public entry fun add_admins( + admin: &signer, policy: address, new_admins: vector
+ ) acquires Policy { + assert_admin(policy, address_of(admin)); + let config = &mut Policy[policy]; + add_members(&mut config.admins, &new_admins, policy); + emit(AddMembers { policy, role: ROLE_ADMIN, members: new_admins }); + } + + public entry fun remove_admins( + admin: &signer, policy: address, old_admins: vector
+ ) acquires Policy { + assert_admin(policy, address_of(admin)); + let config = &mut Policy[policy]; + remove_members(&mut config.admins, &old_admins); + assert!( + config.admins.length() >= 1, + error::invalid_state(EWOULD_REMOVE_ALL_ADMINS) + ); + emit(RemoveMembers { policy, role: ROLE_ADMIN, members: old_admins }); + } + + public entry fun add_guardians( + admin: &signer, policy: address, new_guardians: vector
+ ) acquires Policy { + assert_admin(policy, address_of(admin)); + let config = &mut Policy[policy]; + add_members(&mut config.guardians, &new_guardians, policy); + emit(AddMembers { policy, role: ROLE_GUARDIAN, members: new_guardians }); + } + + public entry fun remove_guardians( + admin: &signer, policy: address, old_guardians: vector
+ ) acquires Policy { + assert_admin(policy, address_of(admin)); + let config = &mut Policy[policy]; + remove_members(&mut config.guardians, &old_guardians); + emit(RemoveMembers { policy, role: ROLE_GUARDIAN, members: old_guardians }); + } + + /// Deny everything until unpaused. Denies loudly with REASON_POLICY_PAUSED rather than + /// silently allowing. + public entry fun pause(guardian: &signer, policy: address) acquires Policy { + set_paused(guardian, policy, true); + } + + public entry fun unpause(guardian: &signer, policy: address) acquires Policy { + set_paused(guardian, policy, false); + } + + fun set_paused(guardian: &signer, policy: address, paused: bool) acquires Policy { + assert_policy_exists(policy); + assert!( + Policy[policy].guardians.contains(&address_of(guardian)), + error::permission_denied(ENOT_GUARDIAN) + ); + Policy[policy].paused = paused; + emit(SetPaused { policy, paused }); + } + + // =============================== What a business calls =============================== + + /// The common case, one line inside an entry function. Aborts unless the subject may act + /// outright. + /// + /// @abort EDENIED when the policy refuses, ESTEP_UP_REQUIRED when it wants an authorization. + public fun require( + policy: address, subject: address, action: u8, amount: u64 + ) acquires Policy { + let (decision, _) = evaluate(policy, subject, action, amount); + assert!(decision != DECISION_DENY, error::permission_denied(EDENIED)); + assert!( + decision != DECISION_STEP_UP, + error::permission_denied(ESTEP_UP_REQUIRED) + ); + } + + /// The step-up variant. The authorization is a transaction argument, which is the point: + /// unlike a view call, which leaves no trace, it lands in the ledger permanently and gives the + /// business an independently verifiable record of why it allowed this specific action. + /// + /// An allow decision consumes nothing, so a caller may always route through this function. + public fun require_authorized( + policy: address, + subject: address, + action: u8, + amount: u64, + authorization: vector + ) acquires Policy { + let (decision, _) = evaluate(policy, subject, action, amount); + assert!(decision != DECISION_DENY, error::permission_denied(EDENIED)); + if (decision == DECISION_ALLOW) { + return + }; + let config = &Policy[policy]; + attestation_authorization::verify_and_consume( + policy, + config.authorizer_pubkey, + config.authorizer_max_ttl_secs, + subject, + action, + amount, + authorization + ); + } + + /// Non-aborting form, for a caller that wants to branch rather than fail. + public fun check( + policy: address, subject: address, action: u8, amount: u64 + ): (u8, u16) acquires Policy { + evaluate(policy, subject, action, amount) + } + + // =============================== Helpers =============================== + + fun create_policy_account(deployer: &signer): (signer, SignerCapability) { + let deployer_nonce = account::get_sequence_number(address_of(deployer)); + account::create_resource_account( + deployer, create_policy_seed(to_bytes(&deployer_nonce)) + ) + } + + fun create_policy_seed(seed: vector): vector { + let account_seed = vector[]; + account_seed.append(DOMAIN_SEPARATOR); + account_seed.append(seed); + account_seed + } + + fun empty_body(): Body { + Body { + require_any: vector[], + require_all: vector[], + deny_any: vector[], + attr_rules: vector[], + chain_deny: option::none() + } + } + + fun build_body( + require_any_sources: vector
, + require_any_levels: vector, + require_all_sources: vector
, + require_all_levels: vector, + deny_any: vector
, + chain_deny: Option
+ ): Body { + assert!( + require_any_sources.length() <= MAX_SOURCES + && require_all_sources.length() <= MAX_SOURCES + && deny_any.length() <= MAX_SOURCES, + error::invalid_argument(ETOO_MANY_SOURCES) + ); + assert!( + require_any_sources.length() == require_any_levels.length() + && require_all_sources.length() == require_all_levels.length(), + error::invalid_argument(ELENGTH_MISMATCH) + ); + + // A named source that does not exist would abort at evaluation time, which would brick the + // policy for every subject. Reject it at staging time instead, where it is one caller's + // problem rather than everyone's. + deny_any.for_each_ref(|source| { + assert!(attestation::is_source(*source), error::not_found(EUNKNOWN_SOURCE)); + }); + if (option::is_some(&chain_deny)) { + assert!( + attestation::is_source(*option::borrow(&chain_deny)), + error::not_found(EUNKNOWN_SOURCE) + ); + }; + + Body { + require_any: build_source_refs(require_any_sources, require_any_levels), + require_all: build_source_refs(require_all_sources, require_all_levels), + deny_any, + attr_rules: vector[], + chain_deny + } + } + + fun build_source_refs( + sources: vector
, levels: vector + ): vector { + let refs = vector[]; + let index = 0; + while (index < sources.length()) { + assert!( + attestation::is_source(sources[index]), + error::not_found(EUNKNOWN_SOURCE) + ); + refs.push_back(SourceRef { source: sources[index], min_level: levels[index] }); + index += 1; + }; + refs + } + + fun eval_attr_rule(rule: &AttrRule, subject: address): bool { + let value = attestation::attribute_of(rule.source, subject, rule.key); + // An unset attribute satisfies only OP_NOT_IN: a subject the source says nothing about is + // not in any list, but neither does it meet a positive requirement. + if (value.is_empty()) { + return rule.op == OP_NOT_IN + }; + if (rule.op == OP_IN) { + rule.values.contains(&value) + } else if (rule.op == OP_NOT_IN) { + !rule.values.contains(&value) + } else if (rule.op == OP_EQ) { + value == rule.values[0] + } else { + gte_bytes(&value, &rule.values[0]) + } + } + + /// Big-endian unsigned comparison. Values of differing length are not comparable, so the + /// predicate fails rather than guessing an alignment. + fun gte_bytes(left: &vector, right: &vector): bool { + if (left.length() != right.length()) { + return false + }; + let index = 0; + while (index < left.length()) { + if (left[index] > right[index]) { + return true + }; + if (left[index] < right[index]) { + return false + }; + index += 1; + }; + true + } + + fun validate_members(members: &vector
, policy_address: address) { + let distinct: vector
= vector[]; + members.for_each_ref(|member| { + assert!( + *member != policy_address, + error::invalid_argument(ESELF_CANNOT_BE_MEMBER) + ); + assert!( + !distinct.contains(member), + error::invalid_argument(EDUPLICATE_MEMBER) + ); + distinct.push_back(*member); + }); + } + + fun add_members( + list: &mut vector
, new_members: &vector
, policy_address: address + ) { + validate_members(new_members, policy_address); + new_members.for_each_ref(|member| { + assert!( + !list.contains(member), + error::invalid_argument(EDUPLICATE_MEMBER) + ); + list.push_back(*member); + }); + } + + fun remove_members(list: &mut vector
, old_members: &vector
) { + old_members.for_each_ref(|member| { + let (found, index) = list.index_of(member); + if (found) { + list.remove(index); + }; + }); + } + + fun assert_policy_exists(policy: address) { + assert!(exists(policy), error::not_found(EACCOUNT_NOT_POLICY)); + } + + fun assert_admin(policy: address, addr: address) acquires Policy { + assert_policy_exists(policy); + assert!( + Policy[policy].admins.contains(&addr), + error::permission_denied(ENOT_ADMIN) + ); + } + + // =============================== Tests =============================== + + #[test_only] + use std::account::create_account_for_test; + #[test_only] + use std::timestamp; + #[test_only] + use std::ed25519; + + #[test_only] + const SUBJECT_A: address = @0xa11; + #[test_only] + const SUBJECT_B: address = @0xb22; + #[test_only] + const SUBJECT_C: address = @0xc33; + #[test_only] + const NONCE: vector = x"1111111111111111111111111111111111111111111111111111111111111111"; + #[test_only] + const NONCE_2: vector = x"2222222222222222222222222222222222222222222222222222222222222222"; + #[test_only] + const ONE_YEAR: u64 = 31536000; + #[test_only] + const LEVEL_BASIC: u8 = 1; + #[test_only] + const LEVEL_ENHANCED: u8 = 2; + // ISO-3166-1 numeric for Portugal (620) and Brazil (076), big-endian. + #[test_only] + const ATTR_COUNTRY: u16 = 1; + #[test_only] + const COUNTRY_PT: vector = x"026c"; + #[test_only] + const COUNTRY_BR: vector = x"004c"; + + #[test_only] + fun setup(framework: &signer) { + timestamp::set_time_has_started_for_testing(framework); + std::chain_id::initialize_for_test(framework, 4); + } + + // A source whose deployer holds every role, with one registered issuer. + #[test_only] + fun new_source(deployer: &signer): address { + let deployer_address = address_of(deployer); + if (!std::account::exists_at(deployer_address)) { + create_account_for_test(deployer_address); + }; + let source = attestation::get_next_source_address(deployer_address); + attestation::create( + deployer, + vector[deployer_address], + vector[], + vector[deployer_address], + vector[deployer_address], + vector[deployer_address] + ); + attestation::register_issuer(deployer, source, deployer_address, vector[]); + source + } + + #[test_only] + fun new_policy(deployer: &signer): address { + let deployer_address = address_of(deployer); + if (!std::account::exists_at(deployer_address)) { + create_account_for_test(deployer_address); + }; + let policy = get_next_policy_address(deployer_address); + create(deployer, vector[deployer_address], vector[deployer_address]); + policy + } + + #[test_only] + fun vouch(deployer: &signer, source: address, subject: address, level: u8) { + attestation::issue_batch( + deployer, + source, + vector[subject], + vector[level], + vector[now_seconds() + ONE_YEAR], + 0 + ); + } + + // Stage a body requiring any of `sources` at `level`, and activate it immediately. + #[test_only] + fun apply_require_any( + admin: &signer, policy: address, sources: vector
, level: u8 + ) acquires Policy { + let levels = vector[]; + sources.for_each_ref(|_s| { levels.push_back(level); }); + stage_body( + admin, + policy, + sources, + levels, + vector[], + vector[], + vector[], + vector[], + now_seconds() + ); + activate_pending(admin, policy); + } + + // --- Creation and the empty body --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_create_denies_everything(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + assert!(admins(policy) == vector[address_of(deployer)], 0); + assert!(!is_paused(policy), 1); + // An unconfigured policy is a loud refusal, not an open door. + let (decision, reason) = evaluate(policy, SUBJECT_A, ACTION_TRANSFER, 1); + assert!(decision == DECISION_DENY, 2); + assert!(reason == REASON_EMPTY_BODY, 3); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 4); + // The nonce store was created alongside, so the step-up path is usable later. + assert!(attestation_authorization::is_initialized(policy), 5); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10006, location = Self)] + fun test_create_without_admin_fails(framework: &signer, deployer: &signer) { + setup(framework); + create_account_for_test(address_of(deployer)); + create(deployer, vector[], vector[]); + } + + // --- require_any and levels --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_require_any(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_NO_QUALIFYING, 1); + + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 2); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_OK, 3); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_level_too_low(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_ENHANCED); + + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + // Vouched for, but not highly enough. This is the yellow tier. + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_NO_QUALIFYING, 1); + + vouch(deployer, source, SUBJECT_A, LEVEL_ENHANCED); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 2); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_require_any_accepts_either_source( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let first = new_source(deployer); + let second = new_source(other); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[first, second], LEVEL_BASIC); + + vouch(other, second, SUBJECT_A, LEVEL_BASIC); + // Either source qualifying is enough, which is what makes onboarding cost zero for a + // business that points at sources someone else already populated. + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_require_all_needs_every_source( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let first = new_source(deployer); + let second = new_source(other); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[], + vector[], + vector[first, second], + vector[LEVEL_BASIC, LEVEL_BASIC], + vector[], + vector[], + now_seconds() + ); + activate_pending(deployer, policy); + + vouch(deployer, first, SUBJECT_A, LEVEL_BASIC); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_MISSING_REQUIRED, 0); + vouch(other, second, SUBJECT_A, LEVEL_BASIC); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 1); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_require_all_level_too_low( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let first = new_source(deployer); + let policy = new_policy(other); + stage_body( + other, + policy, + vector[], + vector[], + vector[first], + vector[LEVEL_ENHANCED], + vector[], + vector[], + now_seconds() + ); + activate_pending(other, policy); + vouch(deployer, first, SUBJECT_A, LEVEL_BASIC); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_LEVEL_TOO_LOW, 0); + } + + // --- Denial precedence --- + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_deny_source_beats_a_vouching_source( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let good = new_source(deployer); + let sanctions = new_source(other); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[good], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[sanctions], + vector[], + now_seconds() + ); + activate_pending(deployer, policy); + + vouch(deployer, good, SUBJECT_A, LEVEL_ENHANCED); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + + // One denial outranks any amount of vouching, from any number of sources. + attestation::deny(other, sanctions, SUBJECT_A, 99, now_seconds()); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 1); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_SOURCE_DENIED, 2); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_chain_deny_checked_first( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let good = new_source(deployer); + let chain = new_source(other); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[good], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[chain], + now_seconds() + ); + activate_pending(deployer, policy); + + vouch(deployer, good, SUBJECT_A, LEVEL_BASIC); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + attestation::deny(other, chain, SUBJECT_A, 1, now_seconds()); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_CHAIN_DENIED, 1); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + #[expected_failure(abort_code = 0x10012, location = Self)] + fun test_two_chain_deny_sources_fail( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let first = new_source(deployer); + let second = new_source(other); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[first], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[first, second], + now_seconds() + ); + } + + // --- Attribute predicates --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_attr_rule_in_and_not_in(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + vouch(deployer, source, SUBJECT_B, LEVEL_BASIC); + attestation::set_attribute(deployer, source, SUBJECT_A, ATTR_COUNTRY, COUNTRY_PT); + attestation::set_attribute(deployer, source, SUBJECT_B, ATTR_COUNTRY, COUNTRY_BR); + + // Allow only Portugal. + stage_attr_rules( + deployer, + policy, + vector[source], + vector[ATTR_COUNTRY], + vector[OP_IN], + vector[vector[COUNTRY_PT]] + ); + activate_pending(deployer, policy); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + assert!(!is_allowed(policy, SUBJECT_B, ACTION_TRANSFER, 1), 1); + assert!(reason_of(policy, SUBJECT_B, ACTION_TRANSFER, 1) == REASON_ATTR_FAILED, 2); + + // Invert it: everywhere except Portugal. + stage_attr_rules( + deployer, + policy, + vector[source], + vector[ATTR_COUNTRY], + vector[OP_NOT_IN], + vector[vector[COUNTRY_PT]] + ); + activate_pending(deployer, policy); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 3); + assert!(is_allowed(policy, SUBJECT_B, ACTION_TRANSFER, 1), 4); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_unset_attribute_satisfies_only_not_in( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_C, LEVEL_BASIC); + // SUBJECT_C has no country attribute at all. + + stage_attr_rules( + deployer, + policy, + vector[source], + vector[ATTR_COUNTRY], + vector[OP_IN], + vector[vector[COUNTRY_PT]] + ); + activate_pending(deployer, policy); + // A subject the source says nothing about does not meet a positive requirement. + assert!(!is_allowed(policy, SUBJECT_C, ACTION_TRANSFER, 1), 0); + + stage_attr_rules( + deployer, + policy, + vector[source], + vector[ATTR_COUNTRY], + vector[OP_NOT_IN], + vector[vector[COUNTRY_PT]] + ); + activate_pending(deployer, policy); + // But it is not in any list either. + assert!(is_allowed(policy, SUBJECT_C, ACTION_TRANSFER, 1), 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_attr_rule_gte(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + // An 8-byte big-endian score of 5000. + attestation::set_attribute(deployer, source, SUBJECT_A, 9, x"0000000000001388"); + + stage_attr_rules( + deployer, + policy, + vector[source], + vector[9], + vector[OP_GTE], + vector[vector[x"0000000000001388"]] + ); + activate_pending(deployer, policy); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + + // Raise the bar by one. + stage_attr_rules( + deployer, + policy, + vector[source], + vector[9], + vector[OP_GTE], + vector[vector[x"0000000000001389"]] + ); + activate_pending(deployer, policy); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10010, location = Self)] + fun test_eq_rule_with_two_values_fails(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + stage_attr_rules( + deployer, + policy, + vector[source], + vector[ATTR_COUNTRY], + vector[OP_EQ], + vector[vector[COUNTRY_PT, COUNTRY_BR]] + ); + } + + // --- Staging --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_staged_body_waits_for_its_time(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + + stage_body( + deployer, + policy, + vector[source], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + 1000 + ); + assert!(has_pending(policy), 0); + assert!(pending_effective_at(policy) == 1000, 1); + // Staged, not live: the old body still governs, so transactions in flight are unaffected. + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_EMPTY_BODY, 2); + + timestamp::update_global_time_for_test_secs(1000); + activate_pending(deployer, policy); + assert!(!has_pending(policy), 3); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 4); + } + + #[test(framework = @0x1, deployer = @0x123, anyone = @0x456)] + fun test_activation_is_permissionless( + framework: &signer, deployer: &signer, anyone: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[source], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + now_seconds() + ); + // The business need not be online when its own rule change comes due. + activate_pending(anyone, policy); + assert!(!has_pending(policy), 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x3000C, location = Self)] + fun test_activate_before_effective_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[source], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + 1000 + ); + activate_pending(deployer, policy); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x3000B, location = Self)] + fun test_activate_without_pending_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + activate_pending(deployer, policy); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_cancel_pending(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + stage_body( + deployer, + policy, + vector[source], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + 1000 + ); + cancel_pending(deployer, policy); + assert!(!has_pending(policy), 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x6000F, location = Self)] + fun test_staging_unknown_source_fails(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + // Naming a source that does not exist would abort for every subject at evaluation time, + // bricking the policy. Reject it at staging time instead. + stage_body( + deployer, + policy, + vector[@0xdead], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + now_seconds() + ); + } + + // --- Pause --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_pause_denies_loudly(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + + pause(deployer, policy); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 1); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_POLICY_PAUSED, 2); + unpause(deployer, policy); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 3); + } + + // --- Simulation --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_simulate(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + set_step_up(deployer, policy, ACTION_TRANSFER, 100); + + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + vouch(deployer, source, SUBJECT_B, LEVEL_BASIC); + attestation::deny(deployer, source, SUBJECT_B, 1, now_seconds()); + + let decisions = simulate(policy, vector[SUBJECT_A, SUBJECT_B, SUBJECT_C], ACTION_TRANSFER, 1); + assert!(decisions == vector[DECISION_ALLOW, DECISION_DENY, DECISION_DENY], 0); + + // Over the threshold, the allowed subject needs an authorization instead. + let stepped = simulate(policy, vector[SUBJECT_A], ACTION_TRANSFER, 1000); + assert!(stepped == vector[DECISION_STEP_UP], 1); + + let counts = simulate_counts(policy, vector[SUBJECT_A, SUBJECT_B, SUBJECT_C], ACTION_TRANSFER, 1); + assert!(counts == vector[1, 2, 0], 2); + } + + // --- require and step-up thresholds --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_step_up_threshold(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + + // No threshold set, so no action ever steps up. + assert!(step_up_for(policy, ACTION_TRANSFER) == 0, 0); + assert!(decision_of(policy, SUBJECT_A, ACTION_TRANSFER, 1000000) == DECISION_ALLOW, 1); + + set_step_up(deployer, policy, ACTION_TRANSFER, 100); + assert!(decision_of(policy, SUBJECT_A, ACTION_TRANSFER, 100) == DECISION_ALLOW, 2); + assert!(decision_of(policy, SUBJECT_A, ACTION_TRANSFER, 101) == DECISION_STEP_UP, 3); + // A threshold is per action, so another action is unaffected. + assert!(decision_of(policy, SUBJECT_A, ACTION_BORROW, 10000) == DECISION_ALLOW, 4); + + clear_step_up(deployer, policy, ACTION_TRANSFER); + assert!(decision_of(policy, SUBJECT_A, ACTION_TRANSFER, 101) == DECISION_ALLOW, 5); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x5000D, location = Self)] + fun test_require_aborts_when_denied(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + require(policy, SUBJECT_A, ACTION_TRANSFER, 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x5000E, location = Self)] + fun test_require_aborts_when_step_up_needed( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + set_step_up(deployer, policy, ACTION_TRANSFER, 100); + require(policy, SUBJECT_A, ACTION_TRANSFER, 500); + } + + // --- Authorization (the step-up path) --- + + #[test_only] + /// Stand up a policy that steps up above `threshold`, with a live authorizer key. + fun policy_with_authorizer( + deployer: &signer, source: address, threshold: u64 + ): (address, ed25519::SecretKey) acquires Policy { + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + set_step_up(deployer, policy, ACTION_TRANSFER, threshold); + let (secret_key, validated_key) = ed25519::generate_keys(); + set_authorizer( + deployer, + policy, + ed25519::validated_public_key_to_bytes(&validated_key), + 3600 + ); + (policy, secret_key) + } + + #[test_only] + fun sign_authorization( + secret_key: &ed25519::SecretKey, + policy: address, + subject: address, + action: u8, + bucket: u8, + nonce: vector, + issued_at: u64, + expires_at: u64 + ): vector { + let message = + attestation_authorization::authorization_message( + policy, subject, action, bucket, nonce, issued_at, expires_at + ); + attestation_authorization::encode_for_test( + action, + bucket, + nonce, + issued_at, + expires_at, + ed25519::signature_to_bytes(&ed25519::sign_arbitrary_bytes(secret_key, message)) + ) + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_require_authorized_passes_with_a_real_signature( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + + // Bucket 3 commits to a ceiling of 1000, which covers the 500 being moved. + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + assert!(attestation_authorization::is_nonce_used(policy, NONCE), 0); + + // Below the threshold nothing is consumed, so the same call works with no authorization. + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 50, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30004, location = aptos_framework::attestation_authorization)] + fun test_authorization_replay_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + // One capability, one use. + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10005, location = aptos_framework::attestation_authorization)] + fun test_authorization_amount_over_bucket_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + // Bucket 2 commits to a ceiling of 100, and the caller tries to move 5000 with it. + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 2, NONCE, 0, 600 + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 5000, authorization); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10002, location = aptos_framework::attestation_authorization)] + fun test_authorization_for_another_subject_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + vouch(deployer, source, SUBJECT_B, LEVEL_BASIC); + // Signed for B, presented for A. The subject is inside the signed message. + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_B, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30003, location = aptos_framework::attestation_authorization)] + fun test_expired_authorization_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + timestamp::update_global_time_for_test_secs(600); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x10006, location = aptos_framework::attestation_authorization)] + fun test_authorization_window_longer_than_policy_allows_fails( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + // The policy caps the window at 3600 seconds; the authorizer tried to issue a year. + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, ONE_YEAR + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_authorization_does_not_override_a_denial( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + attestation::deny(deployer, source, SUBJECT_A, 1, now_seconds()); + + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + // A denial is not a step-up, so a perfectly valid capability cannot buy past it. + let (decision, reason) = evaluate(policy, SUBJECT_A, ACTION_TRANSFER, 500); + assert!(decision == DECISION_DENY, 0); + assert!(reason == REASON_NO_QUALIFYING, 1); + assert!(!attestation_authorization::is_nonce_used(policy, NONCE), 2); + let _ = authorization; + } + + #[test(framework = @0x1, deployer = @0x123, anyone = @0x456)] + fun test_prune_expired_nonces( + framework: &signer, deployer: &signer, anyone: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let (policy, secret_key) = policy_with_authorizer(deployer, source, 100); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + let authorization = + sign_authorization( + &secret_key, policy, SUBJECT_A, ACTION_TRANSFER, 3, NONCE, 0, 600 + ); + require_authorized(policy, SUBJECT_A, ACTION_TRANSFER, 500, authorization); + assert!(attestation_authorization::is_nonce_used(policy, NONCE), 0); + + // Still replayable, so pruning must refuse to release it. + attestation_authorization::prune_nonces(anyone, policy, vector[NONCE]); + assert!(attestation_authorization::is_nonce_used(policy, NONCE), 1); + + timestamp::update_global_time_for_test_secs(600); + attestation_authorization::prune_nonces(anyone, policy, vector[NONCE]); + assert!(!attestation_authorization::is_nonce_used(policy, NONCE), 2); + } + + // --- Roles --- + + #[test(framework = @0x1, deployer = @0x123, stranger = @0x456)] + #[expected_failure(abort_code = 0x50002, location = Self)] + fun test_non_admin_cannot_stage( + framework: &signer, deployer: &signer, stranger: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + stage_body( + stranger, + policy, + vector[source], + vector[LEVEL_BASIC], + vector[], + vector[], + vector[], + vector[], + now_seconds() + ); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x30007, location = Self)] + fun test_cannot_remove_last_admin(framework: &signer, deployer: &signer) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + remove_admins(deployer, policy, vector[address_of(deployer)]); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + fun test_role_add_and_remove( + framework: &signer, deployer: &signer, other: &signer + ) acquires Policy { + setup(framework); + let policy = new_policy(deployer); + let other_address = address_of(other); + add_admins(deployer, policy, vector[other_address]); + assert!(is_admin(other_address, policy), 0); + add_guardians(deployer, policy, vector[other_address]); + assert!(guardians(policy).contains(&other_address), 1); + remove_guardians(deployer, policy, vector[other_address]); + assert!(!guardians(policy).contains(&other_address), 2); + remove_admins(deployer, policy, vector[other_address]); + assert!(!is_admin(other_address, policy), 3); + } + + // --- A source going stale propagates through the policy --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_issuer_compromise_propagates( + framework: &signer, deployer: &signer + ) acquires Policy { + setup(framework); + let source = new_source(deployer); + let policy = new_policy(deployer); + apply_require_any(deployer, policy, vector[source], LEVEL_BASIC); + vouch(deployer, source, SUBJECT_A, LEVEL_BASIC); + assert!(is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 0); + + // One write at the source invalidates the whole cohort, and every policy reading that + // source sees it immediately with no action of its own. + attestation::bump_issuer_epoch(deployer, source, 1); + assert!(!is_allowed(policy, SUBJECT_A, ACTION_TRANSFER, 1), 1); + assert!(reason_of(policy, SUBJECT_A, ACTION_TRANSFER, 1) == REASON_NO_QUALIFYING, 2); + } +} diff --git a/aptos-move/framework/aptos-framework/sources/attestation_policy.spec.move b/aptos-move/framework/aptos-framework/sources/attestation_policy.spec.move new file mode 100644 index 00000000000..cb051a65da0 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/attestation_policy.spec.move @@ -0,0 +1,213 @@ +spec aptos_framework::attestation_policy { + /// + /// No.: 1 + /// Requirement: A staged body has no effect before its activation time (INV-9). Staging, re-staging and + /// cancelling never change the live body, and only activate_pending swaps it in, once its time has arrived. + /// Criticality: Critical + /// Implementation: stage_body and stage_attr_rules write only Policy.pending. activate_pending asserts that a body + /// is pending and that now_seconds() >= effective_at_secs before replacing Policy.body. + /// Enforcement: Formally verified via [high-level-req-1.1](stage_body), [high-level-req-1.2](stage_attr_rules), + /// [high-level-req-1.3](cancel_pending) and [high-level-req-1.4](activate_pending). + /// + /// No.: 2 + /// Requirement: A denial always produces DECISION_DENY (INV-1). A chain-wide denial is consulted before any + /// positive rule and wins over every vouching source. + /// Criticality: Critical + /// Implementation: evaluate checks the chain denial source first, then every deny_any source, before any + /// require_all or require_any rule. + /// Enforcement: Formally verified for the chain denial via [high-level-req-2](evaluate). The deny_any sources are + /// consulted inside a for_each_ref loop, which the prover havocs, and are covered by unit tests + /// (test_deny_source_beats_a_vouching_source). + /// + /// No.: 3 + /// Requirement: A paused policy denies with its own reason rather than silently allowing, and a policy with no + /// positive rule allows nothing. + /// Criticality: High + /// Implementation: evaluate returns (DECISION_DENY, REASON_POLICY_PAUSED) when paused, and + /// (DECISION_DENY, REASON_EMPTY_BODY) when both require lists are empty. + /// Enforcement: Formally verified via [high-level-req-3.1](evaluate) and [high-level-req-3.2](require). + /// + /// No.: 4 + /// Requirement: Only an admin can stage, cancel, set step-up thresholds, set the authorizer or manage roles, only a + /// guardian can pause, and a policy always has at least one admin. + /// Criticality: Critical + /// Implementation: assert_admin and the guardian check run first in every entry function, and remove_admins + /// asserts that at least one admin remains. + /// Enforcement: Formally verified via [high-level-req-4.1](stage_body), [high-level-req-4.2](set_paused) and + /// [high-level-req-4.3](remove_admins). + /// + /// No.: 5 + /// Requirement: A body can name at most one chain-wide denial source. + /// Criticality: Medium + /// Implementation: stage_body asserts chain_deny has length 0 or 1. + /// Enforcement: Formally verified via [high-level-req-5](stage_body). + /// + spec module { + pragma verify = true; + pragma aborts_if_is_strict = false; + } + + spec fun spec_now(): u64 { + aptos_framework::timestamp::spec_now_seconds() + } + + spec schema PolicyAdminAbortsIf { + policy: address; + admin: signer; + aborts_if !exists(policy); + aborts_if !contains(global(policy).admins, address_of(admin)); + } + + spec standard_version(): u64 { + aborts_if false; + ensures result == VERSION; + } + + spec is_paused(policy: address): bool { + aborts_if !exists(policy); + ensures result == global(policy).paused; + } + + spec has_pending(policy: address): bool { + aborts_if !exists(policy); + ensures result == option::is_some(global(policy).pending); + } + + spec step_up_for(policy: address, action: u8): u64 { + aborts_if !exists(policy); + let thresholds = global(policy).step_up_above; + ensures table::spec_contains(thresholds, action) ==> result == table::spec_get(thresholds, action); + ensures !table::spec_contains(thresholds, action) ==> result == 0; + } + + spec evaluate(policy: address, subject: address, action: u8, amount: u64): (u8, u16) { + // Source lists are walked by for_each_ref loops, which the prover havocs, so abort + // conditions inside them (a missing clock) are not enumerated. + pragma aborts_if_is_partial; + aborts_if !exists(policy); + let config = global(policy); + let body = config.body; + let empty = len(body.require_any) == 0 && len(body.require_all) == 0; + /// [high-level-req-3.1] + ensures config.paused ==> result_1 == DECISION_DENY && result_2 == REASON_POLICY_PAUSED; + ensures !config.paused && empty ==> result_1 == DECISION_DENY && result_2 == REASON_EMPTY_BODY; + /// [high-level-req-2] + ensures !config.paused && !empty && option::is_some(body.chain_deny) + && aptos_framework::attestation::spec_is_denied(option::borrow(body.chain_deny), subject) + ==> result_1 == DECISION_DENY && result_2 == REASON_CHAIN_DENIED; + ensures result_1 == DECISION_ALLOW ==> result_2 == REASON_OK; + ensures result_1 == DECISION_ALLOW || result_1 == DECISION_DENY || result_1 == DECISION_STEP_UP; + } + + spec require(policy: address, subject: address, action: u8, amount: u64) { + pragma aborts_if_is_partial; + aborts_if !exists(policy); + /// [high-level-req-3.2] + aborts_if global(policy).paused; + aborts_if len(global(policy).body.require_any) == 0 + && len(global(policy).body.require_all) == 0; + } + + spec stage_body( + admin: &signer, + policy: address, + require_any_sources: vector
, + require_any_levels: vector, + require_all_sources: vector
, + require_all_levels: vector, + deny_any: vector
, + chain_deny: vector
, + effective_at_secs: u64 + ) { + // Source existence is checked inside loops, which the prover havocs. + pragma aborts_if_is_partial; + /// [high-level-req-4.1] + include PolicyAdminAbortsIf; + /// [high-level-req-5] + aborts_if len(chain_deny) > 1; + aborts_if len(require_any_sources) > MAX_SOURCES; + aborts_if len(require_all_sources) > MAX_SOURCES; + aborts_if len(deny_any) > MAX_SOURCES; + aborts_if len(require_any_sources) != len(require_any_levels); + aborts_if len(require_all_sources) != len(require_all_levels); + let post config = global(policy); + /// [high-level-req-1.1] + ensures config.body == old(global(policy).body); + ensures option::is_some(config.pending); + ensures option::borrow(config.pending).effective_at_secs == effective_at_secs; + ensures option::borrow(config.pending).body.deny_any == deny_any; + ensures len(chain_deny) == 0 ==> option::is_none(option::borrow(config.pending).body.chain_deny); + ensures len(chain_deny) == 1 ==> option::borrow(config.pending).body.chain_deny == option::spec_some(chain_deny[0]); + } + + spec stage_attr_rules( + admin: &signer, + policy: address, + sources: vector
, + keys: vector, + ops: vector, + values: vector>> + ) { + pragma aborts_if_is_partial; + include PolicyAdminAbortsIf; + aborts_if len(sources) > MAX_RULES; + aborts_if len(sources) != len(keys) || len(sources) != len(ops) || len(sources) != len(values); + /// [high-level-req-1.2] + ensures global(policy).body == old(global(policy).body); + ensures option::is_some(global(policy).pending); + } + + spec cancel_pending(admin: &signer, policy: address) { + include PolicyAdminAbortsIf; + aborts_if option::is_none(global(policy).pending); + /// [high-level-req-1.3] + ensures global(policy).body == old(global(policy).body); + ensures option::is_none(global(policy).pending); + } + + spec activate_pending(_anyone: &signer, policy: address) { + let config = global(policy); + aborts_if !exists(policy); + aborts_if option::is_none(config.pending); + aborts_if !exists(@aptos_framework); + /// [high-level-req-1.4] + aborts_if spec_now() < option::borrow(config.pending).effective_at_secs; + ensures global(policy).body == option::borrow(config.pending).body; + ensures option::is_none(global(policy).pending); + } + + spec set_paused(guardian: &signer, policy: address, paused: bool) { + aborts_if !exists(policy); + /// [high-level-req-4.2] + aborts_if !contains(global(policy).guardians, address_of(guardian)); + ensures global(policy).paused == paused; + ensures global(policy).body == old(global(policy).body); + ensures global(policy).pending == old(global(policy).pending); + } + + spec set_step_up(admin: &signer, policy: address, action: u8, threshold: u64) { + include PolicyAdminAbortsIf; + ensures table::spec_get(global(policy).step_up_above, action) == threshold; + ensures global(policy).body == old(global(policy).body); + } + + spec set_authorizer(admin: &signer, policy: address, pubkey: vector, max_ttl_secs: u64) { + include PolicyAdminAbortsIf; + aborts_if len(pubkey) != 0 && len(pubkey) != 32; + ensures global(policy).authorizer_pubkey == pubkey; + ensures global(policy).authorizer_max_ttl_secs == max_ttl_secs; + } + + spec remove_admins(admin: &signer, policy: address, old_admins: vector
) { + pragma aborts_if_is_partial; + include PolicyAdminAbortsIf; + /// [high-level-req-4.3] + ensures len(global(policy).admins) >= 1; + } + + spec create(deployer: &signer, admins: vector
, guardians: vector
) { + // create_resource_account has cross-module side effects (account creation, coin + // registration, sequence-number seed derivation) that the prover cannot model. + pragma verify = false; + } +} diff --git a/aptos-move/framework/aptos-framework/sources/merkle_proof.move b/aptos-move/framework/aptos-framework/sources/merkle_proof.move new file mode 100644 index 00000000000..be322fa0489 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/merkle_proof.move @@ -0,0 +1,326 @@ +/// OpenZeppelin-compatible Merkle membership verification. +/// +/// Matches @openzeppelin/merkle-tree and OpenZeppelin's MerkleProof.sol exactly: +/// leaf = keccak256(keccak256(preimage)) (standardLeafHash) +/// node = keccak256(min(a,b) || max(a,b)) (standardNodeHash / commutativeKeccak256) +/// +/// Sorted-pair hashing is what removes the leaf index from the API: the verifier never +/// needs to know whether a sibling is on the left or the right. The double-hashed leaf is +/// what replaces domain-separation prefixes; without it, the concatenation of a sorted pair +/// of internal nodes can be reinterpreted as a leaf. That is the 64-byte hazard +/// MerkleProof.sol warns about, and it is the whole reason the leaf is hashed twice. +/// +/// Written for Move 2.1: no resource index syntax, explicit vector:: calls. +module aptos_framework::merkle_proof { + use std::error; + use std::vector; + use aptos_framework::aptos_hash; + use aptos_framework::comparator; + + /// Proof length cap. OpenZeppelin trees are complete but not perfect, so leaves sit at + /// two different depths and proof length varies per leaf. Exact-depth checks are + /// therefore not available; cap instead. 32 covers 2^32 leaves. + const MAX_PROOF_LEN: u64 = 32; + + /// The proof has more than MAX_PROOF_LEN siblings. + const E_PROOF_TOO_LONG: u64 = 1; + /// A leaf or sibling is not exactly 32 bytes. + const E_BAD_DIGEST_LEN: u64 = 2; + + /// commutativeKeccak256: sort the pair, concatenate, hash the 64 bytes. + fun hash_pair(a: vector, b: vector): vector { + let cmp = comparator::compare_u8_vector(copy a, copy b); + let buf = if (comparator::is_smaller_than(&cmp)) { + let t = a; + vector::append(&mut t, b); + t + } else { + let t = b; + vector::append(&mut t, a); + t + }; + aptos_hash::keccak256(buf) + } + + /// standardLeafHash. `preimage` is the ABI encoding of the leaf tuple. Keep every field + /// a 32-byte value (bytes32 on the JavaScript side) so plain concatenation here matches + /// abi.encode there; see the note on encoding at the bottom of this file. + public fun leaf_hash(preimage: vector): vector { + aptos_hash::keccak256(aptos_hash::keccak256(preimage)) + } + + /// MerkleProof.processProof: fold the leaf upward through the sibling hashes. + public fun process_proof(leaf: vector, proof: vector>): vector { + assert!( + vector::length(&proof) <= MAX_PROOF_LEN, + error::invalid_argument(E_PROOF_TOO_LONG) + ); + assert!(vector::length(&leaf) == 32, error::invalid_argument(E_BAD_DIGEST_LEN)); + let computed = leaf; + let i = 0; + let n = vector::length(&proof); + while ({ + spec { + invariant n == len(proof); + invariant i <= n; + invariant i == 0 ==> computed == leaf; + }; + i < n + }) { + let sibling = *vector::borrow(&proof, i); + assert!( + vector::length(&sibling) == 32, + error::invalid_argument(E_BAD_DIGEST_LEN) + ); + computed = hash_pair(computed, sibling); + i = i + 1; + }; + computed + } + + /// MerkleProof.verify. + public fun verify(root: vector, leaf: vector, proof: vector>): bool { + process_proof(leaf, proof) == root + } + + // ------------------------------------------------------------------ + // Registry-facing shape: address in, proof in, bool out. + // ------------------------------------------------------------------ + + /// Bind the leaf to this registry so a proof issued for one registry cannot be replayed + /// against another that happens to adopt the same root. Both fields are 32 bytes, so the + /// preimage is bytes32 || bytes32 and the JS side is + /// StandardMerkleTree.of([[registry, subject]], ['bytes32','bytes32']) + /// + /// Deliberately NOT included: the epoch. Leaving it out lets the registry accept the + /// previous root during a rotation grace window without recomputing the leaf. + public fun subject_leaf(registry: address, subject: address): vector { + let buf = std::bcs::to_bytes(®istry); + vector::append(&mut buf, std::bcs::to_bytes(&subject)); + leaf_hash(buf) + } + + #[test] + fun oz_vectors() { + // Tree over five 32-byte addresses, built with OpenZeppelin's algorithm: + // double-hashed leaves, leaves sorted by hash (sortLeaves defaults to true), + // placed in reverse at the tail of a 2n-1 heap, commutative node hashing. + let root = x"0573ec1d2c71abd9d936aca283796fc8a9fbaddc3266ecd0c390aa1106c3df3f"; + + // 0x...0c33, proof depth 3 + let leaf_c33 = leaf_hash(x"0000000000000000000000000000000000000000000000000000000000000c33"); + let proof_c33 = vector[ + x"737a3a3bcf517b50aafa2edc80a736cb71072a5454183b97ae01b5c1dd816a4f", + x"dd4f35afdc3e50ee9d2d45a15591c2f44e4b230aa130b9e4d75421bf464f560d", + x"9e9c8062173feba09de411bec52f93f262827a7e380c4bcbcc04f89e17fb02f4", + ]; + assert!(verify(root, leaf_c33, proof_c33), 100); + + // 0x...0a11, proof depth 2. Different depth, same root: this is why the verifier + // must cap proof length rather than require an exact depth. + let leaf_a11 = leaf_hash(x"0000000000000000000000000000000000000000000000000000000000000a11"); + let proof_a11 = vector[ + x"9fa3dd464039702e6b38c256d95997bfc82b1b2914d5f1fb32e548aba08baf98", + x"025436324c781fd4a75116beb4c2646dea6a532d08fae4c227a4593e0baf2dcf", + ]; + assert!(verify(root, leaf_a11, proof_a11), 101); + + // An address outside the set does not verify with a well-formed proof. + let leaf_out = leaf_hash(x"000000000000000000000000000000000000000000000000000000000000ffff"); + let proof_out = vector[ + x"9fa3dd464039702e6b38c256d95997bfc82b1b2914d5f1fb32e548aba08baf98", + x"025436324c781fd4a75116beb4c2646dea6a532d08fae4c227a4593e0baf2dcf", + ]; + assert!(!verify(root, leaf_out, proof_out), 102); + } + + // The vectors below come from @openzeppelin/merkle-tree 1.x itself (StandardMerkleTree.of), + // not from a reimplementation. + + #[test_only] + const REGISTRY: address = @0x94d1a87048840daa2ccb24dd50b1176d808ab8dc170a2492845f416a7c5801b1; + #[test_only] + /// StandardMerkleTree.of([[REGISTRY, s] for s in a11, b22, c33, d44, e55, f66, 777], + /// ['bytes32', 'bytes32']). Seven leaves, so proofs have depth 2 or 3. + const BOUND_ROOT: vector = x"6b0f31d23e28de5e4a07f4ff973ece0ecd3f044616eae7984a664250a2abdc19"; + + #[test_only] + fun bound_proof(subject: address): vector> { + if (subject == @0xa11) { + vector[ + x"c186e553e4a1243744c5c6ebfb79c16707bd5d312b56362f596dde270a133bff", + x"acf871e7d55582aae9aae3304b30209eae259960aa7fca4428019394da24a1b9", + x"4faffc94448d88f74e4a8258458427d33bbdef40c08f92a3f6781d5287547d53" + ] + } else if (subject == @0xb22) { + vector[ + x"7f552b6a6c67b4fa5456123d410524b1d0e93aa4443cd94a3686911d24e7b8f7", + x"8960379e26e7324933e7b910cd20aefbef17a7dbb57d15e3fb033882ce137759", + x"4faffc94448d88f74e4a8258458427d33bbdef40c08f92a3f6781d5287547d53" + ] + } else if (subject == @0xc33) { + vector[ + x"1a55aebaecf159477c6111b27420d3a25e96c06ccac0aa6fdc9caf5402991fe7", + x"da5a4dc375046c385afcbff227285ec0cd4f104456828bbc879d71e377e4961c", + x"8ab165d1df144e47424dd904f7e966a1a8660127031778fec0aac99dcc44654e" + ] + } else if (subject == @0xd44) { + vector[ + x"504df07c38bf9e166ca2057f30207779393e774f5f36bd8065ac3adc2d06272c", + x"8960379e26e7324933e7b910cd20aefbef17a7dbb57d15e3fb033882ce137759", + x"4faffc94448d88f74e4a8258458427d33bbdef40c08f92a3f6781d5287547d53" + ] + } else if (subject == @0xe55) { + vector[ + x"fed2a70bf6fea6bec11a2ce4b69faed68e9f62624bcd16133b1e43eedaa311e5", + x"8ab165d1df144e47424dd904f7e966a1a8660127031778fec0aac99dcc44654e" + ] + } else if (subject == @0xf66) { + vector[ + x"a160143187a54d94b179c47b4d9dfe9a740228cb6e8b7a65211e4c76314e5eb8", + x"acf871e7d55582aae9aae3304b30209eae259960aa7fca4428019394da24a1b9", + x"4faffc94448d88f74e4a8258458427d33bbdef40c08f92a3f6781d5287547d53" + ] + } else { + vector[ + x"48c93a8b18ca17b82e23632554e361469c5cf1cb4bbf55de32b860c12bbd5903", + x"da5a4dc375046c385afcbff227285ec0cd4f104456828bbc879d71e377e4961c", + x"8ab165d1df144e47424dd904f7e966a1a8660127031778fec0aac99dcc44654e" + ] + } + } + + #[test] + fun test_single_leaf_tree() { + // A one-leaf tree's root is the leaf hash itself, and its proof is empty. + let leaf = leaf_hash(x"0000000000000000000000000000000000000000000000000000000000000a11"); + let root = x"b7fae45f0386c0f429982dcbe7dd1089a33e9ba104301d2b501d62670840f5e3"; + assert!(leaf == root, 0); + assert!(process_proof(leaf, vector[]) == leaf, 1); + assert!(verify(root, leaf, vector[]), 2); + } + + #[test] + fun test_two_leaf_tree() { + let root = x"ade7a8f2ed6d8922b6894eba53abe730c932f16721933db2505456ac63950feb"; + let leaf_a = leaf_hash(x"0000000000000000000000000000000000000000000000000000000000000a11"); + let leaf_b = leaf_hash(x"0000000000000000000000000000000000000000000000000000000000000b22"); + assert!(verify(root, leaf_a, vector[leaf_b]), 0); + assert!(verify(root, leaf_b, vector[leaf_a]), 1); + // Commutative: the order of the pair does not matter. + assert!(hash_pair(leaf_a, leaf_b) == hash_pair(leaf_b, leaf_a), 2); + // Without its sibling a leaf is not the root. + assert!(!verify(root, leaf_a, vector[]), 3); + } + + #[test] + fun test_subject_leaf_matches_oz() { + assert!( + subject_leaf(REGISTRY, @0xa11) + == x"a160143187a54d94b179c47b4d9dfe9a740228cb6e8b7a65211e4c76314e5eb8", + 0 + ); + let subjects = vector[@0xa11, @0xb22, @0xc33, @0xd44, @0xe55, @0xf66, @0x777]; + subjects.for_each(|subject| { + assert!(verify(BOUND_ROOT, subject_leaf(REGISTRY, subject), bound_proof(subject)), 1); + }); + } + + #[test] + fun test_wrong_proofs_fail() { + let leaf = subject_leaf(REGISTRY, @0xa11); + // Another leaf's proof. + assert!(!verify(BOUND_ROOT, leaf, bound_proof(@0xb22)), 0); + // A tampered sibling. + let proof = bound_proof(@0xa11); + let sibling = &mut proof[0]; + sibling[31] ^= 1; + assert!(!verify(BOUND_ROOT, leaf, proof), 1); + // Levels out of order. + let proof = bound_proof(@0xa11); + proof.swap(0, 1); + assert!(!verify(BOUND_ROOT, leaf, proof), 2); + // A truncated proof. + let proof = bound_proof(@0xa11); + proof.pop_back(); + assert!(!verify(BOUND_ROOT, leaf, proof), 3); + // A subject outside the set, with a member's proof. + assert!(!verify(BOUND_ROOT, subject_leaf(REGISTRY, @0x888), bound_proof(@0xa11)), 4); + // The same subject under another registry: the leaf binds the registry, so a proof cannot + // be replayed against a different source that adopted the same root. + assert!(!verify(BOUND_ROOT, subject_leaf(@0x1, @0xa11), bound_proof(@0xa11)), 5); + } + + #[test] + fun test_leaf_is_not_an_internal_node() { + // The 64-byte hazard. Node 0x8960... is the parent of the a11 and f66 leaves. Presenting the + // concatenation of its children as a leaf preimage must not reproduce it, which is what + // the double hash guarantees. + let a11 = x"a160143187a54d94b179c47b4d9dfe9a740228cb6e8b7a65211e4c76314e5eb8"; + let f66 = x"c186e553e4a1243744c5c6ebfb79c16707bd5d312b56362f596dde270a133bff"; + let parent = x"8960379e26e7324933e7b910cd20aefbef17a7dbb57d15e3fb033882ce137759"; + assert!(hash_pair(a11, f66) == parent, 0); + let preimage = a11; + vector::append(&mut preimage, f66); + assert!(leaf_hash(preimage) != parent, 1); + } + + #[test] + fun test_proof_at_the_length_cap_is_accepted() { + let proof = vector[]; + let i = 0; + while (i < MAX_PROOF_LEN) { + proof.push_back(x"0000000000000000000000000000000000000000000000000000000000000001"); + i += 1; + }; + assert!(!verify(BOUND_ROOT, subject_leaf(REGISTRY, @0xa11), proof), 0); + } + + #[test] + #[expected_failure(abort_code = 0x10001, location = Self)] + fun test_proof_over_the_length_cap_fails() { + let proof = vector[]; + let i = 0; + while (i <= MAX_PROOF_LEN) { + proof.push_back(x"0000000000000000000000000000000000000000000000000000000000000001"); + i += 1; + }; + process_proof(subject_leaf(REGISTRY, @0xa11), proof); + } + + #[test] + #[expected_failure(abort_code = 0x10002, location = Self)] + fun test_short_leaf_fails() { + process_proof(x"01", vector[]); + } + + #[test] + #[expected_failure(abort_code = 0x10002, location = Self)] + fun test_short_sibling_fails() { + process_proof(subject_leaf(REGISTRY, @0xa11), vector[x"01"]); + } +} + +// Encoding note, and the reason this interoperates at all. +// +// ABI encoding is not BCS. abi.encode pads every value to a 32-byte word, so a JS leaf of +// ['address','uint8','address'] is 96 bytes while the BCS equivalent in Move is 65. Roots +// built by the two toolchains would not match. +// +// The fix is to make every leaf field a 32-byte value and declare it as bytes32 in the +// JavaScript types array. abi.encode of bytes32 is the 32 raw bytes, and bcs::to_bytes of a +// Move address is also the 32 raw bytes with no length prefix, so plain concatenation on +// this side reproduces abi.encode on that side byte for byte. Anything narrower than 32 +// bytes (a u8 status, a u64 expiry) must be left-padded to a full word in Move before it is +// appended, or declared as bytes32 and padded on the JS side. Do not mix in BCS integers. +// +// Two consequences worth writing into the spec: +// +// 1. Commutative hashing commits to a SET, not a sequence. There is no position, so a proof +// cannot establish "this subject is at index i". Never derive a nullifier, a dedup key or +// a replay identifier from proof bytes: the sibling multiset determines the outcome, so a +// proof is not a canonical encoding. Hash the leaf for that, never the proof. +// +// 2. Every root in the tests above has been checked against @openzeppelin/merkle-tree 1.x: +// the `oz_vectors` root is StandardMerkleTree.of over the bytes32 values 0x...0a11, +// 0x...0b22, 0x...0c33, 0x...0d44 and 0x...0e55 with type ['bytes32']. diff --git a/aptos-move/framework/aptos-framework/sources/merkle_proof.spec.move b/aptos-move/framework/aptos-framework/sources/merkle_proof.spec.move new file mode 100644 index 00000000000..7096f4644bc --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/merkle_proof.spec.move @@ -0,0 +1,66 @@ +spec aptos_framework::merkle_proof { + /// + /// No.: 1 + /// Requirement: Leaves are double hashed, as in OpenZeppelin's standardLeafHash, so no 64-byte leaf preimage can be + /// confused with an internal node. + /// Criticality: Critical + /// Implementation: leaf_hash applies keccak256 twice; subject_leaf builds the preimage as bcs(registry) || + /// bcs(subject), both 32 bytes, and hashes it with leaf_hash. + /// Enforcement: Formally verified that [high-level-req-1.1](leaf_hash) and [high-level-req-1.2](subject_leaf) + /// never abort. The hash values themselves are pinned against @openzeppelin/merkle-tree vectors in unit tests: + /// the keccak256 native's abstract spec does not propagate through these call sites, so an ensures over + /// spec_keccak256 cannot be proven here. + /// + /// No.: 2 + /// Requirement: Internal nodes hash the sorted pair, as in OpenZeppelin's commutativeKeccak256, so the verifier + /// needs no leaf index. + /// Criticality: High + /// Implementation: hash_pair concatenates the smaller operand first and hashes the 64 bytes. + /// Enforcement: Audited in unit tests (test_two_leaf_tree checks commutativity, test_leaf_is_not_an_internal_node + /// checks a node against OpenZeppelin). Not formally specified, for the same keccak256 reason as requirement 1. + /// + /// No.: 3 + /// Requirement: Proofs are bounded, and every leaf and sibling is a 32-byte digest. + /// Criticality: Medium + /// Implementation: process_proof asserts len(proof) <= MAX_PROOF_LEN and a 32-byte leaf before folding, and a + /// 32-byte sibling at each step. + /// Enforcement: Formally verified via [high-level-req-3](process_proof) for the proof length and leaf length. The + /// per-sibling check sits inside the fold loop, which the prover havocs, and is covered by unit tests. + /// + /// No.: 4 + /// Requirement: An empty proof verifies exactly when the leaf is the root, which is the one-leaf tree. + /// Criticality: Medium + /// Implementation: process_proof returns the leaf unchanged when the proof is empty. + /// Enforcement: Formally verified via [high-level-req-4](verify). + /// + spec module { + pragma verify = true; + pragma aborts_if_is_strict = false; + } + + spec leaf_hash(preimage: vector): vector { + /// [high-level-req-1.1] + aborts_if false; + } + + spec subject_leaf(registry: address, subject: address): vector { + /// [high-level-req-1.2] + aborts_if false; + } + + spec process_proof(leaf: vector, proof: vector>): vector { + pragma aborts_if_is_partial; + /// [high-level-req-3] + aborts_if len(proof) > MAX_PROOF_LEN; + aborts_if len(leaf) != 32; + ensures len(proof) == 0 ==> result == leaf; + } + + spec verify(root: vector, leaf: vector, proof: vector>): bool { + pragma aborts_if_is_partial; + aborts_if len(proof) > MAX_PROOF_LEN; + aborts_if len(leaf) != 32; + /// [high-level-req-4] + ensures len(proof) == 0 ==> result == (leaf == root); + } +} diff --git a/aptos-move/framework/aptos-framework/sources/zktls.move b/aptos-move/framework/aptos-framework/sources/zktls.move new file mode 100644 index 00000000000..b85e987c722 --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/zktls.move @@ -0,0 +1,1346 @@ +/// Onchain verification of zkTLS attestations, at enrollment. +/// +/// The chain verifies the attestation itself: it recomputes the claim digest with keccak256 and +/// recovers one signer per signature with `secp256k1::ecdsa_recover` against an epoch-registered +/// attestor set, rejecting duplicates and requiring a threshold. Both primitives are ungated +/// natives, so this module needs no Rust change, no new native and no feature flag. The shape is +/// borrowed from Reclaim's onchain verifier, which contains no zero-knowledge verification at all: +/// it is a digest recomputation plus N public-key recoveries plus a threshold and a duplicate +/// check. +/// +/// This is ENROLLMENT and must never sit in a per-action path. A proxy or MPC-TLS session takes +/// seconds, needs the user to log into the provider inside the flow, and depends on a provider +/// template that breaks when the provider changes its markup. Verify once at the boundary, write a +/// fact through `aptos_framework::attestation`, then read the fact from then on. The failure mode +/// of a broken template is then that new enrollment degrades, not that existing subjects break. +/// +/// Known offchain dependency, stated plainly because it cannot be fixed here: `template_id` is the +/// hash of a provider config (the request URL, the response regex, the redaction rules) that lives +/// in the provider's own registry. An attestor cannot run a session without resolving that hash to +/// the config, so if the provider delists it the onchain template becomes a dead pointer that +/// governance cannot repair. +module aptos_framework::zktls { + use std::aptos_hash::keccak256; + use std::error; + use std::event::emit; + use std::option; + use std::secp256k1; + use std::signer::address_of; + use std::table::{Self, Table}; + use std::timestamp::now_seconds; + use aptos_framework::attestation; + + /// The caller is not an admin of the source. + const ENOT_ADMIN: u64 = 1; + /// No template is registered under the given id. + const EUNKNOWN_TEMPLATE: u64 = 2; + /// The template has been revoked. + const ETEMPLATE_REVOKED: u64 = 3; + /// Fewer distinct attestors signed than the threshold requires. + const EBELOW_THRESHOLD: u64 = 4; + /// The same attestor signed twice. + const EDUPLICATE_SIGNER: u64 = 5; + /// A recovered signer is not in the registered attestor set. + const EUNKNOWN_ATTESTOR: u64 = 6; + /// No attestor set is registered for the requested epoch. + const EUNKNOWN_ATTESTOR_EPOCH: u64 = 7; + /// The claim does not bind this subject and this template. + const EMALFORMED_CLAIM: u64 = 8; + /// A signature could not be recovered. + const EBAD_SIGNATURE: u64 = 9; + /// The threshold must be at least 1 and at most the attestor count. + const EBAD_THRESHOLD: u64 = 10; + /// The attestor list cannot contain duplicates. + const EDUPLICATE_ATTESTOR: u64 = 11; + /// This source has no verifier configured. + const ENOT_INITIALIZED: u64 = 12; + /// An attestor address must be exactly 20 bytes. + const EBAD_ATTESTOR_LENGTH: u64 = 13; + /// A signature must be exactly 65 bytes: r, s, then the recovery id. + const EBAD_SIGNATURE_LENGTH: u64 = 14; + /// The attestor epoch has been rotated away and its grace window has ended. + const ERETIRED_ATTESTOR_EPOCH: u64 = 15; + /// This claim has already been used to enroll. + const ECLAIM_CONSUMED: u64 = 16; + + /// Largest number of signatures one claim may carry. + const MAX_SIGNATURES: u64 = 16; + const MAX_U64: u64 = 18446744073709551615; + /// Ethereum-style attestor address length. + const ATTESTOR_LENGTH: u64 = 20; + /// r (32) plus s (32) plus the recovery id (1). + const SIGNATURE_LENGTH: u64 = 65; + + /// The prefix Ethereum wallets and attestor networks apply before signing. + const ETH_PREFIX: vector = b"\x19Ethereum Signed Message:\n"; + + /// A set of attestors and how many of them must sign. + struct AttestorSet has copy, drop, store { + // 20-byte Ethereum-style addresses, because that is the identity form existing attestor + // networks already publish. + attestors: vector>, + threshold: u64 + } + + /// What a successful claim under one provider template entitles the subject to. + struct Template has copy, drop, store { + // Hash of the provider config. See the module doc for what this depends on. + template_id: vector, + grants_level: u8, + ttl_secs: u64, + active: bool + } + + /// Stored under the source's resource account address. + struct Verifier has key { + // epoch to set. Past epochs are retained for audit. Only the current epoch verifies, plus + // the immediately previous one until `previous_deadline_secs`, so a claim signed moments + // before a rotation still verifies while a rotated-away compromised set stops verifying. + sets: Table, + current_epoch: u64, + // Claims under epoch current_epoch - 1 verify while now_seconds() is below this. + previous_deadline_secs: u64, + templates: Table, Template>, + // keccak256 of every claim already used to enroll, so a signed claim is single use and + // cannot refresh an expiry forever or undo an issuer's revocation. + consumed: Table, bool> + } + + #[event] + struct SetAttestorSet has drop, store { + source: address, + epoch: u64, + threshold: u64, + count: u64 + } + + #[event] + struct RegisterTemplate has drop, store { + source: address, + template_id: vector, + grants_level: u8 + } + + #[event] + struct RevokeTemplate has drop, store { + source: address, + template_id: vector + } + + #[event] + struct Enroll has drop, store { + source: address, + subject: address, + template_id: vector, + signers: u64 + } + + // =============================== Views =============================== + + #[view] + public fun is_initialized(source: address): bool { + exists(source) + } + + #[view] + public fun current_epoch(source: address): u64 acquires Verifier { + assert_initialized(source); + Verifier[source].current_epoch + } + + #[view] + /// Attestor addresses registered for an epoch. + public fun attestors(source: address, epoch: u64): vector> acquires Verifier { + assert_initialized(source); + let verifier = &Verifier[source]; + assert!( + table::contains(&verifier.sets, epoch), + error::not_found(EUNKNOWN_ATTESTOR_EPOCH) + ); + table::borrow(&verifier.sets, epoch).attestors + } + + #[view] + public fun threshold(source: address, epoch: u64): u64 acquires Verifier { + assert_initialized(source); + let verifier = &Verifier[source]; + assert!( + table::contains(&verifier.sets, epoch), + error::not_found(EUNKNOWN_ATTESTOR_EPOCH) + ); + table::borrow(&verifier.sets, epoch).threshold + } + + #[view] + /// Whether claims signed by the given epoch's attestor set are accepted right now. + public fun is_epoch_accepted(source: address, epoch: u64): bool acquires Verifier { + if (!exists(source)) { + return false + }; + epoch_accepted(&Verifier[source], epoch) + } + + #[view] + /// Whether a claim has already been used to enroll in this source. + public fun is_claim_consumed(source: address, claim: vector): bool acquires Verifier { + exists(source) + && table::contains(&Verifier[source].consumed, keccak256(claim)) + } + + #[view] + public fun is_template_active(source: address, template_id: vector): bool acquires Verifier { + if (!exists(source)) { + return false + }; + let templates = &Verifier[source].templates; + table::contains(templates, template_id) + && table::borrow(templates, template_id).active + } + + #[view] + /// The digest an attestor signs: keccak256 over the Ethereum-prefixed claim. Published so an + /// attestor implementation can be checked against this module without reading it. + public fun claim_digest(claim: vector): vector { + let prefixed = vector[]; + prefixed.append(ETH_PREFIX); + prefixed.append(decimal_bytes(claim.length())); + prefixed.append(claim); + keccak256(prefixed) + } + + #[view] + /// Recover the 20-byte attestor address that produced a signature over a claim. + public fun recover_attestor(claim: vector, signature: vector): vector { + assert!( + signature.length() == SIGNATURE_LENGTH, + error::invalid_argument(EBAD_SIGNATURE_LENGTH) + ); + let digest = claim_digest(claim); + // Ethereum wallets and attestor networks emit v as 27 or 28; the native takes 0 to 3. + let v = signature[SIGNATURE_LENGTH - 1]; + let recovery_id = if (v >= 27) { v - 27 } else { v }; + assert!(recovery_id < 4, error::invalid_argument(EBAD_SIGNATURE)); + let rs = vector[]; + let index = 0; + while (index < SIGNATURE_LENGTH - 1) { + rs.push_back(signature[index]); + index += 1; + }; + let recovered = + secp256k1::ecdsa_recover( + digest, recovery_id, &secp256k1::ecdsa_signature_from_bytes(rs) + ); + assert!(option::is_some(&recovered), error::invalid_argument(EBAD_SIGNATURE)); + let pubkey = secp256k1::ecdsa_raw_public_key_to_bytes(option::borrow(&recovered)); + // Ethereum address: the low 20 bytes of keccak256 over the 64-byte public key. + let hashed = keccak256(pubkey); + let address_bytes = vector[]; + let position = 12; + while (position < 32) { + address_bytes.push_back(hashed[position]); + position += 1; + }; + address_bytes + } + + #[view] + /// Whether `enroll` would accept this claim right now, without writing anything: the + /// signatures meet the threshold, the template is active and the claim is unused. Aborts on + /// the same malformed inputs `enroll` aborts on. + public fun verify_claim( + source: address, + template_id: vector, + subject: address, + claim: vector, + signatures: vector>, + attestor_epoch: u64 + ): bool acquires Verifier { + let (met, active, consumed, _) = + verify_claim_internal( + source, template_id, subject, claim, signatures, attestor_epoch + ); + met && active && !consumed + } + + // =============================== Configuration =============================== + + /// Create the verifier for a source. Requires an admin of that source, and obtains the + /// source's resource-account signer through `attestation`'s friend accessor. + public entry fun initialize(admin: &signer, source: address) { + assert!( + attestation::is_admin(address_of(admin), source), + error::permission_denied(ENOT_ADMIN) + ); + let source_signer = attestation::source_signer(source); + move_to( + &source_signer, + Verifier { + sets: table::new(), + current_epoch: 0, + previous_deadline_secs: 0, + templates: table::new, Template>(), + consumed: table::new, bool>() + } + ); + } + + /// Register a new attestor set under the next epoch. The set it replaces keeps verifying for + /// `previous_grace_secs`, so a claim signed moments before the rotation still verifies; every + /// older set stops verifying immediately. Rotating away a compromised set with a zero grace + /// window cuts it off at once. + /// + /// @param admin An admin of the source. + /// @param source The source address. + /// @param attestor_addresses 20-byte Ethereum-style addresses, no duplicates. + /// @param required How many distinct attestors must sign. At least 1, at most the count. + /// @param previous_grace_secs How long the replaced set keeps verifying. 0 for no grace. + public entry fun set_attestor_set( + admin: &signer, + source: address, + attestor_addresses: vector>, + required: u64, + previous_grace_secs: u64 + ) acquires Verifier { + assert!( + attestation::is_admin(address_of(admin), source), + error::permission_denied(ENOT_ADMIN) + ); + assert_initialized(source); + let count = attestor_addresses.length(); + assert!( + required >= 1 && required <= count, + error::invalid_argument(EBAD_THRESHOLD) + ); + let seen: vector> = vector[]; + attestor_addresses.for_each_ref(|attestor| { + assert!( + attestor.length() == ATTESTOR_LENGTH, + error::invalid_argument(EBAD_ATTESTOR_LENGTH) + ); + assert!( + !seen.contains(attestor), + error::invalid_argument(EDUPLICATE_ATTESTOR) + ); + seen.push_back(*attestor); + }); + + let now = now_seconds(); + let verifier = &mut Verifier[source]; + let epoch = verifier.current_epoch + 1; + verifier.current_epoch = epoch; + verifier.previous_deadline_secs = + if (previous_grace_secs > MAX_U64 - now) { MAX_U64 } + else { now + previous_grace_secs }; + table::add( + &mut verifier.sets, + epoch, + AttestorSet { attestors: attestor_addresses, threshold: required } + ); + emit(SetAttestorSet { source, epoch, threshold: required, count }); + } + + /// Allow a provider template and say what a claim under it grants. + public entry fun register_template( + admin: &signer, + source: address, + template_id: vector, + grants_level: u8, + ttl_secs: u64 + ) acquires Verifier { + assert!( + attestation::is_admin(address_of(admin), source), + error::permission_denied(ENOT_ADMIN) + ); + assert_initialized(source); + table::upsert( + &mut Verifier[source].templates, + template_id, + Template { template_id, grants_level, ttl_secs, active: true } + ); + emit(RegisterTemplate { source, template_id, grants_level }); + } + + /// Stop accepting new claims under a template. Facts already recorded are untouched; use + /// `attestation::bump_issuer_epoch` with issuer id 0, which invalidates every zkTLS + /// enrollment in the source, or a denial per subject for those. + public entry fun revoke_template( + admin: &signer, source: address, template_id: vector + ) acquires Verifier { + assert!( + attestation::is_admin(address_of(admin), source), + error::permission_denied(ENOT_ADMIN) + ); + assert_initialized(source); + let templates = &mut Verifier[source].templates; + assert!( + table::contains(templates, template_id), + error::not_found(EUNKNOWN_TEMPLATE) + ); + table::borrow_mut(templates, template_id).active = false; + emit(RevokeTemplate { source, template_id }); + } + + // =============================== Enrollment =============================== + + /// Submit a verified claim about yourself. No issuer key is involved on this path: the trust + /// root is the attestor set plus the provider's TLS certificate, not an operator holding a key. + /// + /// @param user The subject. Must be the address the claim names. + /// @param source The source to record the fact in. + /// @param template_id Registered, active template the claim was produced under. + /// @param claim Canonically serialized claim. Must contain the subject and the template id. + /// @param signatures One 65-byte recoverable ECDSA signature per attestor. + /// @param attestor_epoch Epoch whose attestor set signed. + /// @param nullifier 32 bytes binding one identity to one subject, or empty to skip. When set, + /// its lowercase hex must appear in the signed claim, so the attestors vouch for it. + /// @abort If the claim does not bind the subject (or the nullifier), a signer is unknown or + /// repeated, the attestor epoch is retired, the template is revoked, the claim was + /// already used, or fewer than the threshold signed. + public entry fun enroll( + user: &signer, + source: address, + template_id: vector, + claim: vector, + signatures: vector>, + attestor_epoch: u64, + nullifier: vector + ) acquires Verifier { + let subject = address_of(user); + let (met, active, consumed, signers) = + verify_claim_internal( + source, template_id, subject, claim, signatures, attestor_epoch + ); + assert!(active, error::invalid_state(ETEMPLATE_REVOKED)); + assert!(!consumed, error::invalid_state(ECLAIM_CONSUMED)); + assert!(met, error::invalid_argument(EBELOW_THRESHOLD)); + // A nullifier the attestors did not sign is just a user-chosen value and gives no sybil + // resistance, so it must be carried by the claim itself. + assert!( + nullifier.is_empty() || contains_bytes(&claim, &lowercase_hex(nullifier)), + error::invalid_argument(EMALFORMED_CLAIM) + ); + + let verifier = &mut Verifier[source]; + table::add(&mut verifier.consumed, keccak256(claim), true); + let template = *table::borrow(&verifier.templates, template_id); + + attestation::record_verified_claim( + source, + subject, + template.grants_level, + now_seconds() + template.ttl_secs, + keccak256(claim), + nullifier + ); + + emit(Enroll { source, subject, template_id, signers }); + } + + /// Structural checks abort. Returns whether the threshold is met, whether the template is + /// active, whether the claim was already consumed, and how many distinct attestors signed. + fun verify_claim_internal( + source: address, + template_id: vector, + subject: address, + claim: vector, + signatures: vector>, + attestor_epoch: u64 + ): (bool, bool, bool, u64) acquires Verifier { + assert_initialized(source); + assert!( + signatures.length() <= MAX_SIGNATURES, + error::invalid_argument(EBELOW_THRESHOLD) + ); + + let verifier = &Verifier[source]; + assert!( + table::contains(&verifier.templates, template_id), + error::not_found(EUNKNOWN_TEMPLATE) + ); + assert!( + table::contains(&verifier.sets, attestor_epoch), + error::not_found(EUNKNOWN_ATTESTOR_EPOCH) + ); + assert!( + epoch_accepted(verifier, attestor_epoch), + error::invalid_state(ERETIRED_ATTESTOR_EPOCH) + ); + let set = table::borrow(&verifier.sets, attestor_epoch); + + // The single most important check in this module, and the first thing an attacker will + // look for. Without it a valid attestation for one person is a valid attestation for + // whoever relays it, and one self-hosted endpoint mints unlimited verified addresses. + assert!( + claim_binds(&claim, subject, &template_id), + error::invalid_argument(EMALFORMED_CLAIM) + ); + + let seen: vector> = vector[]; + signatures.for_each_ref(|signature| { + let recovered = recover_attestor(claim, *signature); + assert!( + set.attestors.contains(&recovered), + error::invalid_argument(EUNKNOWN_ATTESTOR) + ); + assert!( + !seen.contains(&recovered), + error::invalid_argument(EDUPLICATE_SIGNER) + ); + seen.push_back(recovered); + }); + + ( + seen.length() >= set.threshold, + table::borrow(&verifier.templates, template_id).active, + table::contains(&verifier.consumed, keccak256(claim)), + seen.length() + ) + } + + fun epoch_accepted(verifier: &Verifier, epoch: u64): bool { + epoch != 0 + && ( + epoch == verifier.current_epoch + || ( + // Written as a subtraction so an epoch of u64::MAX cannot overflow. + epoch < verifier.current_epoch + && verifier.current_epoch - epoch == 1 + && now_seconds() < verifier.previous_deadline_secs + ) + ) + } + + // =============================== Helpers =============================== + + /// Whether the claim names this subject and this template. The canonical serialization is the + /// provider's, so this checks containment of both binding values rather than parsing: a claim + /// that does not carry them is rejected outright. + /// + /// Reclaim compatibility means reproducing its ASCII claim serialization exactly, down to the + /// lowercase hex identifier, the decimal formatting of its integer fields and the newline + /// joins. One wrong byte fails every proof, so this must be covered by pinned conformance + /// vectors from a real attestor rather than by a test written from the documentation. + fun claim_binds( + claim: &vector, subject: address, template_id: &vector + ): bool { + contains_bytes(claim, &lowercase_hex(std::bcs::to_bytes(&subject))) + && contains_bytes(claim, &lowercase_hex(*template_id)) + } + + /// Whether `needle` appears in `haystack`. + fun contains_bytes(haystack: &vector, needle: &vector): bool { + let needle_length = needle.length(); + let haystack_length = haystack.length(); + if (needle_length == 0 || needle_length > haystack_length) { + return needle_length == 0 + }; + let start = 0; + while (start + needle_length <= haystack_length) { + let offset = 0; + let matched = true; + while (offset < needle_length && matched) { + if (haystack[start + offset] != needle[offset]) { + matched = false; + }; + offset += 1; + }; + if (matched) { + return true + }; + start += 1; + }; + false + } + + /// Lowercase hex encoding, matching the form attestor networks put in a claim. + fun lowercase_hex(bytes: vector): vector { + let digits = b"0123456789abcdef"; + let out = vector[]; + bytes.for_each(|byte| { + out.push_back(digits[((byte >> 4) as u64)]); + out.push_back(digits[((byte & 0x0f) as u64)]); + }); + out + } + + /// Decimal ASCII encoding of a length, for the Ethereum signing prefix. + fun decimal_bytes(value: u64): vector { + if (value == 0) { + return b"0" + }; + let digits = vector[]; + let remaining = value; + while (remaining > 0) { + digits.push_back(((remaining % 10) as u8) + 48); + remaining /= 10; + }; + digits.reverse(); + digits + } + + fun assert_initialized(source: address) { + assert!(exists(source), error::not_found(ENOT_INITIALIZED)); + } + + // =============================== Tests =============================== + + #[test_only] + use std::account::create_account_for_test; + #[test_only] + use std::timestamp; + + #[test_only] + const TEMPLATE: vector = x"7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e"; + #[test_only] + const OTHER_TEMPLATE: vector = x"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"; + #[test_only] + const CLAIM_A: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2237653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030613131222c227473223a2231373030303030303030227d"; + #[test_only] + const CLAIM_B: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2237653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030623232222c227473223a2231373030303030303030227d"; + #[test_only] + const CLAIM_A_OTHER_TEMPLATE: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2235613561356135613561356135613561356135613561356135613561356135613561356135613561356135613561356135613561356135613561356135613561222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030613131222c227473223a2231373030303030303030227d"; + #[test_only] + const CLAIM_A_DIGEST: vector = x"912fdd57cc04db75f129bd56dc54658123218868366f80f6644ee0b292d027f5"; + #[test_only] + const ATTESTOR_1: vector = x"19e7e376e7c213b7e7e7e46cc70a5dd086daff2a"; + #[test_only] + const ATTESTOR_2: vector = x"1563915e194d8cfba1943570603f7606a3115508"; + #[test_only] + const ATTESTOR_3: vector = x"5cbdd86a2fa8dc4bddd8a8f69dba48572eec07fb"; + #[test_only] + const ATTESTOR_4: vector = x"7564105e977516c53be337314c7e53838967bdac"; + #[test_only] + const SIG_A_1: vector = x"4703083a440db78273fd4ce9154b0157f8ab7c15bb65eed7fbfbef5c2ec1449163d44433555af2f7ec21b64014621a54d0c4a078aac75f38c1b4f78d888ca79b00"; + #[test_only] + const SIG_A_2: vector = x"b54401bceb2e7d8061864924292ca5403aa7236f6ceeb55ec271453d6528e6f03343476fe93a74d4c576b7129e496d882fc5487b5c25c7668550284d6afe181300"; + #[test_only] + const SIG_A_3: vector = x"08697601f0f6a657e28154b8eae435136ed64b1064d9b4264982c2a1b0853f923288b570e09ef8d88a9af7e7fa177986c3d9710444ee0d57062a70bde2f47eb801"; + #[test_only] + const SIG_A_4: vector = x"b85981aae115e62404096ecb494fa0cbc5728ea6485fa98298bef589ba42368a35aed04c34c235dfa1f64f40f90a4ed633140670e196760b041829a5b8cd4e2101"; + #[test_only] + const SIG_B_1: vector = x"8fbfd696cef9aeaa25d06afc3474ae75e1986ab9133a5bc274449256172338a16a9d7d7eb86f92a76fa50a73a2818682f7a34bafa9a13df66b3c1258b34470ee00"; + #[test_only] + const SIG_B_2: vector = x"5ffc582e9709da7f454ee57f297b030ecfcd76421886edb1ad2256741c1f5b5f7b36d4575535c93602efb46161646d61641b1d8a1a8e2f1b059a5ee822dfcaf401"; + #[test_only] + const SIG_OTHER_TEMPLATE_1: vector = x"baea13629ea7d53646cc7b1d8d14cb82e283e533f902505f1b9579df195cfbb018b3732b13f10acf5195efc9dfe6836ccd99c3a4ed019cb7239cc0f39036734c01"; + #[test_only] + const SIG_OTHER_TEMPLATE_2: vector = x"60ecbc93b3b8b7afa359b7aabf2a2f2fb1fa137ca32633598e0152877011108c5dfb7455b7f1d1000d032a647b6a5c2034400b04cb50c6b0fe8d245f8a3149d300"; + + #[test_only] + const CLAIM_A_REFRESH: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2237653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030613131222c227473223a2231373030303030313030227d"; + #[test_only] + const CLAIM_A_NULLIFIED: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2237653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030613131222c227473223a2231373030303030303030222c226e756c6c6966696572223a2231313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131227d"; + #[test_only] + const CLAIM_B_NULLIFIED: vector = x"7b2270726f7669646572223a2268747470222c2274656d706c617465223a2237653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765376537653765222c226f776e6572223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030623232222c227473223a2231373030303030303030222c226e756c6c6966696572223a2231313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131227d"; + #[test_only] + const SIG_A_REFRESH_1: vector = x"cca6bd1ac3eda43ec5c92dff15268b1a35558a0f99ba7ae8a721fbc452a92e99678c820835737a589bcf65e9f3f98b4b9147f90e7737d3578421cdbcdc54130500"; + #[test_only] + const SIG_A_REFRESH_2: vector = x"64ba8b859e88de7d139e5de8a92327dbe0776e7272094cde5268b846b4f9266e47559d61595080f5744c9ef565417f92aeefe08381b39bc5adf10d01ea22182c01"; + #[test_only] + const SIG_A_NULLIFIED_1: vector = x"8f89ebbae7c4afcf47a8eac4fea1065f27d78228e3218ffa62d12f08ac0a84b04759ef4ffa2aa2a842ae6ae110832573efb21bb259e290e9983a6242679fb17000"; + #[test_only] + const SIG_A_NULLIFIED_2: vector = x"5e4da7e6a806dc873b71a5b7ffb252e491f11a0c747c422f31876220e80090c92965ba95a0ccb1f73dd984a1e0f63bab2b0e4ab972ec608bafdb5c53bdb408bd01"; + #[test_only] + const SIG_B_NULLIFIED_1: vector = x"01a673a511e1c50cfe3c73c47567b01e21bb0cd0c74f67db6111e4d4af5152b77dd5ca1ee0f2da3438afe5e069fe8bb38e3fc19fb821dc4bbef776e4743f30b000"; + #[test_only] + const SIG_B_NULLIFIED_2: vector = x"7049978c93c1960169e5e6770885938655866afe81f7fe929e33df17579096670844f26f9c9dfc7be422829d120c66594f8bb6d914d18e29c033e3e2c6de169d01"; + + #[test_only] + const SUBJECT_A: address = @0xa11; + #[test_only] + const SUBJECT_B: address = @0xb22; + #[test_only] + const LEVEL_ZKTLS: u8 = 3; + #[test_only] + const TTL: u64 = 86400; + #[test_only] + const NULLIFIER: vector = x"1111111111111111111111111111111111111111111111111111111111111111"; + + #[test_only] + fun setup(framework: &signer) { + timestamp::set_time_has_started_for_testing(framework); + std::chain_id::initialize_for_test(framework, 4); + } + + // A source whose deployer holds every role, with a verifier, a 2-of-3 attestor set at epoch 1 + // and TEMPLATE registered. + #[test_only] + fun new_verifier(deployer: &signer): address acquires Verifier { + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = attestation::get_next_source_address(deployer_address); + attestation::create( + deployer, + vector[deployer_address], + vector[], + vector[deployer_address], + vector[deployer_address], + vector[deployer_address] + ); + initialize(deployer, source); + set_attestor_set(deployer, source, vector[ATTESTOR_1, ATTESTOR_2, ATTESTOR_3], 2, 0); + register_template(deployer, source, TEMPLATE, LEVEL_ZKTLS, TTL); + source + } + + // --- Configuration --- + + #[test(framework = @0x1, deployer = @0x123)] + fun test_initialize(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = attestation::get_next_source_address(deployer_address); + attestation::create(deployer, vector[deployer_address], vector[], vector[], vector[], vector[]); + assert!(!is_initialized(source), 0); + assert!(!is_template_active(source, TEMPLATE), 1); + + initialize(deployer, source); + assert!(is_initialized(source), 2); + assert!(current_epoch(source) == 0, 3); + assert!(!is_template_active(source, TEMPLATE), 4); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + #[expected_failure(abort_code = 0x50001, location = Self)] + fun test_initialize_by_non_admin_fails( + framework: &signer, deployer: &signer, other: &signer + ) { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = attestation::get_next_source_address(deployer_address); + attestation::create(deployer, vector[deployer_address], vector[], vector[], vector[], vector[]); + initialize(other, source); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(major_status = 4004, location = Self)] + fun test_initialize_twice_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + initialize(deployer, source); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_attestor_set_rotation_keeps_old_epochs( + framework: &signer, deployer: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(current_epoch(source) == 1, 0); + assert!(attestors(source, 1) == vector[ATTESTOR_1, ATTESTOR_2, ATTESTOR_3], 1); + assert!(threshold(source, 1) == 2, 2); + + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 0); + assert!(current_epoch(source) == 2, 3); + assert!(attestors(source, 2) == vector[ATTESTOR_4], 4); + assert!(threshold(source, 2) == 1, 5); + // The previous set is retained unchanged. + assert!(attestors(source, 1) == vector[ATTESTOR_1, ATTESTOR_2, ATTESTOR_3], 6); + assert!(threshold(source, 1) == 2, 7); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000a, location = Self)] + fun test_zero_threshold_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_1], 0, 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000a, location = Self)] + fun test_threshold_above_count_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_1, ATTESTOR_2], 3, 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000b, location = Self)] + fun test_duplicate_attestor_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_1, ATTESTOR_1], 1, 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x1000d, location = Self)] + fun test_bad_attestor_length_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[x"0102"], 1, 0); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + #[expected_failure(abort_code = 0x50001, location = Self)] + fun test_set_attestor_set_by_non_admin_fails( + framework: &signer, deployer: &signer, other: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(other, source, vector[ATTESTOR_4], 1, 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x6000c, location = Self)] + fun test_set_attestor_set_uninitialized_fails( + framework: &signer, deployer: &signer + ) acquires Verifier { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = attestation::get_next_source_address(deployer_address); + attestation::create(deployer, vector[deployer_address], vector[], vector[], vector[], vector[]); + set_attestor_set(deployer, source, vector[ATTESTOR_1], 1, 0); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x60007, location = Self)] + fun test_unknown_epoch_view_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + attestors(source, 2); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_template_lifecycle(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(is_template_active(source, TEMPLATE), 0); + assert!(!is_template_active(source, OTHER_TEMPLATE), 1); + + revoke_template(deployer, source, TEMPLATE); + assert!(!is_template_active(source, TEMPLATE), 2); + + // Registering again reactivates, with the new terms. + register_template(deployer, source, TEMPLATE, LEVEL_ZKTLS + 1, TTL); + assert!(is_template_active(source, TEMPLATE), 3); + } + + #[test(framework = @0x1, deployer = @0x123)] + #[expected_failure(abort_code = 0x60002, location = Self)] + fun test_revoke_unknown_template_fails(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + revoke_template(deployer, source, OTHER_TEMPLATE); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + #[expected_failure(abort_code = 0x50001, location = Self)] + fun test_register_template_by_non_admin_fails( + framework: &signer, deployer: &signer, other: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + register_template(other, source, OTHER_TEMPLATE, LEVEL_ZKTLS, TTL); + } + + #[test(framework = @0x1, deployer = @0x123, other = @0x456)] + #[expected_failure(abort_code = 0x50001, location = Self)] + fun test_revoke_template_by_non_admin_fails( + framework: &signer, deployer: &signer, other: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + revoke_template(other, source, TEMPLATE); + } + + // --- Digest and recovery, pinned against vectors computed off chain --- + + #[test] + fun test_claim_digest_matches_ethereum_personal_sign() { + assert!(claim_digest(CLAIM_A) == CLAIM_A_DIGEST, 0); + } + + #[test] + fun test_recover_attestor() { + assert!(recover_attestor(CLAIM_A, SIG_A_1) == ATTESTOR_1, 0); + assert!(recover_attestor(CLAIM_A, SIG_A_2) == ATTESTOR_2, 1); + assert!(recover_attestor(CLAIM_A, SIG_A_3) == ATTESTOR_3, 2); + // A signature over a different claim recovers some other key. + assert!(recover_attestor(CLAIM_A, SIG_B_1) != ATTESTOR_1, 3); + } + + #[test] + fun test_recover_attestor_accepts_ethereum_v() { + // Same signatures with v encoded as 27 + recovery id, as Ethereum tooling emits them. + let sig_1 = SIG_A_1; + *sig_1.borrow_mut(SIGNATURE_LENGTH - 1) = 27; + assert!(recover_attestor(CLAIM_A, sig_1) == ATTESTOR_1, 0); + let sig_3 = SIG_A_3; + *sig_3.borrow_mut(SIGNATURE_LENGTH - 1) = 28; + assert!(recover_attestor(CLAIM_A, sig_3) == ATTESTOR_3, 1); + } + + #[test] + #[expected_failure(abort_code = 0x10009, location = Self)] + fun test_out_of_range_recovery_id_fails() { + let signature = SIG_A_1; + *signature.borrow_mut(SIGNATURE_LENGTH - 1) = 5; + recover_attestor(CLAIM_A, signature); + } + + #[test] + #[expected_failure(abort_code = 0x1000e, location = Self)] + fun test_bad_signature_length_fails() { + recover_attestor(CLAIM_A, x"00"); + } + + #[test] + #[expected_failure(abort_code = 0x10009, location = Self)] + fun test_unrecoverable_signature_fails() { + let signature = vector[]; + let index = 0; + while (index < SIGNATURE_LENGTH) { + signature.push_back(0); + index += 1; + }; + recover_attestor(CLAIM_A, signature); + } + + #[test] + fun test_helpers() { + assert!(decimal_bytes(0) == b"0", 0); + assert!(decimal_bytes(7) == b"7", 1); + assert!(decimal_bytes(190) == b"190", 2); + assert!(decimal_bytes(18446744073709551615) == b"18446744073709551615", 3); + assert!(lowercase_hex(x"00ab7f") == b"00ab7f", 4); + assert!(contains_bytes(&b"abcdef", &b"cde"), 5); + assert!(contains_bytes(&b"abcdef", &b"abcdef"), 6); + assert!(contains_bytes(&b"abc", &b""), 7); + assert!(!contains_bytes(&b"abc", &b"abcd"), 8); + assert!(!contains_bytes(&b"abcdef", &b"ce"), 9); + assert!(claim_binds(&CLAIM_A, SUBJECT_A, &TEMPLATE), 10); + assert!(!claim_binds(&CLAIM_A, SUBJECT_B, &TEMPLATE), 11); + assert!(!claim_binds(&CLAIM_A, SUBJECT_A, &OTHER_TEMPLATE), 12); + } + + // --- Enrollment --- + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_enroll_two_of_three( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(verify_claim(source, TEMPLATE, SUBJECT_A, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1), 0); + assert!(!attestation::is_verified(source, SUBJECT_A), 1); + + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_3, SIG_A_1], 1, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 2); + assert!(attestation::level_of(source, SUBJECT_A) == LEVEL_ZKTLS, 3); + assert!(attestation::expires_at(source, SUBJECT_A) == now_seconds() + TTL, 4); + + // The fact lapses on its own at the template's TTL. + timestamp::fast_forward_seconds(TTL); + assert!(!attestation::is_verified(source, SUBJECT_A), 5); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_enroll_with_every_attestor( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2, SIG_A_3], 1, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + + // A fresh claim later refreshes the expiry. + timestamp::fast_forward_seconds(100); + enroll( + user, source, TEMPLATE, CLAIM_A_REFRESH, vector[SIG_A_REFRESH_1, SIG_A_REFRESH_2], 1, vector[] + ); + assert!(attestation::expires_at(source, SUBJECT_A) == now_seconds() + TTL, 1); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x30010, location = Self)] + fun test_replayed_claim_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(!is_claim_consumed(source, CLAIM_A), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + assert!(is_claim_consumed(source, CLAIM_A), 1); + assert!(!verify_claim(source, TEMPLATE, SUBJECT_A, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1), 2); + + // Replaying the same signed claim later would push the expiry out forever. + timestamp::fast_forward_seconds(100); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x30010, location = Self)] + fun test_replay_cannot_undo_revocation( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + attestation::register_issuer(deployer, source, address_of(deployer), vector[]); + attestation::revoke_batch(deployer, source, vector[SUBJECT_A], 1); + assert!(!attestation::is_verified(source, SUBJECT_A), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_enroll_with_signed_nullifier( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll( + user, + source, + TEMPLATE, + CLAIM_A_NULLIFIED, + vector[SIG_A_NULLIFIED_1, SIG_A_NULLIFIED_2], + 1, + NULLIFIER + ); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10008, location = Self)] + fun test_nullifier_not_in_claim_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + // CLAIM_A carries no nullifier, so a caller-chosen one is not vouched for by anybody. + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, NULLIFIER); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_bump_of_cohort_zero_kills_zktls_facts( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + + attestation::bump_issuer_epoch(deployer, source, 0); + assert!(!attestation::is_verified(source, SUBJECT_A), 1); + assert!(attestation::issuer_epoch_of(source, 0) == 1, 2); + + // New enrollments land in the new cohort epoch and are usable. + enroll( + user, source, TEMPLATE, CLAIM_A_REFRESH, vector[SIG_A_REFRESH_1, SIG_A_REFRESH_2], 1, vector[] + ); + assert!(attestation::is_verified(source, SUBJECT_A), 3); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10004, location = Self)] + fun test_below_threshold_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(!verify_claim(source, TEMPLATE, SUBJECT_A, CLAIM_A, vector[SIG_A_1], 1), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10004, location = Self)] + fun test_no_signatures_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10004, location = Self)] + fun test_too_many_signatures_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + let signatures = vector[]; + let index = 0; + while (index <= MAX_SIGNATURES) { + signatures.push_back(SIG_A_1); + index += 1; + }; + enroll(user, source, TEMPLATE, CLAIM_A, signatures, 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10005, location = Self)] + fun test_duplicate_signer_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_1], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10006, location = Self)] + fun test_unknown_attestor_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_4], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10006, location = Self)] + fun test_signature_over_another_claim_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_B_1, SIG_B_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x60007, location = Self)] + fun test_unknown_attestor_epoch_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 2, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_previous_epoch_still_verifies_after_rotation( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 100); + assert!(is_epoch_accepted(source, 1), 0); + assert!(is_epoch_accepted(source, 2), 1); + timestamp::fast_forward_seconds(99); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 2); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x3000f, location = Self)] + fun test_previous_epoch_retired_after_grace( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 100); + timestamp::fast_forward_seconds(100); + assert!(!is_epoch_accepted(source, 1), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x3000f, location = Self)] + fun test_zero_grace_retires_previous_epoch_at_once( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + // Rotating away a compromised set with no grace cuts it off immediately. + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x3000f, location = Self)] + fun test_two_epochs_back_is_retired_despite_grace( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 1000); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 1000); + assert!(!is_epoch_accepted(source, 1), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_max_epoch_is_not_accepted(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + assert!(!is_epoch_accepted(source, MAX_U64), 0); + assert!(!is_epoch_accepted(source, 0), 1); + } + + #[test(framework = @0x1, deployer = @0x123)] + fun test_huge_grace_does_not_overflow(framework: &signer, deployer: &signer) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + timestamp::fast_forward_seconds(10); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, MAX_U64); + assert!(is_epoch_accepted(source, 1), 0); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10006, location = Self)] + fun test_old_attestors_rejected_under_new_epoch( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1], 2, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_new_epoch_verifies( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + set_attestor_set(deployer, source, vector[ATTESTOR_4], 1, 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_4], 2, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x60002, location = Self)] + fun test_unknown_template_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll( + user, + source, + OTHER_TEMPLATE, + CLAIM_A_OTHER_TEMPLATE, + vector[SIG_OTHER_TEMPLATE_1, SIG_OTHER_TEMPLATE_2], + 1, + vector[] + ); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x30003, location = Self)] + fun test_revoked_template_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + revoke_template(deployer, source, TEMPLATE); + assert!(!verify_claim(source, TEMPLATE, SUBJECT_A, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1), 0); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_revoking_template_keeps_recorded_facts( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + revoke_template(deployer, source, TEMPLATE); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + } + + #[test(framework = @0x1, deployer = @0x123, relayer = @0xb22)] + #[expected_failure(abort_code = 0x10008, location = Self)] + fun test_claim_for_another_subject_fails( + framework: &signer, deployer: &signer, relayer: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + // A valid attestation for A is not an attestation for whoever relays it. + enroll(relayer, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x10008, location = Self)] + fun test_claim_under_another_template_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + register_template(deployer, source, OTHER_TEMPLATE, LEVEL_ZKTLS + 1, TTL); + // CLAIM_A names TEMPLATE, so it cannot be redeemed under a more generous template. + enroll(user, source, OTHER_TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x6000c, location = Self)] + fun test_enroll_uninitialized_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let deployer_address = address_of(deployer); + create_account_for_test(deployer_address); + let source = attestation::get_next_source_address(deployer_address); + attestation::create(deployer, vector[deployer_address], vector[], vector[], vector[], vector[]); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x30007, location = aptos_framework::attestation)] + fun test_enroll_on_paused_source_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + attestation::pause(deployer, source); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + #[expected_failure(abort_code = 0x30013, location = aptos_framework::attestation)] + fun test_enroll_denied_subject_fails( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + attestation::deny(deployer, source, SUBJECT_A, 1, now_seconds()); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11, other = @0xb22)] + #[expected_failure(abort_code = 0x30014, location = aptos_framework::attestation)] + fun test_nullifier_binds_one_subject( + framework: &signer, deployer: &signer, user: &signer, other: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + enroll( + user, + source, + TEMPLATE, + CLAIM_A_NULLIFIED, + vector[SIG_A_NULLIFIED_1, SIG_A_NULLIFIED_2], + 1, + NULLIFIER + ); + enroll( + other, + source, + TEMPLATE, + CLAIM_B_NULLIFIED, + vector[SIG_B_NULLIFIED_1, SIG_B_NULLIFIED_2], + 1, + NULLIFIER + ); + } + + #[test(framework = @0x1, deployer = @0x123, user = @0xa11)] + fun test_enrolled_fact_survives_floor_raise_then_dies_on_next( + framework: &signer, deployer: &signer, user: &signer + ) acquires Verifier { + setup(framework); + let source = new_verifier(deployer); + // A floor raised before enrollment must not make new zkTLS facts unusable. + attestation::set_floor_epoch(deployer, source, 1); + enroll(user, source, TEMPLATE, CLAIM_A, vector[SIG_A_1, SIG_A_2], 1, vector[]); + assert!(attestation::is_verified(source, SUBJECT_A), 0); + // Raising the floor again invalidates it, like every other fact. + attestation::set_floor_epoch(deployer, source, 2); + assert!(!attestation::is_verified(source, SUBJECT_A), 1); + } +} diff --git a/aptos-move/framework/aptos-framework/sources/zktls.spec.move b/aptos-move/framework/aptos-framework/sources/zktls.spec.move new file mode 100644 index 00000000000..45c222a724d --- /dev/null +++ b/aptos-move/framework/aptos-framework/sources/zktls.spec.move @@ -0,0 +1,217 @@ +spec aptos_framework::zktls { + /// + /// No.: 1 + /// Requirement: Only an admin of the source can configure its verifier: create it, register attestor sets, and + /// register or revoke templates. + /// Criticality: Critical + /// Implementation: initialize, set_attestor_set, register_template and revoke_template all assert + /// attestation::is_admin before touching the Verifier. + /// Enforcement: Formally verified via [high-level-req-1](set_attestor_set) and audited in unit tests for the + /// remaining entry functions. + /// + /// No.: 2 + /// Requirement: An attestor set has a threshold of at least one and at most its size, and each rotation moves to + /// a new epoch that is one higher than the last. + /// Criticality: High + /// Implementation: set_attestor_set asserts 1 <= required <= count, increments current_epoch and adds the set + /// under the new epoch. + /// Enforcement: Formally verified via [high-level-req-2](set_attestor_set). + /// + /// No.: 3 + /// Requirement: Only the current attestor epoch verifies, plus the immediately previous one until the grace + /// deadline set at rotation. A rotated-away set stops verifying once the deadline passes, and an older one stops + /// immediately. + /// Criticality: Critical + /// Implementation: epoch_accepted accepts current_epoch, or current_epoch - 1 while now < previous_deadline_secs. + /// verify_claim_internal asserts it. + /// Enforcement: Formally verified via [high-level-req-3.1](epoch_accepted) and [high-level-req-3.2](enroll). + /// + /// No.: 4 + /// Requirement: A signed claim is single use. Once a claim has been used to enroll it can never enroll again, so it + /// can neither refresh an expiry indefinitely nor undo a revocation. + /// Criticality: Critical + /// Implementation: enroll records keccak256(claim) in Verifier.consumed and aborts if it is already present. + /// verify_claim returns false for a consumed claim. + /// Enforcement: Formally verified via [high-level-req-4.1](enroll) and [high-level-req-4.2](verify_claim). + /// + /// No.: 5 + /// Requirement: A claim under a revoked template never enrolls, and verify_claim never reports it as valid. + /// Criticality: High + /// Implementation: verify_claim_internal returns the template's active flag; enroll aborts with ETEMPLATE_REVOKED + /// and verify_claim folds it into its result. + /// Enforcement: Formally verified via [high-level-req-5.1](enroll) and [high-level-req-5.2](verify_claim). + /// + /// No.: 6 + /// Requirement: A claim is bound to the subject submitting it and to the template it is redeemed under, and a + /// non-empty nullifier must be carried by the signed claim. A valid attestation for one person is not an + /// attestation for whoever relays it. + /// Criticality: Critical + /// Implementation: claim_binds checks that the lowercase hex of bcs(subject) and of template_id both appear in the + /// claim, and enroll checks the same for the nullifier. + /// Enforcement: Audited in unit tests (test_claim_for_another_subject_fails, test_claim_under_another_template_fails, + /// test_nullifier_not_in_claim_fails). The byte-search loops are not amenable to the prover. + /// + /// No.: 7 + /// Requirement: Distinct attestors, each a member of the epoch's set, must sign, and at least threshold of them. + /// Criticality: Critical + /// Implementation: verify_claim_internal recovers each signer with secp256k1::ecdsa_recover, aborts on a signer + /// outside the set or a repeated signer, and compares the distinct count with the threshold. + /// Enforcement: Audited in unit tests with fixed secp256k1 vectors. The recovery loop runs inside for_each_ref, + /// which the prover havocs. + /// + spec module { + pragma verify = true; + pragma aborts_if_is_strict = false; + } + + spec fun spec_now(): u64 { + aptos_framework::timestamp::spec_now_seconds() + } + + spec fun spec_epoch_accepted(verifier: Verifier, epoch: u64): bool { + epoch != 0 + && (epoch == verifier.current_epoch + || (epoch < verifier.current_epoch + && verifier.current_epoch - epoch == 1 + && spec_now() < verifier.previous_deadline_secs)) + } + + spec is_initialized(source: address): bool { + aborts_if false; + ensures result == exists(source); + } + + spec current_epoch(source: address): u64 { + aborts_if !exists(source); + ensures result == global(source).current_epoch; + } + + spec threshold(source: address, epoch: u64): u64 { + aborts_if !exists(source); + aborts_if !table::spec_contains(global(source).sets, epoch); + ensures result == table::spec_get(global(source).sets, epoch).threshold; + } + + spec is_template_active(source: address, template_id: vector): bool { + aborts_if false; + let templates = global(source).templates; + ensures result == (exists(source) + && table::spec_contains(templates, template_id) + && table::spec_get(templates, template_id).active); + } + + spec is_claim_consumed(source: address, claim: vector): bool { + aborts_if false; + ensures result == (exists(source) + && table::spec_contains( + global(source).consumed, aptos_std::aptos_hash::spec_keccak256(claim) + )); + } + + spec epoch_accepted(verifier: &Verifier, epoch: u64): bool { + pragma aborts_if_is_partial; + /// [high-level-req-3.1] + ensures result == spec_epoch_accepted(verifier, epoch); + } + + spec is_epoch_accepted(source: address, epoch: u64): bool { + pragma aborts_if_is_partial; + ensures !exists(source) ==> !result; + ensures exists(source) ==> result == spec_epoch_accepted(global(source), epoch); + } + + spec set_attestor_set( + admin: &signer, + source: address, + attestor_addresses: vector>, + required: u64, + previous_grace_secs: u64 + ) { + // Attestor length and duplicate rejection happen inside a for_each_ref loop, which the + // prover havocs; those paths are covered by unit tests. + pragma aborts_if_is_partial; + /// [high-level-req-1] + aborts_if !aptos_framework::attestation::spec_is_source(source); + aborts_if !contains(global(source).admins, address_of(admin)); + aborts_if !exists(source); + /// [high-level-req-2] + aborts_if required < 1 || required > len(attestor_addresses); + let post verifier = global(source); + ensures verifier.current_epoch == old(global(source).current_epoch) + 1; + ensures table::spec_contains(verifier.sets, verifier.current_epoch); + ensures table::spec_get(verifier.sets, verifier.current_epoch).threshold == required; + ensures table::spec_get(verifier.sets, verifier.current_epoch).attestors == attestor_addresses; + ensures verifier.previous_deadline_secs >= spec_now(); + ensures verifier.consumed == old(global(source).consumed); + ensures verifier.templates == old(global(source).templates); + } + + spec register_template( + admin: &signer, + source: address, + template_id: vector, + grants_level: u8, + ttl_secs: u64 + ) { + pragma aborts_if_is_partial; + aborts_if !exists(source); + let post template = table::spec_get(global(source).templates, template_id); + ensures template.active; + ensures template.grants_level == grants_level; + ensures template.ttl_secs == ttl_secs; + } + + spec revoke_template(admin: &signer, source: address, template_id: vector) { + pragma aborts_if_is_partial; + aborts_if !exists(source); + aborts_if !table::spec_contains(global(source).templates, template_id); + ensures !table::spec_get(global(source).templates, template_id).active; + ensures global(source).consumed == old(global(source).consumed); + } + + spec verify_claim( + source: address, + template_id: vector, + subject: address, + claim: vector, + signatures: vector>, + attestor_epoch: u64 + ): bool { + pragma aborts_if_is_partial; + aborts_if !exists(source); + let verifier = global(source); + /// [high-level-req-4.2] + ensures result ==> !table::spec_contains(verifier.consumed, aptos_std::aptos_hash::spec_keccak256(claim)); + /// [high-level-req-5.2] + ensures result ==> table::spec_get(verifier.templates, template_id).active; + ensures result ==> spec_epoch_accepted(verifier, attestor_epoch); + } + + spec enroll( + user: &signer, + source: address, + template_id: vector, + claim: vector, + signatures: vector>, + attestor_epoch: u64, + nullifier: vector + ) { + // The signature-recovery and byte-search loops are havocked by the prover, so only the + // listed abort conditions are claimed, not that they are exhaustive. + pragma aborts_if_is_partial; + let verifier = global(source); + let digest = aptos_std::aptos_hash::spec_keccak256(claim); + aborts_if !exists(source); + aborts_if !table::spec_contains(verifier.templates, template_id); + aborts_if !table::spec_contains(verifier.sets, attestor_epoch); + /// [high-level-req-3.2] + aborts_if !spec_epoch_accepted(verifier, attestor_epoch); + /// [high-level-req-5.1] + aborts_if !table::spec_get(verifier.templates, template_id).active; + /// [high-level-req-4.1] + aborts_if table::spec_contains(verifier.consumed, digest); + ensures table::spec_contains(global(source).consumed, digest); + ensures global(source).sets == old(global(source).sets); + ensures global(source).templates == old(global(source).templates); + } +} diff --git a/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs b/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs index 2cff97836bb..e07ce46a2fb 100644 --- a/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs +++ b/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs @@ -446,6 +446,359 @@ pub enum EntryFunctionCall { _bridge_transfer_id: Vec, }, + /// Add admins. + AttestationAddAdmins { + source: AccountAddress, + new_admins: Vec, + }, + + AttestationAddGuardians { + source: AccountAddress, + new_guardians: Vec, + }, + + AttestationAddIssuers { + source: AccountAddress, + new_issuers: Vec, + }, + + AttestationAddRemovers { + source: AccountAddress, + new_removers: Vec, + }, + + AttestationAddSentinels { + source: AccountAddress, + new_sentinels: Vec, + }, + + /// Invalidate every fact an issuer has written, in one write. This is the remedy for a + /// compromised issuer key and it is O(1) in the size of the cohort. Issuer id 0 bumps the + /// zkTLS enrollment cohort, which has no registered issuer. The new epoch is one above the + /// issuer's effective epoch, so a bump always takes effect even below a raised floor. + AttestationBumpIssuerEpoch { + source: AccountAddress, + issuer_id: u16, + }, + + /// Create a new attestation source. The deployer only authorizes resource-account creation and + /// pays gas; it gains no role unless listed in the role arguments. + /// + /// @param deployer Signer that authorizes resource-account creation and pays gas. + /// @param admins Addresses allowed to configure. At least one, no duplicates, not the source. + /// @param issuers Addresses allowed to write facts. May be empty and filled in later. + /// @param sentinels Addresses allowed to add denials only. May be empty. + /// @param removers Addresses allowed to remove denials only. May be empty. + /// @param guardians Addresses allowed to pause writes. May be empty. + /// @abort If a list has duplicates, names the source itself, or there is no admin. + AttestationCreate { + admins: Vec, + issuers: Vec, + sentinels: Vec, + removers: Vec, + guardians: Vec, + }, + + /// Exclude a subject. Takes effect at `effective_at_secs`, which may be in the future so a + /// denial can be announced before it bites. + AttestationDeny { + source: AccountAddress, + subject: AccountAddress, + reason: u16, + effective_at_secs: u64, + }, + + /// Exclude many subjects at once, with a shared reason and immediate effect. + AttestationDenyBatch { + source: AccountAddress, + subjects: Vec, + reason: u16, + }, + + /// Record or refresh facts for many subjects at once. + /// + /// @param issuer A registered, active issuer of the source. + /// @param source The source address. + /// @param subjects Subjects to write. + /// @param levels Tier per subject, same length as `subjects`. + /// @param expires_at_secs Expiry per subject, same length as `subjects`. + /// @param reason Reason code recorded in each subject's history. + /// @abort If paused, the caller is not an issuer, the lengths differ, the batch is too large, + /// or any subject is denied. + AttestationIssueBatch { + source: AccountAddress, + subjects: Vec, + levels: Vec, + expires_at_secs: Vec, + reason: u16, + }, + + /// Pause writes. Never changes the answer `is_verified` gives. + AttestationPause { + source: AccountAddress, + }, + + /// Publish a set commitment for the next epoch. Rotation invalidates outstanding proofs, so + /// publish on a fixed low-frequency cadence: it is a privacy measure, because cohort timing + /// leaks, and a throughput one, because every gated transaction reads this slot. + AttestationPublishRoot { + source: AccountAddress, + digest: Vec, + leaf_count: u64, + }, + + /// Record a fact from an attestation the issuer signed off chain. The caller need not be the + /// subject or the issuer. + /// + /// @param source The source address. + /// @param subject Subject the attestation is about. + /// @param issuer_id Issuer that signed. + /// @param issuer_epoch Must equal the issuer's current epoch, so an attestation signed before + /// a compromise bump is refused and one signed for a future epoch is too. + /// @param nullifier 32 bytes binding one real-world identity to one subject, or empty to skip. + /// @param signature 64-byte ed25519 signature over `attestation_message`. + /// @abort If paused, the epoch is stale, the signature fails, a newer attestation is already + /// recorded, the nullifier is bound elsewhere, or the subject is denied. + AttestationRedeemAttestation { + source: AccountAddress, + subject: AccountAddress, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: Vec, + signature: Vec, + }, + + /// Register an issuer and assign it a stable id. The public key is used only by the + /// permissionless relay path, and may be empty for an issuer that only writes directly. + /// + /// @param admin An admin of the source. + /// @param source The source address. + /// @param issuer Address to register. + /// @param pubkey 32-byte ed25519 public key, or empty. + /// @abort If the issuer is already registered or the key length is wrong. + AttestationRegisterIssuer { + source: AccountAddress, + issuer: AccountAddress, + pubkey: Vec, + }, + + /// Remove admins. A source may never be left with zero admins. + AttestationRemoveAdmins { + source: AccountAddress, + old_admins: Vec, + }, + + AttestationRemoveAttribute { + source: AccountAddress, + subject: AccountAddress, + key: u16, + }, + + AttestationRemoveGuardians { + source: AccountAddress, + old_guardians: Vec, + }, + + AttestationRemoveIssuers { + source: AccountAddress, + old_issuers: Vec, + }, + + AttestationRemoveRemovers { + source: AccountAddress, + old_removers: Vec, + }, + + AttestationRemoveSentinels { + source: AccountAddress, + old_sentinels: Vec, + }, + + /// Move many subjects to STATE_REVOKED. A subject that is already revoked is skipped, so one + /// stale entry cannot brick a batch. + AttestationRevokeBatch { + source: AccountAddress, + subjects: Vec, + reason: u16, + }, + + /// Replace an issuer's signing key. Facts already written stay valid; use + /// `bump_issuer_epoch` to invalidate them. + AttestationRotateIssuerKey { + source: AccountAddress, + issuer: AccountAddress, + new_pubkey: Vec, + }, + + /// Set an attribute on a subject. Every attribute written is public forever, so a source that + /// writes jurisdiction data has made a disclosure decision on behalf of its subjects. + AttestationSetAttribute { + source: AccountAddress, + subject: AccountAddress, + key: u16, + value: Vec, + }, + + /// Invalidate every fact written below the given epoch, across all issuers including the zkTLS + /// cohort. Strictly increasing, so lowering the floor can never resurrect a fact. + AttestationSetFloorEpoch { + source: AccountAddress, + epoch: u64, + }, + + /// Temporarily withhold an active subject's fact, reversibly. + AttestationSuspend { + source: AccountAddress, + subject: AccountAddress, + reason: u16, + }, + + /// Remove an exclusion. Deliberately a different role from `deny`. + AttestationUndeny { + source: AccountAddress, + subject: AccountAddress, + }, + + AttestationUnpause { + source: AccountAddress, + }, + + /// Reverse a suspension. Only a suspended record can be reactivated: a revoked one needs + /// re-issuance, and a denied subject cannot be reactivated at all. The record keeps the issuer + /// and epoch it was issued under, so a fact killed by an epoch bump stays dead. + AttestationUnsuspend { + source: AccountAddress, + subject: AccountAddress, + reason: u16, + }, + + /// Release the storage held by nonces that can no longer be replayed. Permissionless, because + /// it is pure cleanup and nobody has a reason to withhold it other than the fee, which is the + /// caller's to pay. + AttestationAuthorizationPruneNonces { + policy: AccountAddress, + nonces: Vec>, + }, + + /// Push the staged body live. Permissionless once its time has arrived, so the business does + /// not have to be online at the moment its own rule change takes effect. + AttestationPolicyActivatePending { + policy: AccountAddress, + }, + + AttestationPolicyAddAdmins { + policy: AccountAddress, + new_admins: Vec, + }, + + AttestationPolicyAddGuardians { + policy: AccountAddress, + new_guardians: Vec, + }, + + /// Discard a staged body that has not activated yet. + AttestationPolicyCancelPending { + policy: AccountAddress, + }, + + /// Stop demanding authorization for an action. + AttestationPolicyClearStepUp { + policy: AccountAddress, + action: u8, + }, + + /// Create a new policy. The deployer only authorizes resource-account creation and pays gas; + /// it gains no role unless listed. The body starts empty, which denies everything until rules + /// are staged and activated. + /// + /// @param deployer Signer that authorizes resource-account creation and pays gas. + /// @param admins Addresses allowed to stage rules. At least one, no duplicates. + /// @param guardians Addresses allowed to pause evaluation. May be empty. + /// @abort If a list has duplicates, names the policy itself, or there is no admin. + AttestationPolicyCreate { + admins: Vec, + guardians: Vec, + }, + + /// Deny everything until unpaused. Denies loudly with REASON_POLICY_PAUSED rather than + /// silently allowing. + AttestationPolicyPause { + policy: AccountAddress, + }, + + AttestationPolicyRemoveAdmins { + policy: AccountAddress, + old_admins: Vec, + }, + + AttestationPolicyRemoveGuardians { + policy: AccountAddress, + old_guardians: Vec, + }, + + /// Set the key that signs authorizations for this policy, and the longest window it may + /// issue. A window of 0 with an empty key disables the step-up path entirely. + AttestationPolicySetAuthorizer { + policy: AccountAddress, + pubkey: Vec, + max_ttl_secs: u64, + }, + + /// Set the amount above which an action needs a fresh authorization. Absent by default, so a + /// liveness dependency is never enabled by accident. + AttestationPolicySetStepUp { + policy: AccountAddress, + action: u8, + threshold: u64, + }, + + /// Stage attribute predicates onto the pending body. Call `stage_body` first. + /// + /// @param sources Source whose attribute each predicate reads. + /// @param keys Attribute key per predicate. + /// @param ops One of OP_IN, OP_NOT_IN, OP_EQ, OP_GTE. + /// @param values Candidate values per predicate. OP_EQ and OP_GTE take exactly one. + AttestationPolicyStageAttrRules { + policy: AccountAddress, + sources: Vec, + keys: Vec, + ops: Vec, + values: Vec>>, + }, + + /// Stage a new body, to take effect at `effective_at_secs`. Staging rather than applying + /// immediately is what keeps a rule change from breaking a transaction already in flight. + /// + /// @param admin An admin of the policy. + /// @param policy The policy address. + /// @param require_any_sources Sources of which at least one must vouch. May be empty. + /// @param require_any_levels Minimum level per entry, same length as require_any_sources. + /// @param require_all_sources Sources that must all vouch. May be empty. + /// @param require_all_levels Minimum level per entry, same length as require_all_sources. + /// @param deny_any Sources whose denial denies. May be empty. + /// @param chain_deny Optional chain-wide denial source: empty for none, or exactly one address. + /// A vector rather than an `Option` because entry functions cannot take `Option` arguments. + /// @param effective_at_secs When the body becomes active. + /// @abort If the lengths differ, a list is over MAX_SOURCES, chain_deny names more than one + /// source, or a named source does not exist. + AttestationPolicyStageBody { + policy: AccountAddress, + require_any_sources: Vec, + require_any_levels: Vec, + require_all_sources: Vec, + require_all_levels: Vec, + deny_any: Vec, + chain_deny: Vec, + effective_at_secs: u64, + }, + + AttestationPolicyUnpause { + policy: AccountAddress, + }, + /// Same as `publish_package` but as an entry function which can be called as a transaction. Because /// of current restrictions for txn parameters, the metadata needs to be passed in serialized form. CodePublishPackageTxn { @@ -525,6 +878,12 @@ pub enum EntryFunctionCall { pool_address: AccountAddress, }, + /// Enable partial governance voting on a delegation pool if it has not already been initialized. + /// This is intended for idempotent migration scripts over existing delegation pools. + DelegationPoolEnablePartialGovernanceVotingIfNeeded { + pool_address: AccountAddress, + }, + /// Evict a delegator that is not allowlisted by unlocking their entire stake. DelegationPoolEvictDelegator { delegator_address: AccountAddress, @@ -1367,6 +1726,68 @@ pub enum EntryFunctionCall { VestingVestMany { contract_addresses: Vec, }, + + /// Submit a verified claim about yourself. No issuer key is involved on this path: the trust + /// root is the attestor set plus the provider's TLS certificate, not an operator holding a key. + /// + /// @param user The subject. Must be the address the claim names. + /// @param source The source to record the fact in. + /// @param template_id Registered, active template the claim was produced under. + /// @param claim Canonically serialized claim. Must contain the subject and the template id. + /// @param signatures One 65-byte recoverable ECDSA signature per attestor. + /// @param attestor_epoch Epoch whose attestor set signed. + /// @param nullifier 32 bytes binding one identity to one subject, or empty to skip. When set, + /// its lowercase hex must appear in the signed claim, so the attestors vouch for it. + /// @abort If the claim does not bind the subject (or the nullifier), a signer is unknown or + /// repeated, the attestor epoch is retired, the template is revoked, the claim was + /// already used, or fewer than the threshold signed. + ZktlsEnroll { + source: AccountAddress, + template_id: Vec, + claim: Vec, + signatures: Vec>, + attestor_epoch: u64, + nullifier: Vec, + }, + + /// Create the verifier for a source. Requires an admin of that source, and obtains the + /// source's resource-account signer through `attestation`'s friend accessor. + ZktlsInitialize { + source: AccountAddress, + }, + + /// Allow a provider template and say what a claim under it grants. + ZktlsRegisterTemplate { + source: AccountAddress, + template_id: Vec, + grants_level: u8, + ttl_secs: u64, + }, + + /// Stop accepting new claims under a template. Facts already recorded are untouched; use + /// `attestation::bump_issuer_epoch` with issuer id 0, which invalidates every zkTLS + /// enrollment in the source, or a denial per subject for those. + ZktlsRevokeTemplate { + source: AccountAddress, + template_id: Vec, + }, + + /// Register a new attestor set under the next epoch. The set it replaces keeps verifying for + /// `previous_grace_secs`, so a claim signed moments before the rotation still verifies; every + /// older set stops verifying immediately. Rotating away a compromised set with a zero grace + /// window cuts it off at once. + /// + /// @param admin An admin of the source. + /// @param source The source address. + /// @param attestor_addresses 20-byte Ethereum-style addresses, no duplicates. + /// @param required How many distinct attestors must sign. At least 1, at most the count. + /// @param previous_grace_secs How long the replaced set keeps verifying. 0 for no grace. + ZktlsSetAttestorSet { + source: AccountAddress, + attestor_addresses: Vec>, + required: u64, + previous_grace_secs: u64, + }, } impl EntryFunctionCall { @@ -1607,6 +2028,205 @@ impl EntryFunctionCall { AtomicBridgeInitiatorRefundBridgeTransfer { _bridge_transfer_id, } => atomic_bridge_initiator_refund_bridge_transfer(_bridge_transfer_id), + AttestationAddAdmins { source, new_admins } => { + attestation_add_admins(source, new_admins) + }, + AttestationAddGuardians { + source, + new_guardians, + } => attestation_add_guardians(source, new_guardians), + AttestationAddIssuers { + source, + new_issuers, + } => attestation_add_issuers(source, new_issuers), + AttestationAddRemovers { + source, + new_removers, + } => attestation_add_removers(source, new_removers), + AttestationAddSentinels { + source, + new_sentinels, + } => attestation_add_sentinels(source, new_sentinels), + AttestationBumpIssuerEpoch { source, issuer_id } => { + attestation_bump_issuer_epoch(source, issuer_id) + }, + AttestationCreate { + admins, + issuers, + sentinels, + removers, + guardians, + } => attestation_create(admins, issuers, sentinels, removers, guardians), + AttestationDeny { + source, + subject, + reason, + effective_at_secs, + } => attestation_deny(source, subject, reason, effective_at_secs), + AttestationDenyBatch { + source, + subjects, + reason, + } => attestation_deny_batch(source, subjects, reason), + AttestationIssueBatch { + source, + subjects, + levels, + expires_at_secs, + reason, + } => attestation_issue_batch(source, subjects, levels, expires_at_secs, reason), + AttestationPause { source } => attestation_pause(source), + AttestationPublishRoot { + source, + digest, + leaf_count, + } => attestation_publish_root(source, digest, leaf_count), + AttestationRedeemAttestation { + source, + subject, + issuer_id, + issuer_epoch, + level, + expires_at_secs, + issued_at_secs, + nullifier, + signature, + } => attestation_redeem_attestation( + source, + subject, + issuer_id, + issuer_epoch, + level, + expires_at_secs, + issued_at_secs, + nullifier, + signature, + ), + AttestationRegisterIssuer { + source, + issuer, + pubkey, + } => attestation_register_issuer(source, issuer, pubkey), + AttestationRemoveAdmins { source, old_admins } => { + attestation_remove_admins(source, old_admins) + }, + AttestationRemoveAttribute { + source, + subject, + key, + } => attestation_remove_attribute(source, subject, key), + AttestationRemoveGuardians { + source, + old_guardians, + } => attestation_remove_guardians(source, old_guardians), + AttestationRemoveIssuers { + source, + old_issuers, + } => attestation_remove_issuers(source, old_issuers), + AttestationRemoveRemovers { + source, + old_removers, + } => attestation_remove_removers(source, old_removers), + AttestationRemoveSentinels { + source, + old_sentinels, + } => attestation_remove_sentinels(source, old_sentinels), + AttestationRevokeBatch { + source, + subjects, + reason, + } => attestation_revoke_batch(source, subjects, reason), + AttestationRotateIssuerKey { + source, + issuer, + new_pubkey, + } => attestation_rotate_issuer_key(source, issuer, new_pubkey), + AttestationSetAttribute { + source, + subject, + key, + value, + } => attestation_set_attribute(source, subject, key, value), + AttestationSetFloorEpoch { source, epoch } => { + attestation_set_floor_epoch(source, epoch) + }, + AttestationSuspend { + source, + subject, + reason, + } => attestation_suspend(source, subject, reason), + AttestationUndeny { source, subject } => attestation_undeny(source, subject), + AttestationUnpause { source } => attestation_unpause(source), + AttestationUnsuspend { + source, + subject, + reason, + } => attestation_unsuspend(source, subject, reason), + AttestationAuthorizationPruneNonces { policy, nonces } => { + attestation_authorization_prune_nonces(policy, nonces) + }, + AttestationPolicyActivatePending { policy } => { + attestation_policy_activate_pending(policy) + }, + AttestationPolicyAddAdmins { policy, new_admins } => { + attestation_policy_add_admins(policy, new_admins) + }, + AttestationPolicyAddGuardians { + policy, + new_guardians, + } => attestation_policy_add_guardians(policy, new_guardians), + AttestationPolicyCancelPending { policy } => attestation_policy_cancel_pending(policy), + AttestationPolicyClearStepUp { policy, action } => { + attestation_policy_clear_step_up(policy, action) + }, + AttestationPolicyCreate { admins, guardians } => { + attestation_policy_create(admins, guardians) + }, + AttestationPolicyPause { policy } => attestation_policy_pause(policy), + AttestationPolicyRemoveAdmins { policy, old_admins } => { + attestation_policy_remove_admins(policy, old_admins) + }, + AttestationPolicyRemoveGuardians { + policy, + old_guardians, + } => attestation_policy_remove_guardians(policy, old_guardians), + AttestationPolicySetAuthorizer { + policy, + pubkey, + max_ttl_secs, + } => attestation_policy_set_authorizer(policy, pubkey, max_ttl_secs), + AttestationPolicySetStepUp { + policy, + action, + threshold, + } => attestation_policy_set_step_up(policy, action, threshold), + AttestationPolicyStageAttrRules { + policy, + sources, + keys, + ops, + values, + } => attestation_policy_stage_attr_rules(policy, sources, keys, ops, values), + AttestationPolicyStageBody { + policy, + require_any_sources, + require_any_levels, + require_all_sources, + require_all_levels, + deny_any, + chain_deny, + effective_at_secs, + } => attestation_policy_stage_body( + policy, + require_any_sources, + require_any_levels, + require_all_sources, + require_all_levels, + deny_any, + chain_deny, + effective_at_secs, + ), + AttestationPolicyUnpause { policy } => attestation_policy_unpause(policy), CodePublishPackageTxn { metadata_serialized, code, @@ -1657,6 +2277,9 @@ impl EntryFunctionCall { DelegationPoolEnablePartialGovernanceVoting { pool_address } => { delegation_pool_enable_partial_governance_voting(pool_address) }, + DelegationPoolEnablePartialGovernanceVotingIfNeeded { pool_address } => { + delegation_pool_enable_partial_governance_voting_if_needed(pool_address) + }, DelegationPoolEvictDelegator { delegator_address } => { delegation_pool_evict_delegator(delegator_address) }, @@ -2140,6 +2763,38 @@ impl EntryFunctionCall { } => vesting_update_voter(contract_address, new_voter), VestingVest { contract_address } => vesting_vest(contract_address), VestingVestMany { contract_addresses } => vesting_vest_many(contract_addresses), + ZktlsEnroll { + source, + template_id, + claim, + signatures, + attestor_epoch, + nullifier, + } => zktls_enroll( + source, + template_id, + claim, + signatures, + attestor_epoch, + nullifier, + ), + ZktlsInitialize { source } => zktls_initialize(source), + ZktlsRegisterTemplate { + source, + template_id, + grants_level, + ttl_secs, + } => zktls_register_template(source, template_id, grants_level, ttl_secs), + ZktlsRevokeTemplate { + source, + template_id, + } => zktls_revoke_template(source, template_id), + ZktlsSetAttestorSet { + source, + attestor_addresses, + required, + previous_grace_secs, + } => zktls_set_attestor_set(source, attestor_addresses, required, previous_grace_secs), } } @@ -3235,11 +3890,10 @@ pub fn atomic_bridge_initiator_refund_bridge_transfer( )) } -/// Same as `publish_package` but as an entry function which can be called as a transaction. Because -/// of current restrictions for txn parameters, the metadata needs to be passed in serialized form. -pub fn code_publish_package_txn( - metadata_serialized: Vec, - code: Vec>, +/// Add admins. +pub fn attestation_add_admins( + source: AccountAddress, + new_admins: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3247,52 +3901,62 @@ pub fn code_publish_package_txn( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("code").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("publish_package_txn").to_owned(), + ident_str!("add_admins").to_owned(), vec![], vec![ - bcs::to_bytes(&metadata_serialized).unwrap(), - bcs::to_bytes(&code).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&new_admins).unwrap(), ], )) } -pub fn coin_create_coin_conversion_map() -> TransactionPayload { +pub fn attestation_add_guardians( + source: AccountAddress, + new_guardians: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("create_coin_conversion_map").to_owned(), - vec![], + ident_str!("add_guardians").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&new_guardians).unwrap(), + ], )) } -/// Create APT pairing by passing `AptosCoin`. -pub fn coin_create_pairing(coin_type: TypeTag) -> TransactionPayload { +pub fn attestation_add_issuers( + source: AccountAddress, + new_issuers: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("create_pairing").to_owned(), - vec![coin_type], + ident_str!("add_issuers").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&new_issuers).unwrap(), + ], )) } -/// Migrate to fungible store for `CoinType` if not yet. -pub fn coin_migrate_coin_store_to_fungible_store( - coin_type: TypeTag, - accounts: Vec, +pub fn attestation_add_removers( + source: AccountAddress, + new_removers: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3300,85 +3964,129 @@ pub fn coin_migrate_coin_store_to_fungible_store( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("migrate_coin_store_to_fungible_store").to_owned(), - vec![coin_type], - vec![bcs::to_bytes(&accounts).unwrap()], + ident_str!("add_removers").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&new_removers).unwrap(), + ], )) } -/// Voluntarily migrate to fungible store for `CoinType` if not yet. -pub fn coin_migrate_to_fungible_store(coin_type: TypeTag) -> TransactionPayload { +pub fn attestation_add_sentinels( + source: AccountAddress, + new_sentinels: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("migrate_to_fungible_store").to_owned(), - vec![coin_type], + ident_str!("add_sentinels").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&new_sentinels).unwrap(), + ], )) } -/// Transfers `amount` of coins `CoinType` from `from` to `to`. -pub fn coin_transfer(coin_type: TypeTag, to: AccountAddress, amount: u64) -> TransactionPayload { +/// Invalidate every fact an issuer has written, in one write. This is the remedy for a +/// compromised issuer key and it is O(1) in the size of the cohort. Issuer id 0 bumps the +/// zkTLS enrollment cohort, which has no registered issuer. The new epoch is one above the +/// issuer's effective epoch, so a bump always takes effect even below a raised floor. +pub fn attestation_bump_issuer_epoch(source: AccountAddress, issuer_id: u16) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("transfer").to_owned(), - vec![coin_type], - vec![bcs::to_bytes(&to).unwrap(), bcs::to_bytes(&amount).unwrap()], + ident_str!("bump_issuer_epoch").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&issuer_id).unwrap(), + ], )) } -/// Upgrade total supply to use a parallelizable implementation if it is -/// available. -pub fn coin_upgrade_supply(coin_type: TypeTag) -> TransactionPayload { +/// Create a new attestation source. The deployer only authorizes resource-account creation and +/// pays gas; it gains no role unless listed in the role arguments. +/// +/// @param deployer Signer that authorizes resource-account creation and pays gas. +/// @param admins Addresses allowed to configure. At least one, no duplicates, not the source. +/// @param issuers Addresses allowed to write facts. May be empty and filled in later. +/// @param sentinels Addresses allowed to add denials only. May be empty. +/// @param removers Addresses allowed to remove denials only. May be empty. +/// @param guardians Addresses allowed to pause writes. May be empty. +/// @abort If a list has duplicates, names the source itself, or there is no admin. +pub fn attestation_create( + admins: Vec, + issuers: Vec, + sentinels: Vec, + removers: Vec, + guardians: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("coin").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("upgrade_supply").to_owned(), - vec![coin_type], + ident_str!("create").to_owned(), vec![], + vec![ + bcs::to_bytes(&admins).unwrap(), + bcs::to_bytes(&issuers).unwrap(), + bcs::to_bytes(&sentinels).unwrap(), + bcs::to_bytes(&removers).unwrap(), + bcs::to_bytes(&guardians).unwrap(), + ], )) } -/// Add `amount` of coins to the delegation pool `pool_address`. -pub fn delegation_pool_add_stake(pool_address: AccountAddress, amount: u64) -> TransactionPayload { +/// Exclude a subject. Takes effect at `effective_at_secs`, which may be in the future so a +/// denial can be announced before it bites. +pub fn attestation_deny( + source: AccountAddress, + subject: AccountAddress, + reason: u16, + effective_at_secs: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("add_stake").to_owned(), + ident_str!("deny").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&reason).unwrap(), + bcs::to_bytes(&effective_at_secs).unwrap(), ], )) } -/// Allowlist a delegator as the pool owner. -pub fn delegation_pool_allowlist_delegator( - delegator_address: AccountAddress, +/// Exclude many subjects at once, with a shared reason and immediate effect. +pub fn attestation_deny_batch( + source: AccountAddress, + subjects: Vec, + reason: u16, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3386,23 +4094,34 @@ pub fn delegation_pool_allowlist_delegator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("allowlist_delegator").to_owned(), + ident_str!("deny_batch").to_owned(), vec![], - vec![bcs::to_bytes(&delegator_address).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subjects).unwrap(), + bcs::to_bytes(&reason).unwrap(), + ], )) } -/// A voter could create a governance proposal by this function. To successfully create a proposal, the voter's -/// voting power in THIS delegation pool must be not less than the minimum required voting power specified in -/// `aptos_governance.move`. -pub fn delegation_pool_create_proposal( - pool_address: AccountAddress, - execution_hash: Vec, - metadata_location: Vec, - metadata_hash: Vec, - is_multi_step_proposal: bool, +/// Record or refresh facts for many subjects at once. +/// +/// @param issuer A registered, active issuer of the source. +/// @param source The source address. +/// @param subjects Subjects to write. +/// @param levels Tier per subject, same length as `subjects`. +/// @param expires_at_secs Expiry per subject, same length as `subjects`. +/// @param reason Reason code recorded in each subject's history. +/// @abort If paused, the caller is not an issuer, the lengths differ, the batch is too large, +/// or any subject is denied. +pub fn attestation_issue_batch( + source: AccountAddress, + subjects: Vec, + levels: Vec, + expires_at_secs: Vec, + reason: u16, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3410,79 +4129,121 @@ pub fn delegation_pool_create_proposal( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("create_proposal").to_owned(), + ident_str!("issue_batch").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&execution_hash).unwrap(), - bcs::to_bytes(&metadata_location).unwrap(), - bcs::to_bytes(&metadata_hash).unwrap(), - bcs::to_bytes(&is_multi_step_proposal).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subjects).unwrap(), + bcs::to_bytes(&levels).unwrap(), + bcs::to_bytes(&expires_at_secs).unwrap(), + bcs::to_bytes(&reason).unwrap(), ], )) } -/// Allows a delegator to delegate its voting power to a voter. If this delegator already has a delegated voter, -/// this change won't take effects until the next lockup period. -pub fn delegation_pool_delegate_voting_power( - pool_address: AccountAddress, - new_voter: AccountAddress, -) -> TransactionPayload { +/// Pause writes. Never changes the answer `is_verified` gives. +pub fn attestation_pause(source: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("delegate_voting_power").to_owned(), + ident_str!("pause").to_owned(), vec![], - vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), - ], + vec![bcs::to_bytes(&source).unwrap()], )) } -/// Disable delegators allowlisting as the pool owner. The existing allowlist will be emptied. -pub fn delegation_pool_disable_delegators_allowlisting() -> TransactionPayload { +/// Publish a set commitment for the next epoch. Rotation invalidates outstanding proofs, so +/// publish on a fixed low-frequency cadence: it is a privacy measure, because cohort timing +/// leaks, and a throughput one, because every gated transaction reads this slot. +pub fn attestation_publish_root( + source: AccountAddress, + digest: Vec, + leaf_count: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("disable_delegators_allowlisting").to_owned(), - vec![], + ident_str!("publish_root").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&digest).unwrap(), + bcs::to_bytes(&leaf_count).unwrap(), + ], )) } -/// Enable delegators allowlisting as the pool owner. -pub fn delegation_pool_enable_delegators_allowlisting() -> TransactionPayload { +/// Record a fact from an attestation the issuer signed off chain. The caller need not be the +/// subject or the issuer. +/// +/// @param source The source address. +/// @param subject Subject the attestation is about. +/// @param issuer_id Issuer that signed. +/// @param issuer_epoch Must equal the issuer's current epoch, so an attestation signed before +/// a compromise bump is refused and one signed for a future epoch is too. +/// @param nullifier 32 bytes binding one real-world identity to one subject, or empty to skip. +/// @param signature 64-byte ed25519 signature over `attestation_message`. +/// @abort If paused, the epoch is stale, the signature fails, a newer attestation is already +/// recorded, the nullifier is bound elsewhere, or the subject is denied. +pub fn attestation_redeem_attestation( + source: AccountAddress, + subject: AccountAddress, + issuer_id: u16, + issuer_epoch: u64, + level: u8, + expires_at_secs: u64, + issued_at_secs: u64, + nullifier: Vec, + signature: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("enable_delegators_allowlisting").to_owned(), - vec![], + ident_str!("redeem_attestation").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&issuer_id).unwrap(), + bcs::to_bytes(&issuer_epoch).unwrap(), + bcs::to_bytes(&level).unwrap(), + bcs::to_bytes(&expires_at_secs).unwrap(), + bcs::to_bytes(&issued_at_secs).unwrap(), + bcs::to_bytes(&nullifier).unwrap(), + bcs::to_bytes(&signature).unwrap(), + ], )) } -/// Enable partial governance voting on a stake pool. The voter of this stake pool will be managed by this module. -/// The existing voter will be replaced. The function is permissionless. -pub fn delegation_pool_enable_partial_governance_voting( - pool_address: AccountAddress, +/// Register an issuer and assign it a stable id. The public key is used only by the +/// permissionless relay path, and may be empty for an issuer that only writes directly. +/// +/// @param admin An admin of the source. +/// @param source The source address. +/// @param issuer Address to register. +/// @param pubkey 32-byte ed25519 public key, or empty. +/// @abort If the issuer is already registered or the key length is wrong. +pub fn attestation_register_issuer( + source: AccountAddress, + issuer: AccountAddress, + pubkey: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3490,37 +4251,44 @@ pub fn delegation_pool_enable_partial_governance_voting( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("enable_partial_governance_voting").to_owned(), + ident_str!("register_issuer").to_owned(), vec![], - vec![bcs::to_bytes(&pool_address).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&issuer).unwrap(), + bcs::to_bytes(&pubkey).unwrap(), + ], )) } -/// Evict a delegator that is not allowlisted by unlocking their entire stake. -pub fn delegation_pool_evict_delegator(delegator_address: AccountAddress) -> TransactionPayload { +/// Remove admins. A source may never be left with zero admins. +pub fn attestation_remove_admins( + source: AccountAddress, + old_admins: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("evict_delegator").to_owned(), + ident_str!("remove_admins").to_owned(), vec![], - vec![bcs::to_bytes(&delegator_address).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&old_admins).unwrap(), + ], )) } -/// Initialize a delegation pool of custom fixed `operator_commission_percentage`. -/// A resource account is created from `owner` signer and its supplied `delegation_pool_creation_seed` -/// to host the delegation pool resource and own the underlying stake pool. -/// Ownership over setting the operator/voter is granted to `owner` who has both roles initially. -pub fn delegation_pool_initialize_delegation_pool( - operator_commission_percentage: u64, - delegation_pool_creation_seed: Vec, +pub fn attestation_remove_attribute( + source: AccountAddress, + subject: AccountAddress, + key: u16, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3528,21 +4296,21 @@ pub fn delegation_pool_initialize_delegation_pool( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("initialize_delegation_pool").to_owned(), + ident_str!("remove_attribute").to_owned(), vec![], vec![ - bcs::to_bytes(&operator_commission_percentage).unwrap(), - bcs::to_bytes(&delegation_pool_creation_seed).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&key).unwrap(), ], )) } -/// Move `amount` of coins from pending_inactive to active. -pub fn delegation_pool_reactivate_stake( - pool_address: AccountAddress, - amount: u64, +pub fn attestation_remove_guardians( + source: AccountAddress, + old_guardians: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3550,20 +4318,20 @@ pub fn delegation_pool_reactivate_stake( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("reactivate_stake").to_owned(), + ident_str!("remove_guardians").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&old_guardians).unwrap(), ], )) } -/// Remove a delegator from the allowlist as the pool owner, but do not unlock their stake. -pub fn delegation_pool_remove_delegator_from_allowlist( - delegator_address: AccountAddress, +pub fn attestation_remove_issuers( + source: AccountAddress, + old_issuers: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3571,20 +4339,20 @@ pub fn delegation_pool_remove_delegator_from_allowlist( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("remove_delegator_from_allowlist").to_owned(), + ident_str!("remove_issuers").to_owned(), vec![], - vec![bcs::to_bytes(&delegator_address).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&old_issuers).unwrap(), + ], )) } -/// Allows an operator to change its beneficiary. Any existing unpaid commission rewards will be paid to the new -/// beneficiary. To ensure payment to the current beneficiary, one should first call `synchronize_delegation_pool` -/// before switching the beneficiary. An operator can set one beneficiary for delegation pools, not a separate -/// one for each pool. -pub fn delegation_pool_set_beneficiary_for_operator( - new_beneficiary: AccountAddress, +pub fn attestation_remove_removers( + source: AccountAddress, + old_removers: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3592,50 +4360,69 @@ pub fn delegation_pool_set_beneficiary_for_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("set_beneficiary_for_operator").to_owned(), + ident_str!("remove_removers").to_owned(), vec![], - vec![bcs::to_bytes(&new_beneficiary).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&old_removers).unwrap(), + ], )) } -/// Deprecated. Use the partial governance voting flow instead. -pub fn delegation_pool_set_delegated_voter(_new_voter: AccountAddress) -> TransactionPayload { +pub fn attestation_remove_sentinels( + source: AccountAddress, + old_sentinels: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("set_delegated_voter").to_owned(), + ident_str!("remove_sentinels").to_owned(), vec![], - vec![bcs::to_bytes(&_new_voter).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&old_sentinels).unwrap(), + ], )) } -/// Allows an owner to change the operator of the underlying stake pool. -pub fn delegation_pool_set_operator(new_operator: AccountAddress) -> TransactionPayload { +/// Move many subjects to STATE_REVOKED. A subject that is already revoked is skipped, so one +/// stale entry cannot brick a batch. +pub fn attestation_revoke_batch( + source: AccountAddress, + subjects: Vec, + reason: u16, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("set_operator").to_owned(), + ident_str!("revoke_batch").to_owned(), vec![], - vec![bcs::to_bytes(&new_operator).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subjects).unwrap(), + bcs::to_bytes(&reason).unwrap(), + ], )) } -/// Synchronize delegation and stake pools: distribute yet-undetected rewards to the corresponding internal -/// shares pools, assign commission to operator and eventually prepare delegation pool for a new lockup cycle. -pub fn delegation_pool_synchronize_delegation_pool( - pool_address: AccountAddress, +/// Replace an issuer's signing key. Facts already written stay valid; use +/// `bump_issuer_epoch` to invalidate them. +pub fn attestation_rotate_issuer_key( + source: AccountAddress, + issuer: AccountAddress, + new_pubkey: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3643,62 +4430,70 @@ pub fn delegation_pool_synchronize_delegation_pool( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("synchronize_delegation_pool").to_owned(), + ident_str!("rotate_issuer_key").to_owned(), vec![], - vec![bcs::to_bytes(&pool_address).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&issuer).unwrap(), + bcs::to_bytes(&new_pubkey).unwrap(), + ], )) } -/// Unlock `amount` from the active + pending_active stake of `delegator` or -/// at most how much active stake there is on the stake pool. -pub fn delegation_pool_unlock(pool_address: AccountAddress, amount: u64) -> TransactionPayload { +/// Set an attribute on a subject. Every attribute written is public forever, so a source that +/// writes jurisdiction data has made a disclosure decision on behalf of its subjects. +pub fn attestation_set_attribute( + source: AccountAddress, + subject: AccountAddress, + key: u16, + value: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("unlock").to_owned(), + ident_str!("set_attribute").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&key).unwrap(), + bcs::to_bytes(&value).unwrap(), ], )) } -/// Allows an owner to update the commission percentage for the operator of the underlying stake pool. -pub fn delegation_pool_update_commission_percentage( - new_commission_percentage: u64, -) -> TransactionPayload { +/// Invalidate every fact written below the given epoch, across all issuers including the zkTLS +/// cohort. Strictly increasing, so lowering the floor can never resurrect a fact. +pub fn attestation_set_floor_epoch(source: AccountAddress, epoch: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("update_commission_percentage").to_owned(), + ident_str!("set_floor_epoch").to_owned(), vec![], - vec![bcs::to_bytes(&new_commission_percentage).unwrap()], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&epoch).unwrap(), + ], )) } -/// Vote on a proposal with a voter's voting power. To successfully vote, the following conditions must be met: -/// 1. The voting period of the proposal hasn't ended. -/// 2. The delegation pool's lockup period ends after the voting period of the proposal. -/// 3. The voter still has spare voting power on this proposal. -/// 4. The delegation pool never votes on the proposal before enabling partial governance voting. -pub fn delegation_pool_vote( - pool_address: AccountAddress, - proposal_id: u64, - voting_power: u64, - should_pass: bool, +/// Temporarily withhold an active subject's fact, reversibly. +pub fn attestation_suspend( + source: AccountAddress, + subject: AccountAddress, + reason: u16, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3706,132 +4501,84 @@ pub fn delegation_pool_vote( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("vote").to_owned(), + ident_str!("suspend").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&proposal_id).unwrap(), - bcs::to_bytes(&voting_power).unwrap(), - bcs::to_bytes(&should_pass).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&reason).unwrap(), ], )) } -/// Withdraw `amount` of owned inactive stake from the delegation pool at `pool_address`. -pub fn delegation_pool_withdraw(pool_address: AccountAddress, amount: u64) -> TransactionPayload { +/// Remove an exclusion. Deliberately a different role from `deny`. +pub fn attestation_undeny(source: AccountAddress, subject: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("delegation_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("withdraw").to_owned(), + ident_str!("undeny").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), ], )) } -/// Deposits from the treasury account. Treasury deposit are recorded. -/// @param treasury_account The address of the account that paid the treasury. -/// @param amount The amount of treasury to be deposited. -pub fn governed_gas_pool_deposit_treasury(amount: u64) -> TransactionPayload { +pub fn attestation_unpause(source: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("governed_gas_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("deposit_treasury").to_owned(), + ident_str!("unpause").to_owned(), vec![], - vec![bcs::to_bytes(&amount).unwrap()], + vec![bcs::to_bytes(&source).unwrap()], )) } -/// Initializes the governed gas pool extension alone. -/// @param aptos_framework The signer of the aptos_framework module. -pub fn governed_gas_pool_initialize_governed_gas_pool_extension() -> TransactionPayload { +/// Reverse a suspension. Only a suspended record can be reactivated: a revoked one needs +/// re-issuance, and a denied subject cannot be reactivated at all. The record keeps the issuer +/// and epoch it was issued under, so a fact killed by an epoch bump stays dead. +pub fn attestation_unsuspend( + source: AccountAddress, + subject: AccountAddress, + reason: u16, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("governed_gas_pool").to_owned(), + ident_str!("attestation").to_owned(), ), - ident_str!("initialize_governed_gas_pool_extension").to_owned(), - vec![], + ident_str!("unsuspend").to_owned(), vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&subject).unwrap(), + bcs::to_bytes(&reason).unwrap(), + ], )) } -/// This can be called to install or update a set of JWKs for a federated OIDC provider. This function should -/// be invoked to intially install a set of JWKs or to update a set of JWKs when a keypair is rotated. -/// -/// The `iss` parameter is the value of the `iss` claim on the JWTs that are to be verified by the JWK set. -/// `kid_vec`, `alg_vec`, `e_vec`, `n_vec` are String vectors of the JWK attributes `kid`, `alg`, `e` and `n` respectively. -/// See https://datatracker.ietf.org/doc/html/rfc7517#section-4 for more details about the JWK attributes aforementioned. -/// -/// For the example JWK set snapshot below containing 2 keys for Google found at https://www.googleapis.com/oauth2/v3/certs - -/// ```json -/// { -/// "keys": [ -/// { -/// "alg": "RS256", -/// "use": "sig", -/// "kty": "RSA", -/// "n": "wNHgGSG5B5xOEQNFPW2p_6ZxZbfPoAU5VceBUuNwQWLop0ohW0vpoZLU1tAsq_S9s5iwy27rJw4EZAOGBR9oTRq1Y6Li5pDVJfmzyRNtmWCWndR-bPqhs_dkJU7MbGwcvfLsN9FSHESFrS9sfGtUX-lZfLoGux23TKdYV9EE-H-NDASxrVFUk2GWc3rL6UEMWrMnOqV9-tghybDU3fcRdNTDuXUr9qDYmhmNegYjYu4REGjqeSyIG1tuQxYpOBH-tohtcfGY-oRTS09kgsSS9Q5BRM4qqCkGP28WhlSf4ui0-norS0gKMMI1P_ZAGEsLn9p2TlYMpewvIuhjJs1thw", -/// "kid": "d7b939771a7800c413f90051012d975981916d71", -/// "e": "AQAB" -/// }, -/// { -/// "kty": "RSA", -/// "kid": "b2620d5e7f132b52afe8875cdf3776c064249d04", -/// "alg": "RS256", -/// "n": "pi22xDdK2fz5gclIbDIGghLDYiRO56eW2GUcboeVlhbAuhuT5mlEYIevkxdPOg5n6qICePZiQSxkwcYMIZyLkZhSJ2d2M6Szx2gDtnAmee6o_tWdroKu0DjqwG8pZU693oLaIjLku3IK20lTs6-2TeH-pUYMjEqiFMhn-hb7wnvH_FuPTjgz9i0rEdw_Hf3Wk6CMypaUHi31y6twrMWq1jEbdQNl50EwH-RQmQ9bs3Wm9V9t-2-_Jzg3AT0Ny4zEDU7WXgN2DevM8_FVje4IgztNy29XUkeUctHsr-431_Iu23JIy6U4Kxn36X3RlVUKEkOMpkDD3kd81JPW4Ger_w", -/// "e": "AQAB", -/// "use": "sig" -/// } -/// ] -/// } -/// ``` -/// -/// We can call update_federated_jwk_set for Google's `iss` - "https://accounts.google.com" and for each vector -/// argument `kid_vec`, `alg_vec`, `e_vec`, `n_vec`, we set in index 0 the corresponding attribute in the first JWK and we set in index 1 -/// the corresponding attribute in the second JWK as shown below. -/// -/// ```move -/// use std::string::utf8; -/// aptos_framework::jwks::update_federated_jwk_set( -/// jwk_owner, -/// b"https://accounts.google.com", -/// vector[utf8(b"d7b939771a7800c413f90051012d975981916d71"), utf8(b"b2620d5e7f132b52afe8875cdf3776c064249d04")], -/// vector[utf8(b"RS256"), utf8(b"RS256")], -/// vector[utf8(b"AQAB"), utf8(b"AQAB")], -/// vector[ -/// utf8(b"wNHgGSG5B5xOEQNFPW2p_6ZxZbfPoAU5VceBUuNwQWLop0ohW0vpoZLU1tAsq_S9s5iwy27rJw4EZAOGBR9oTRq1Y6Li5pDVJfmzyRNtmWCWndR-bPqhs_dkJU7MbGwcvfLsN9FSHESFrS9sfGtUX-lZfLoGux23TKdYV9EE-H-NDASxrVFUk2GWc3rL6UEMWrMnOqV9-tghybDU3fcRdNTDuXUr9qDYmhmNegYjYu4REGjqeSyIG1tuQxYpOBH-tohtcfGY-oRTS09kgsSS9Q5BRM4qqCkGP28WhlSf4ui0-norS0gKMMI1P_ZAGEsLn9p2TlYMpewvIuhjJs1thw"), -/// utf8(b"pi22xDdK2fz5gclIbDIGghLDYiRO56eW2GUcboeVlhbAuhuT5mlEYIevkxdPOg5n6qICePZiQSxkwcYMIZyLkZhSJ2d2M6Szx2gDtnAmee6o_tWdroKu0DjqwG8pZU693oLaIjLku3IK20lTs6-2TeH-pUYMjEqiFMhn-hb7wnvH_FuPTjgz9i0rEdw_Hf3Wk6CMypaUHi31y6twrMWq1jEbdQNl50EwH-RQmQ9bs3Wm9V9t-2-_Jzg3AT0Ny4zEDU7WXgN2DevM8_FVje4IgztNy29XUkeUctHsr-431_Iu23JIy6U4Kxn36X3RlVUKEkOMpkDD3kd81JPW4Ger_w") -/// ] -/// ) -/// ``` -/// -/// See AIP-96 for more details about federated keyless - https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-96.md -/// -/// NOTE: Currently only RSA keys are supported. -pub fn jwks_update_federated_jwk_set( - iss: Vec, - kid_vec: Vec>, - alg_vec: Vec>, - e_vec: Vec>, - n_vec: Vec>, +/// Release the storage held by nonces that can no longer be replayed. Permissionless, because +/// it is pure cleanup and nobody has a reason to withhold it other than the fee, which is the +/// caller's to pay. +pub fn attestation_authorization_prune_nonces( + policy: AccountAddress, + nonces: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3839,60 +4586,58 @@ pub fn jwks_update_federated_jwk_set( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("jwks").to_owned(), + ident_str!("attestation_authorization").to_owned(), ), - ident_str!("update_federated_jwk_set").to_owned(), + ident_str!("prune_nonces").to_owned(), vec![], vec![ - bcs::to_bytes(&iss).unwrap(), - bcs::to_bytes(&kid_vec).unwrap(), - bcs::to_bytes(&alg_vec).unwrap(), - bcs::to_bytes(&e_vec).unwrap(), - bcs::to_bytes(&n_vec).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&nonces).unwrap(), ], )) } -/// Withdraw an `amount` of coin `CoinType` from `account` and burn it. -pub fn managed_coin_burn(coin_type: TypeTag, amount: u64) -> TransactionPayload { +/// Push the staged body live. Permissionless once its time has arrived, so the business does +/// not have to be online at the moment its own rule change takes effect. +pub fn attestation_policy_activate_pending(policy: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("managed_coin").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("burn").to_owned(), - vec![coin_type], - vec![bcs::to_bytes(&amount).unwrap()], + ident_str!("activate_pending").to_owned(), + vec![], + vec![bcs::to_bytes(&policy).unwrap()], )) } -/// Destroys capabilities from the account, so that the user no longer has access to mint or burn. -pub fn managed_coin_destroy_caps(coin_type: TypeTag) -> TransactionPayload { +pub fn attestation_policy_add_admins( + policy: AccountAddress, + new_admins: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("managed_coin").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("destroy_caps").to_owned(), - vec![coin_type], + ident_str!("add_admins").to_owned(), vec![], + vec![ + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&new_admins).unwrap(), + ], )) } -/// Initialize new coin `CoinType` in Aptos Blockchain. -/// Mint and Burn Capabilities will be stored under `account` in `Capabilities` resource. -pub fn managed_coin_initialize( - coin_type: TypeTag, - name: Vec, - symbol: Vec, - decimals: u8, - monitor_supply: bool, +pub fn attestation_policy_add_guardians( + policy: AccountAddress, + new_guardians: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -3900,100 +4645,101 @@ pub fn managed_coin_initialize( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("managed_coin").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("initialize").to_owned(), - vec![coin_type], + ident_str!("add_guardians").to_owned(), + vec![], vec![ - bcs::to_bytes(&name).unwrap(), - bcs::to_bytes(&symbol).unwrap(), - bcs::to_bytes(&decimals).unwrap(), - bcs::to_bytes(&monitor_supply).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&new_guardians).unwrap(), ], )) } -/// Create new coins `CoinType` and deposit them into dst_addr's account. -pub fn managed_coin_mint( - coin_type: TypeTag, - dst_addr: AccountAddress, - amount: u64, -) -> TransactionPayload { +/// Discard a staged body that has not activated yet. +pub fn attestation_policy_cancel_pending(policy: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("managed_coin").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("mint").to_owned(), - vec![coin_type], - vec![ - bcs::to_bytes(&dst_addr).unwrap(), - bcs::to_bytes(&amount).unwrap(), - ], + ident_str!("cancel_pending").to_owned(), + vec![], + vec![bcs::to_bytes(&policy).unwrap()], )) } -/// Creating a resource that stores balance of `CoinType` on user's account, withdraw and deposit event handlers. -/// Required if user wants to start accepting deposits of `CoinType` in his account. -pub fn managed_coin_register(coin_type: TypeTag) -> TransactionPayload { +/// Stop demanding authorization for an action. +pub fn attestation_policy_clear_step_up(policy: AccountAddress, action: u8) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("managed_coin").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("register").to_owned(), - vec![coin_type], + ident_str!("clear_step_up").to_owned(), vec![], + vec![ + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&action).unwrap(), + ], )) } -/// Similar to add_owners, but only allow adding one owner. -pub fn multisig_account_add_owner(new_owner: AccountAddress) -> TransactionPayload { +/// Create a new policy. The deployer only authorizes resource-account creation and pays gas; +/// it gains no role unless listed. The body starts empty, which denies everything until rules +/// are staged and activated. +/// +/// @param deployer Signer that authorizes resource-account creation and pays gas. +/// @param admins Addresses allowed to stage rules. At least one, no duplicates. +/// @param guardians Addresses allowed to pause evaluation. May be empty. +/// @abort If a list has duplicates, names the policy itself, or there is no admin. +pub fn attestation_policy_create( + admins: Vec, + guardians: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("add_owner").to_owned(), + ident_str!("create").to_owned(), vec![], - vec![bcs::to_bytes(&new_owner).unwrap()], + vec![ + bcs::to_bytes(&admins).unwrap(), + bcs::to_bytes(&guardians).unwrap(), + ], )) } -/// Add new owners to the multisig account. This can only be invoked by the multisig account itself, through the -/// proposal flow. -/// -/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This -/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to -/// maliciously alter the owners list. -pub fn multisig_account_add_owners(new_owners: Vec) -> TransactionPayload { +/// Deny everything until unpaused. Denies loudly with REASON_POLICY_PAUSED rather than +/// silently allowing. +pub fn attestation_policy_pause(policy: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("add_owners").to_owned(), + ident_str!("pause").to_owned(), vec![], - vec![bcs::to_bytes(&new_owners).unwrap()], + vec![bcs::to_bytes(&policy).unwrap()], )) } -/// Add owners then update number of signatures required, in a single operation. -pub fn multisig_account_add_owners_and_update_signatures_required( - new_owners: Vec, - new_num_signatures_required: u64, +pub fn attestation_policy_remove_admins( + policy: AccountAddress, + old_admins: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4001,21 +4747,20 @@ pub fn multisig_account_add_owners_and_update_signatures_required( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("add_owners_and_update_signatures_required").to_owned(), + ident_str!("remove_admins").to_owned(), vec![], vec![ - bcs::to_bytes(&new_owners).unwrap(), - bcs::to_bytes(&new_num_signatures_required).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&old_admins).unwrap(), ], )) } -/// Approve a multisig transaction. -pub fn multisig_account_approve_transaction( - multisig_account: AccountAddress, - sequence_number: u64, +pub fn attestation_policy_remove_guardians( + policy: AccountAddress, + old_guardians: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4023,22 +4768,23 @@ pub fn multisig_account_approve_transaction( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("approve_transaction").to_owned(), + ident_str!("remove_guardians").to_owned(), vec![], vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&sequence_number).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&old_guardians).unwrap(), ], )) } -/// Creates a new multisig account and add the signer as a single owner. -pub fn multisig_account_create( - num_signatures_required: u64, - metadata_keys: Vec>, - metadata_values: Vec>, +/// Set the key that signs authorizations for this policy, and the longest window it may +/// issue. A window of 0 with an empty key disables the step-up path entirely. +pub fn attestation_policy_set_authorizer( + policy: AccountAddress, + pubkey: Vec, + max_ttl_secs: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4046,22 +4792,24 @@ pub fn multisig_account_create( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("create").to_owned(), + ident_str!("set_authorizer").to_owned(), vec![], vec![ - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&pubkey).unwrap(), + bcs::to_bytes(&max_ttl_secs).unwrap(), ], )) } -/// Create a multisig transaction, which will have one approval initially (from the creator). -pub fn multisig_account_create_transaction( - multisig_account: AccountAddress, - payload: Vec, +/// Set the amount above which an action needs a fresh authorization. Absent by default, so a +/// liveness dependency is never enabled by accident. +pub fn attestation_policy_set_step_up( + policy: AccountAddress, + action: u8, + threshold: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4069,23 +4817,30 @@ pub fn multisig_account_create_transaction( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("create_transaction").to_owned(), + ident_str!("set_step_up").to_owned(), vec![], vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&payload).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&action).unwrap(), + bcs::to_bytes(&threshold).unwrap(), ], )) } -/// Create a multisig transaction with a transaction hash instead of the full payload. -/// This means the payload will be stored off chain for gas saving. Later, during execution, the executor will need -/// to provide the full payload, which will be validated against the hash stored on-chain. -pub fn multisig_account_create_transaction_with_hash( - multisig_account: AccountAddress, - payload_hash: Vec, +/// Stage attribute predicates onto the pending body. Call `stage_body` first. +/// +/// @param sources Source whose attribute each predicate reads. +/// @param keys Attribute key per predicate. +/// @param ops One of OP_IN, OP_NOT_IN, OP_EQ, OP_GTE. +/// @param values Candidate values per predicate. OP_EQ and OP_GTE take exactly one. +pub fn attestation_policy_stage_attr_rules( + policy: AccountAddress, + sources: Vec, + keys: Vec, + ops: Vec, + values: Vec>>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4093,35 +4848,44 @@ pub fn multisig_account_create_transaction_with_hash( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("create_transaction_with_hash").to_owned(), + ident_str!("stage_attr_rules").to_owned(), vec![], vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&payload_hash).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&sources).unwrap(), + bcs::to_bytes(&keys).unwrap(), + bcs::to_bytes(&ops).unwrap(), + bcs::to_bytes(&values).unwrap(), ], )) } -/// Creates a new multisig account on top of an existing account. -/// -/// This offers a migration path for an existing account with a multi-ed25519 auth key (native multisig account). -/// In order to ensure a malicious module cannot obtain backdoor control over an existing account, a signed message -/// with a valid signature from the account's auth key is required. +/// Stage a new body, to take effect at `effective_at_secs`. Staging rather than applying +/// immediately is what keeps a rule change from breaking a transaction already in flight. /// -/// Note that this does not revoke auth key-based control over the account. Owners should separately rotate the auth -/// key after they are fully migrated to the new multisig account. Alternatively, they can call -/// create_with_existing_account_and_revoke_auth_key instead. -pub fn multisig_account_create_with_existing_account( - multisig_address: AccountAddress, - owners: Vec, - num_signatures_required: u64, - account_scheme: u8, - account_public_key: Vec, - create_multisig_account_signed_message: Vec, - metadata_keys: Vec>, - metadata_values: Vec>, +/// @param admin An admin of the policy. +/// @param policy The policy address. +/// @param require_any_sources Sources of which at least one must vouch. May be empty. +/// @param require_any_levels Minimum level per entry, same length as require_any_sources. +/// @param require_all_sources Sources that must all vouch. May be empty. +/// @param require_all_levels Minimum level per entry, same length as require_all_sources. +/// @param deny_any Sources whose denial denies. May be empty. +/// @param chain_deny Optional chain-wide denial source: empty for none, or exactly one address. +/// A vector rather than an `Option` because entry functions cannot take `Option` arguments. +/// @param effective_at_secs When the body becomes active. +/// @abort If the lengths differ, a list is over MAX_SOURCES, chain_deny names more than one +/// source, or a named source does not exist. +pub fn attestation_policy_stage_body( + policy: AccountAddress, + require_any_sources: Vec, + require_any_levels: Vec, + require_all_sources: Vec, + require_all_levels: Vec, + deny_any: Vec, + chain_deny: Vec, + effective_at_secs: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4129,72 +4893,43 @@ pub fn multisig_account_create_with_existing_account( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("create_with_existing_account").to_owned(), + ident_str!("stage_body").to_owned(), vec![], vec![ - bcs::to_bytes(&multisig_address).unwrap(), - bcs::to_bytes(&owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&account_scheme).unwrap(), - bcs::to_bytes(&account_public_key).unwrap(), - bcs::to_bytes(&create_multisig_account_signed_message).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), + bcs::to_bytes(&policy).unwrap(), + bcs::to_bytes(&require_any_sources).unwrap(), + bcs::to_bytes(&require_any_levels).unwrap(), + bcs::to_bytes(&require_all_sources).unwrap(), + bcs::to_bytes(&require_all_levels).unwrap(), + bcs::to_bytes(&deny_any).unwrap(), + bcs::to_bytes(&chain_deny).unwrap(), + bcs::to_bytes(&effective_at_secs).unwrap(), ], )) } -/// Creates a new multisig account on top of an existing account and immediately rotate the origin auth key to 0x0. -/// -/// Note: If the original account is a resource account, this does not revoke all control over it as if any -/// SignerCapability of the resource account still exists, it can still be used to generate the signer for the -/// account. -pub fn multisig_account_create_with_existing_account_and_revoke_auth_key( - multisig_address: AccountAddress, - owners: Vec, - num_signatures_required: u64, - account_scheme: u8, - account_public_key: Vec, - create_multisig_account_signed_message: Vec, - metadata_keys: Vec>, - metadata_values: Vec>, -) -> TransactionPayload { +pub fn attestation_policy_unpause(policy: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("attestation_policy").to_owned(), ), - ident_str!("create_with_existing_account_and_revoke_auth_key").to_owned(), + ident_str!("unpause").to_owned(), vec![], - vec![ - bcs::to_bytes(&multisig_address).unwrap(), - bcs::to_bytes(&owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&account_scheme).unwrap(), - bcs::to_bytes(&account_public_key).unwrap(), - bcs::to_bytes(&create_multisig_account_signed_message).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), - ], + vec![bcs::to_bytes(&policy).unwrap()], )) } -/// Private entry function that creates a new multisig account on top of an existing account and immediately rotate -/// the origin auth key to 0x0. -/// -/// Note: If the original account is a resource account, this does not revoke all control over it as if any -/// SignerCapability of the resource account still exists, it can still be used to generate the signer for the -/// account. -pub fn multisig_account_create_with_existing_account_and_revoke_auth_key_call( - owners: Vec, - num_signatures_required: u64, - metadata_keys: Vec>, - metadata_values: Vec>, +/// Same as `publish_package` but as an entry function which can be called as a transaction. Because +/// of current restrictions for txn parameters, the metadata needs to be passed in serialized form. +pub fn code_publish_package_txn( + metadata_serialized: Vec, + code: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4202,91 +4937,52 @@ pub fn multisig_account_create_with_existing_account_and_revoke_auth_key_call( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("code").to_owned(), ), - ident_str!("create_with_existing_account_and_revoke_auth_key_call").to_owned(), + ident_str!("publish_package_txn").to_owned(), vec![], vec![ - bcs::to_bytes(&owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), + bcs::to_bytes(&metadata_serialized).unwrap(), + bcs::to_bytes(&code).unwrap(), ], )) } -/// Private entry function that creates a new multisig account on top of an existing account. -/// -/// This offers a migration path for an existing account with any type of auth key. -/// -/// Note that this does not revoke auth key-based control over the account. Owners should separately rotate the auth -/// key after they are fully migrated to the new multisig account. Alternatively, they can call -/// create_with_existing_account_and_revoke_auth_key_call instead. -pub fn multisig_account_create_with_existing_account_call( - owners: Vec, - num_signatures_required: u64, - metadata_keys: Vec>, - metadata_values: Vec>, -) -> TransactionPayload { +pub fn coin_create_coin_conversion_map() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("create_with_existing_account_call").to_owned(), + ident_str!("create_coin_conversion_map").to_owned(), + vec![], vec![], - vec![ - bcs::to_bytes(&owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), - ], )) } -/// Creates a new multisig account with the specified additional owner list and signatures required. -/// -/// @param additional_owners The owner account who calls this function cannot be in the additional_owners and there -/// cannot be any duplicate owners in the list. -/// @param num_signatures_required The number of signatures required to execute a transaction. Must be at least 1 and -/// at most the total number of owners. -pub fn multisig_account_create_with_owners( - additional_owners: Vec, - num_signatures_required: u64, - metadata_keys: Vec>, - metadata_values: Vec>, -) -> TransactionPayload { +/// Create APT pairing by passing `AptosCoin`. +pub fn coin_create_pairing(coin_type: TypeTag) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("create_with_owners").to_owned(), + ident_str!("create_pairing").to_owned(), + vec![coin_type], vec![], - vec![ - bcs::to_bytes(&additional_owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), - ], )) } -/// Like `create_with_owners`, but removes the calling account after creation. -/// -/// This is for creating a vanity multisig account from a bootstrapping account that should not -/// be an owner after the vanity multisig address has been secured. -pub fn multisig_account_create_with_owners_then_remove_bootstrapper( - owners: Vec, - num_signatures_required: u64, - metadata_keys: Vec>, - metadata_values: Vec>, +/// Migrate to fungible store for `CoinType` if not yet. +pub fn coin_migrate_coin_store_to_fungible_store( + coin_type: TypeTag, + accounts: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4294,123 +4990,109 @@ pub fn multisig_account_create_with_owners_then_remove_bootstrapper( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("create_with_owners_then_remove_bootstrapper").to_owned(), - vec![], - vec![ - bcs::to_bytes(&owners).unwrap(), - bcs::to_bytes(&num_signatures_required).unwrap(), - bcs::to_bytes(&metadata_keys).unwrap(), - bcs::to_bytes(&metadata_values).unwrap(), - ], + ident_str!("migrate_coin_store_to_fungible_store").to_owned(), + vec![coin_type], + vec![bcs::to_bytes(&accounts).unwrap()], )) } -/// Remove the next transaction if it has sufficient owner rejections. -pub fn multisig_account_execute_rejected_transaction( - multisig_account: AccountAddress, -) -> TransactionPayload { +/// Voluntarily migrate to fungible store for `CoinType` if not yet. +pub fn coin_migrate_to_fungible_store(coin_type: TypeTag) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("execute_rejected_transaction").to_owned(), + ident_str!("migrate_to_fungible_store").to_owned(), + vec![coin_type], vec![], - vec![bcs::to_bytes(&multisig_account).unwrap()], )) } -/// Remove the next transactions until the final_sequence_number if they have sufficient owner rejections. -pub fn multisig_account_execute_rejected_transactions( - multisig_account: AccountAddress, - final_sequence_number: u64, -) -> TransactionPayload { +/// Transfers `amount` of coins `CoinType` from `from` to `to`. +pub fn coin_transfer(coin_type: TypeTag, to: AccountAddress, amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("execute_rejected_transactions").to_owned(), - vec![], - vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&final_sequence_number).unwrap(), - ], + ident_str!("transfer").to_owned(), + vec![coin_type], + vec![bcs::to_bytes(&to).unwrap(), bcs::to_bytes(&amount).unwrap()], )) } -/// Reject a multisig transaction. -pub fn multisig_account_reject_transaction( - multisig_account: AccountAddress, - sequence_number: u64, -) -> TransactionPayload { +/// Upgrade total supply to use a parallelizable implementation if it is +/// available. +pub fn coin_upgrade_supply(coin_type: TypeTag) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("coin").to_owned(), ), - ident_str!("reject_transaction").to_owned(), + ident_str!("upgrade_supply").to_owned(), + vec![coin_type], vec![], - vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&sequence_number).unwrap(), - ], )) } -/// Similar to remove_owners, but only allow removing one owner. -pub fn multisig_account_remove_owner(owner_to_remove: AccountAddress) -> TransactionPayload { +/// Add `amount` of coins to the delegation pool `pool_address`. +pub fn delegation_pool_add_stake(pool_address: AccountAddress, amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("remove_owner").to_owned(), + ident_str!("add_stake").to_owned(), vec![], - vec![bcs::to_bytes(&owner_to_remove).unwrap()], + vec![ + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&amount).unwrap(), + ], )) } -/// Remove owners from the multisig account. This can only be invoked by the multisig account itself, through the -/// proposal flow. -/// -/// This function skips any owners who are not in the multisig account's list of owners. -/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This -/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to -/// maliciously alter the owners list. -pub fn multisig_account_remove_owners(owners_to_remove: Vec) -> TransactionPayload { - TransactionPayload::EntryFunction(EntryFunction::new( - ModuleId::new( +/// Allowlist a delegator as the pool owner. +pub fn delegation_pool_allowlist_delegator( + delegator_address: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("remove_owners").to_owned(), + ident_str!("allowlist_delegator").to_owned(), vec![], - vec![bcs::to_bytes(&owners_to_remove).unwrap()], + vec![bcs::to_bytes(&delegator_address).unwrap()], )) } -/// Swap an owner in for an old one, without changing required signatures. -pub fn multisig_account_swap_owner( - to_swap_in: AccountAddress, - to_swap_out: AccountAddress, +/// A voter could create a governance proposal by this function. To successfully create a proposal, the voter's +/// voting power in THIS delegation pool must be not less than the minimum required voting power specified in +/// `aptos_governance.move`. +pub fn delegation_pool_create_proposal( + pool_address: AccountAddress, + execution_hash: Vec, + metadata_location: Vec, + metadata_hash: Vec, + is_multi_step_proposal: bool, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4418,21 +5100,25 @@ pub fn multisig_account_swap_owner( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("swap_owner").to_owned(), + ident_str!("create_proposal").to_owned(), vec![], vec![ - bcs::to_bytes(&to_swap_in).unwrap(), - bcs::to_bytes(&to_swap_out).unwrap(), + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&execution_hash).unwrap(), + bcs::to_bytes(&metadata_location).unwrap(), + bcs::to_bytes(&metadata_hash).unwrap(), + bcs::to_bytes(&is_multi_step_proposal).unwrap(), ], )) } -/// Swap owners in and out, without changing required signatures. -pub fn multisig_account_swap_owners( - to_swap_in: Vec, - to_swap_out: Vec, +/// Allows a delegator to delegate its voting power to a voter. If this delegator already has a delegated voter, +/// this change won't take effects until the next lockup period. +pub fn delegation_pool_delegate_voting_power( + pool_address: AccountAddress, + new_voter: AccountAddress, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4440,77 +5126,53 @@ pub fn multisig_account_swap_owners( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("swap_owners").to_owned(), + ident_str!("delegate_voting_power").to_owned(), vec![], vec![ - bcs::to_bytes(&to_swap_in).unwrap(), - bcs::to_bytes(&to_swap_out).unwrap(), + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), ], )) } -/// Swap owners in and out, updating number of required signatures. -pub fn multisig_account_swap_owners_and_update_signatures_required( - new_owners: Vec, - owners_to_remove: Vec, - new_num_signatures_required: u64, -) -> TransactionPayload { +/// Disable delegators allowlisting as the pool owner. The existing allowlist will be emptied. +pub fn delegation_pool_disable_delegators_allowlisting() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("swap_owners_and_update_signatures_required").to_owned(), + ident_str!("disable_delegators_allowlisting").to_owned(), + vec![], vec![], - vec![ - bcs::to_bytes(&new_owners).unwrap(), - bcs::to_bytes(&owners_to_remove).unwrap(), - bcs::to_bytes(&new_num_signatures_required).unwrap(), - ], )) } -/// Allow the multisig account to update its own metadata. Note that this overrides the entire existing metadata. -/// If any attributes are not specified in the metadata, they will be removed! -/// -/// This can only be invoked by the multisig account itself, through the proposal flow. -/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This -/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to -/// maliciously alter the number of signatures required. -pub fn multisig_account_update_metadata( - keys: Vec>, - values: Vec>, -) -> TransactionPayload { +/// Enable delegators allowlisting as the pool owner. +pub fn delegation_pool_enable_delegators_allowlisting() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("update_metadata").to_owned(), + ident_str!("enable_delegators_allowlisting").to_owned(), + vec![], vec![], - vec![ - bcs::to_bytes(&keys).unwrap(), - bcs::to_bytes(&values).unwrap(), - ], )) } -/// Update the number of signatures required to execute transaction in the specified multisig account. -/// -/// This can only be invoked by the multisig account itself, through the proposal flow. -/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This -/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to -/// maliciously alter the number of signatures required. -pub fn multisig_account_update_signatures_required( - new_num_signatures_required: u64, +/// Enable partial governance voting on a stake pool. The voter of this stake pool will be managed by this module. +/// The existing voter will be replaced. The function is permissionless. +pub fn delegation_pool_enable_partial_governance_voting( + pool_address: AccountAddress, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4518,19 +5180,18 @@ pub fn multisig_account_update_signatures_required( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("update_signatures_required").to_owned(), + ident_str!("enable_partial_governance_voting").to_owned(), vec![], - vec![bcs::to_bytes(&new_num_signatures_required).unwrap()], + vec![bcs::to_bytes(&pool_address).unwrap()], )) } -/// Generic function that can be used to either approve or reject a multisig transaction -pub fn multisig_account_vote_transaction( - multisig_account: AccountAddress, - sequence_number: u64, - approved: bool, +/// Enable partial governance voting on a delegation pool if it has not already been initialized. +/// This is intended for idempotent migration scripts over existing delegation pools. +pub fn delegation_pool_enable_partial_governance_voting_if_needed( + pool_address: AccountAddress, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4538,51 +5199,37 @@ pub fn multisig_account_vote_transaction( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("vote_transaction").to_owned(), + ident_str!("enable_partial_governance_voting_if_needed").to_owned(), vec![], - vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&sequence_number).unwrap(), - bcs::to_bytes(&approved).unwrap(), - ], + vec![bcs::to_bytes(&pool_address).unwrap()], )) } -/// Generic function that can be used to either approve or reject a batch of transactions within a specified range. -pub fn multisig_account_vote_transactions( - multisig_account: AccountAddress, - starting_sequence_number: u64, - final_sequence_number: u64, - approved: bool, -) -> TransactionPayload { +/// Evict a delegator that is not allowlisted by unlocking their entire stake. +pub fn delegation_pool_evict_delegator(delegator_address: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("vote_transactions").to_owned(), + ident_str!("evict_delegator").to_owned(), vec![], - vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&starting_sequence_number).unwrap(), - bcs::to_bytes(&final_sequence_number).unwrap(), - bcs::to_bytes(&approved).unwrap(), - ], + vec![bcs::to_bytes(&delegator_address).unwrap()], )) } -/// Generic function that can be used to either approve or reject a multisig transaction -/// Retained for backward compatibility: the function with the typographical error in its name -/// will continue to be an accessible entry point. -pub fn multisig_account_vote_transanction( - multisig_account: AccountAddress, - sequence_number: u64, - approved: bool, +/// Initialize a delegation pool of custom fixed `operator_commission_percentage`. +/// A resource account is created from `owner` signer and its supplied `delegation_pool_creation_seed` +/// to host the delegation pool resource and own the underlying stake pool. +/// Ownership over setting the operator/voter is granted to `owner` who has both roles initially. +pub fn delegation_pool_initialize_delegation_pool( + operator_commission_percentage: u64, + delegation_pool_creation_seed: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4590,33 +5237,21 @@ pub fn multisig_account_vote_transanction( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("multisig_account").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("vote_transanction").to_owned(), + ident_str!("initialize_delegation_pool").to_owned(), vec![], vec![ - bcs::to_bytes(&multisig_account).unwrap(), - bcs::to_bytes(&sequence_number).unwrap(), - bcs::to_bytes(&approved).unwrap(), + bcs::to_bytes(&operator_commission_percentage).unwrap(), + bcs::to_bytes(&delegation_pool_creation_seed).unwrap(), ], )) } -/// Completes a bridge transfer on the destination chain. -/// -/// @param caller The signer representing the bridge relayer. -/// @param initiator The initiator's Ethereum address as a vector of bytes. -/// @param bridge_transfer_id The unique identifier for the bridge transfer. -/// @param recipient The address of the recipient on the Aptos blockchain. -/// @param amount The amount of assets to be locked. -/// @param nonce The unique nonce for the transfer. -/// @abort If the caller is not the bridge relayer or the transfer has already been processed. -pub fn native_bridge_complete_bridge_transfer( - _bridge_transfer_id: Vec, - _initiator: Vec, - _recipient: AccountAddress, - _amount: u64, - _nonce: u64, +/// Move `amount` of coins from pending_inactive to active. +pub fn delegation_pool_reactivate_stake( + pool_address: AccountAddress, + amount: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4624,29 +5259,20 @@ pub fn native_bridge_complete_bridge_transfer( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("native_bridge").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("complete_bridge_transfer").to_owned(), + ident_str!("reactivate_stake").to_owned(), vec![], vec![ - bcs::to_bytes(&_bridge_transfer_id).unwrap(), - bcs::to_bytes(&_initiator).unwrap(), - bcs::to_bytes(&_recipient).unwrap(), - bcs::to_bytes(&_amount).unwrap(), - bcs::to_bytes(&_nonce).unwrap(), + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&amount).unwrap(), ], )) } -/// Initiate a bridge transfer of MOVE from Movement to Ethereum -/// Anyone can initiate a bridge transfer from the source chain -/// The amount is burnt from the initiator and the module-level nonce is incremented -/// @param initiator The initiator's Ethereum address as a vector of bytes. -/// @param recipient The address of the recipient on the Aptos blockchain. -/// @param amount The amount of assets to be locked. -pub fn native_bridge_initiate_bridge_transfer( - _recipient: Vec, - _amount: u64, +/// Remove a delegator from the allowlist as the pool owner, but do not unlock their stake. +pub fn delegation_pool_remove_delegator_from_allowlist( + delegator_address: AccountAddress, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4654,134 +5280,134 @@ pub fn native_bridge_initiate_bridge_transfer( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("native_bridge").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("initiate_bridge_transfer").to_owned(), + ident_str!("remove_delegator_from_allowlist").to_owned(), vec![], - vec![ - bcs::to_bytes(&_recipient).unwrap(), - bcs::to_bytes(&_amount).unwrap(), - ], + vec![bcs::to_bytes(&delegator_address).unwrap()], )) } -/// Updates the bridge fee, requiring relayer validation. -/// -/// @param relayer The signer representing the Relayer. -/// @param new_bridge_fee The new bridge fee to be set. -/// @abort If the new bridge fee is the same as the old bridge fee. -pub fn native_bridge_update_bridge_fee(_new_bridge_fee: u64) -> TransactionPayload { +/// Allows an operator to change its beneficiary. Any existing unpaid commission rewards will be paid to the new +/// beneficiary. To ensure payment to the current beneficiary, one should first call `synchronize_delegation_pool` +/// before switching the beneficiary. An operator can set one beneficiary for delegation pools, not a separate +/// one for each pool. +pub fn delegation_pool_set_beneficiary_for_operator( + new_beneficiary: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("native_bridge").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("update_bridge_fee").to_owned(), + ident_str!("set_beneficiary_for_operator").to_owned(), vec![], - vec![bcs::to_bytes(&_new_bridge_fee).unwrap()], + vec![bcs::to_bytes(&new_beneficiary).unwrap()], )) } -/// Updates the insurance budget divider, requiring governance validation. -/// -/// @param aptos_framework The signer representing the Aptos framework. -/// @param new_insurance_budget_divider The new insurance budget divider to be set. -/// @abort If the new insurance budget divider is the same as the old insurance budget divider. -pub fn native_bridge_update_insurance_budget_divider( - _new_insurance_budget_divider: u64, -) -> TransactionPayload { +/// Deprecated. Use the partial governance voting flow instead. +pub fn delegation_pool_set_delegated_voter(_new_voter: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("native_bridge").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("update_insurance_budget_divider").to_owned(), + ident_str!("set_delegated_voter").to_owned(), vec![], - vec![bcs::to_bytes(&_new_insurance_budget_divider).unwrap()], + vec![bcs::to_bytes(&_new_voter).unwrap()], )) } -/// Updates the insurance fund, requiring governance validation. -/// -/// @param aptos_framework The signer representing the Aptos framework. -/// @param new_insurance_fund The new insurance fund to be set. -/// @abort If the new insurance fund is the same as the old insurance fund. -pub fn native_bridge_update_insurance_fund( - _new_insurance_fund: AccountAddress, -) -> TransactionPayload { +/// Allows an owner to change the operator of the underlying stake pool. +pub fn delegation_pool_set_operator(new_operator: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("native_bridge").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("update_insurance_fund").to_owned(), + ident_str!("set_operator").to_owned(), vec![], - vec![bcs::to_bytes(&_new_insurance_fund).unwrap()], + vec![bcs::to_bytes(&new_operator).unwrap()], )) } -pub fn nonce_validation_add_nonce_buckets(count: u64) -> TransactionPayload { +/// Synchronize delegation and stake pools: distribute yet-undetected rewards to the corresponding internal +/// shares pools, assign commission to operator and eventually prepare delegation pool for a new lockup cycle. +pub fn delegation_pool_synchronize_delegation_pool( + pool_address: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("nonce_validation").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("add_nonce_buckets").to_owned(), + ident_str!("synchronize_delegation_pool").to_owned(), vec![], - vec![bcs::to_bytes(&count).unwrap()], + vec![bcs::to_bytes(&pool_address).unwrap()], )) } -pub fn nonce_validation_initialize_nonce_table() -> TransactionPayload { +/// Unlock `amount` from the active + pending_active stake of `delegator` or +/// at most how much active stake there is on the stake pool. +pub fn delegation_pool_unlock(pool_address: AccountAddress, amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("nonce_validation").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("initialize_nonce_table").to_owned(), - vec![], + ident_str!("unlock").to_owned(), vec![], + vec![ + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&amount).unwrap(), + ], )) } -/// Entry function that can be used to transfer, if allow_ungated_transfer is set true. -pub fn object_transfer_call(object: AccountAddress, to: AccountAddress) -> TransactionPayload { +/// Allows an owner to update the commission percentage for the operator of the underlying stake pool. +pub fn delegation_pool_update_commission_percentage( + new_commission_percentage: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("object").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("transfer_call").to_owned(), + ident_str!("update_commission_percentage").to_owned(), vec![], - vec![bcs::to_bytes(&object).unwrap(), bcs::to_bytes(&to).unwrap()], + vec![bcs::to_bytes(&new_commission_percentage).unwrap()], )) } -/// Creates a new object with a unique address derived from the publisher address and the object seed. -/// Publishes the code passed in the function to the newly created object. -/// The caller must provide package metadata describing the package via `metadata_serialized` and -/// the code to be published via `code`. This contains a vector of modules to be deployed on-chain. -pub fn object_code_deployment_publish( - metadata_serialized: Vec, - code: Vec>, +/// Vote on a proposal with a voter's voting power. To successfully vote, the following conditions must be met: +/// 1. The voting period of the proposal hasn't ended. +/// 2. The delegation pool's lockup period ends after the voting period of the proposal. +/// 3. The voter still has spare voting power on this proposal. +/// 4. The delegation pool never votes on the proposal before enabling partial governance voting. +pub fn delegation_pool_vote( + pool_address: AccountAddress, + proposal_id: u64, + voting_power: u64, + should_pass: bool, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4789,85 +5415,132 @@ pub fn object_code_deployment_publish( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("object_code_deployment").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("publish").to_owned(), + ident_str!("vote").to_owned(), vec![], vec![ - bcs::to_bytes(&metadata_serialized).unwrap(), - bcs::to_bytes(&code).unwrap(), + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&proposal_id).unwrap(), + bcs::to_bytes(&voting_power).unwrap(), + bcs::to_bytes(&should_pass).unwrap(), ], )) } -/// Revoke all storable permission handle of the signer immediately. -pub fn permissioned_signer_revoke_all_handles() -> TransactionPayload { +/// Withdraw `amount` of owned inactive stake from the delegation pool at `pool_address`. +pub fn delegation_pool_withdraw(pool_address: AccountAddress, amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("permissioned_signer").to_owned(), + ident_str!("delegation_pool").to_owned(), ), - ident_str!("revoke_all_handles").to_owned(), - vec![], + ident_str!("withdraw").to_owned(), vec![], + vec![ + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&amount).unwrap(), + ], )) } -/// Revoke a specific storable permission handle immediately. This will disallow owner of -/// the storable permission handle to derive signer from it anymore. -pub fn permissioned_signer_revoke_permission_storage_address( - permissions_storage_addr: AccountAddress, -) -> TransactionPayload { +/// Deposits from the treasury account. Treasury deposit are recorded. +/// @param treasury_account The address of the account that paid the treasury. +/// @param amount The amount of treasury to be deposited. +pub fn governed_gas_pool_deposit_treasury(amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("permissioned_signer").to_owned(), + ident_str!("governed_gas_pool").to_owned(), ), - ident_str!("revoke_permission_storage_address").to_owned(), + ident_str!("deposit_treasury").to_owned(), vec![], - vec![bcs::to_bytes(&permissions_storage_addr).unwrap()], + vec![bcs::to_bytes(&amount).unwrap()], )) } -/// Creates a new resource account and rotates the authentication key to either -/// the optional auth key if it is non-empty (though auth keys are 32-bytes) -/// or the source accounts current auth key. -pub fn resource_account_create_resource_account( - seed: Vec, - optional_auth_key: Vec, -) -> TransactionPayload { +/// Initializes the governed gas pool extension alone. +/// @param aptos_framework The signer of the aptos_framework module. +pub fn governed_gas_pool_initialize_governed_gas_pool_extension() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("resource_account").to_owned(), + ident_str!("governed_gas_pool").to_owned(), ), - ident_str!("create_resource_account").to_owned(), + ident_str!("initialize_governed_gas_pool_extension").to_owned(), + vec![], vec![], - vec![ - bcs::to_bytes(&seed).unwrap(), - bcs::to_bytes(&optional_auth_key).unwrap(), - ], )) } -/// Creates a new resource account, transfer the amount of coins from the origin to the resource -/// account, and rotates the authentication key to either the optional auth key if it is -/// non-empty (though auth keys are 32-bytes) or the source accounts current auth key. Note, -/// this function adds additional resource ownership to the resource account and should only be -/// used for resource accounts that need access to `Coin`. -pub fn resource_account_create_resource_account_and_fund( - seed: Vec, - optional_auth_key: Vec, - fund_amount: u64, +/// This can be called to install or update a set of JWKs for a federated OIDC provider. This function should +/// be invoked to intially install a set of JWKs or to update a set of JWKs when a keypair is rotated. +/// +/// The `iss` parameter is the value of the `iss` claim on the JWTs that are to be verified by the JWK set. +/// `kid_vec`, `alg_vec`, `e_vec`, `n_vec` are String vectors of the JWK attributes `kid`, `alg`, `e` and `n` respectively. +/// See https://datatracker.ietf.org/doc/html/rfc7517#section-4 for more details about the JWK attributes aforementioned. +/// +/// For the example JWK set snapshot below containing 2 keys for Google found at https://www.googleapis.com/oauth2/v3/certs - +/// ```json +/// { +/// "keys": [ +/// { +/// "alg": "RS256", +/// "use": "sig", +/// "kty": "RSA", +/// "n": "wNHgGSG5B5xOEQNFPW2p_6ZxZbfPoAU5VceBUuNwQWLop0ohW0vpoZLU1tAsq_S9s5iwy27rJw4EZAOGBR9oTRq1Y6Li5pDVJfmzyRNtmWCWndR-bPqhs_dkJU7MbGwcvfLsN9FSHESFrS9sfGtUX-lZfLoGux23TKdYV9EE-H-NDASxrVFUk2GWc3rL6UEMWrMnOqV9-tghybDU3fcRdNTDuXUr9qDYmhmNegYjYu4REGjqeSyIG1tuQxYpOBH-tohtcfGY-oRTS09kgsSS9Q5BRM4qqCkGP28WhlSf4ui0-norS0gKMMI1P_ZAGEsLn9p2TlYMpewvIuhjJs1thw", +/// "kid": "d7b939771a7800c413f90051012d975981916d71", +/// "e": "AQAB" +/// }, +/// { +/// "kty": "RSA", +/// "kid": "b2620d5e7f132b52afe8875cdf3776c064249d04", +/// "alg": "RS256", +/// "n": "pi22xDdK2fz5gclIbDIGghLDYiRO56eW2GUcboeVlhbAuhuT5mlEYIevkxdPOg5n6qICePZiQSxkwcYMIZyLkZhSJ2d2M6Szx2gDtnAmee6o_tWdroKu0DjqwG8pZU693oLaIjLku3IK20lTs6-2TeH-pUYMjEqiFMhn-hb7wnvH_FuPTjgz9i0rEdw_Hf3Wk6CMypaUHi31y6twrMWq1jEbdQNl50EwH-RQmQ9bs3Wm9V9t-2-_Jzg3AT0Ny4zEDU7WXgN2DevM8_FVje4IgztNy29XUkeUctHsr-431_Iu23JIy6U4Kxn36X3RlVUKEkOMpkDD3kd81JPW4Ger_w", +/// "e": "AQAB", +/// "use": "sig" +/// } +/// ] +/// } +/// ``` +/// +/// We can call update_federated_jwk_set for Google's `iss` - "https://accounts.google.com" and for each vector +/// argument `kid_vec`, `alg_vec`, `e_vec`, `n_vec`, we set in index 0 the corresponding attribute in the first JWK and we set in index 1 +/// the corresponding attribute in the second JWK as shown below. +/// +/// ```move +/// use std::string::utf8; +/// aptos_framework::jwks::update_federated_jwk_set( +/// jwk_owner, +/// b"https://accounts.google.com", +/// vector[utf8(b"d7b939771a7800c413f90051012d975981916d71"), utf8(b"b2620d5e7f132b52afe8875cdf3776c064249d04")], +/// vector[utf8(b"RS256"), utf8(b"RS256")], +/// vector[utf8(b"AQAB"), utf8(b"AQAB")], +/// vector[ +/// utf8(b"wNHgGSG5B5xOEQNFPW2p_6ZxZbfPoAU5VceBUuNwQWLop0ohW0vpoZLU1tAsq_S9s5iwy27rJw4EZAOGBR9oTRq1Y6Li5pDVJfmzyRNtmWCWndR-bPqhs_dkJU7MbGwcvfLsN9FSHESFrS9sfGtUX-lZfLoGux23TKdYV9EE-H-NDASxrVFUk2GWc3rL6UEMWrMnOqV9-tghybDU3fcRdNTDuXUr9qDYmhmNegYjYu4REGjqeSyIG1tuQxYpOBH-tohtcfGY-oRTS09kgsSS9Q5BRM4qqCkGP28WhlSf4ui0-norS0gKMMI1P_ZAGEsLn9p2TlYMpewvIuhjJs1thw"), +/// utf8(b"pi22xDdK2fz5gclIbDIGghLDYiRO56eW2GUcboeVlhbAuhuT5mlEYIevkxdPOg5n6qICePZiQSxkwcYMIZyLkZhSJ2d2M6Szx2gDtnAmee6o_tWdroKu0DjqwG8pZU693oLaIjLku3IK20lTs6-2TeH-pUYMjEqiFMhn-hb7wnvH_FuPTjgz9i0rEdw_Hf3Wk6CMypaUHi31y6twrMWq1jEbdQNl50EwH-RQmQ9bs3Wm9V9t-2-_Jzg3AT0Ny4zEDU7WXgN2DevM8_FVje4IgztNy29XUkeUctHsr-431_Iu23JIy6U4Kxn36X3RlVUKEkOMpkDD3kd81JPW4Ger_w") +/// ] +/// ) +/// ``` +/// +/// See AIP-96 for more details about federated keyless - https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-96.md +/// +/// NOTE: Currently only RSA keys are supported. +pub fn jwks_update_federated_jwk_set( + iss: Vec, + kid_vec: Vec>, + alg_vec: Vec>, + e_vec: Vec>, + n_vec: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4875,83 +5548,85 @@ pub fn resource_account_create_resource_account_and_fund( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("resource_account").to_owned(), + ident_str!("jwks").to_owned(), ), - ident_str!("create_resource_account_and_fund").to_owned(), + ident_str!("update_federated_jwk_set").to_owned(), vec![], vec![ - bcs::to_bytes(&seed).unwrap(), - bcs::to_bytes(&optional_auth_key).unwrap(), - bcs::to_bytes(&fund_amount).unwrap(), + bcs::to_bytes(&iss).unwrap(), + bcs::to_bytes(&kid_vec).unwrap(), + bcs::to_bytes(&alg_vec).unwrap(), + bcs::to_bytes(&e_vec).unwrap(), + bcs::to_bytes(&n_vec).unwrap(), ], )) } -/// Creates a new resource account, publishes the package under this account transaction under -/// this account and leaves the signer cap readily available for pickup. -pub fn resource_account_create_resource_account_and_publish_package( - seed: Vec, - metadata_serialized: Vec, - code: Vec>, -) -> TransactionPayload { +/// Withdraw an `amount` of coin `CoinType` from `account` and burn it. +pub fn managed_coin_burn(coin_type: TypeTag, amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("resource_account").to_owned(), + ident_str!("managed_coin").to_owned(), ), - ident_str!("create_resource_account_and_publish_package").to_owned(), - vec![], - vec![ - bcs::to_bytes(&seed).unwrap(), - bcs::to_bytes(&metadata_serialized).unwrap(), - bcs::to_bytes(&code).unwrap(), - ], + ident_str!("burn").to_owned(), + vec![coin_type], + vec![bcs::to_bytes(&amount).unwrap()], )) } -/// Add `amount` of coins from the `account` owning the StakePool. -pub fn stake_add_stake(amount: u64) -> TransactionPayload { +/// Destroys capabilities from the account, so that the user no longer has access to mint or burn. +pub fn managed_coin_destroy_caps(coin_type: TypeTag) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("managed_coin").to_owned(), ), - ident_str!("add_stake").to_owned(), + ident_str!("destroy_caps").to_owned(), + vec![coin_type], vec![], - vec![bcs::to_bytes(&amount).unwrap()], )) } -/// Similar to increase_lockup_with_cap but will use ownership capability from the signing account. -pub fn stake_increase_lockup() -> TransactionPayload { +/// Initialize new coin `CoinType` in Aptos Blockchain. +/// Mint and Burn Capabilities will be stored under `account` in `Capabilities` resource. +pub fn managed_coin_initialize( + coin_type: TypeTag, + name: Vec, + symbol: Vec, + decimals: u8, + monitor_supply: bool, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("managed_coin").to_owned(), ), - ident_str!("increase_lockup").to_owned(), - vec![], - vec![], - )) -} - -/// Initialize the validator account and give ownership to the signing account -/// except it leaves the ValidatorConfig to be set by another entity. -/// Note: this triggers setting the operator and owner, set it to the account's address -/// to set later. -pub fn stake_initialize_stake_owner( - initial_stake_amount: u64, - operator: AccountAddress, - voter: AccountAddress, + ident_str!("initialize").to_owned(), + vec![coin_type], + vec![ + bcs::to_bytes(&name).unwrap(), + bcs::to_bytes(&symbol).unwrap(), + bcs::to_bytes(&decimals).unwrap(), + bcs::to_bytes(&monitor_supply).unwrap(), + ], + )) +} + +/// Create new coins `CoinType` and deposit them into dst_addr's account. +pub fn managed_coin_mint( + coin_type: TypeTag, + dst_addr: AccountAddress, + amount: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -4959,102 +5634,97 @@ pub fn stake_initialize_stake_owner( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("managed_coin").to_owned(), ), - ident_str!("initialize_stake_owner").to_owned(), - vec![], + ident_str!("mint").to_owned(), + vec![coin_type], vec![ - bcs::to_bytes(&initial_stake_amount).unwrap(), - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&voter).unwrap(), + bcs::to_bytes(&dst_addr).unwrap(), + bcs::to_bytes(&amount).unwrap(), ], )) } -/// Initialize the validator account and give ownership to the signing account. -pub fn stake_initialize_validator( - consensus_pubkey: Vec, - proof_of_possession: Vec, - network_addresses: Vec, - fullnode_addresses: Vec, -) -> TransactionPayload { +/// Creating a resource that stores balance of `CoinType` on user's account, withdraw and deposit event handlers. +/// Required if user wants to start accepting deposits of `CoinType` in his account. +pub fn managed_coin_register(coin_type: TypeTag) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("managed_coin").to_owned(), ), - ident_str!("initialize_validator").to_owned(), + ident_str!("register").to_owned(), + vec![coin_type], vec![], - vec![ - bcs::to_bytes(&consensus_pubkey).unwrap(), - bcs::to_bytes(&proof_of_possession).unwrap(), - bcs::to_bytes(&network_addresses).unwrap(), - bcs::to_bytes(&fullnode_addresses).unwrap(), - ], )) } -/// This can only called by the operator of the validator/staking pool. -pub fn stake_join_validator_set(pool_address: AccountAddress) -> TransactionPayload { +/// Similar to add_owners, but only allow adding one owner. +pub fn multisig_account_add_owner(new_owner: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("join_validator_set").to_owned(), + ident_str!("add_owner").to_owned(), vec![], - vec![bcs::to_bytes(&pool_address).unwrap()], + vec![bcs::to_bytes(&new_owner).unwrap()], )) } -/// Request to have `pool_address` leave the validator set. The validator is only actually removed from the set when -/// the next epoch starts. -/// The last validator in the set cannot leave. This is an edge case that should never happen as long as the network -/// is still operational. +/// Add new owners to the multisig account. This can only be invoked by the multisig account itself, through the +/// proposal flow. /// -/// Can only be called by the operator of the validator/staking pool. -pub fn stake_leave_validator_set(pool_address: AccountAddress) -> TransactionPayload { +/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This +/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to +/// maliciously alter the owners list. +pub fn multisig_account_add_owners(new_owners: Vec) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("leave_validator_set").to_owned(), + ident_str!("add_owners").to_owned(), vec![], - vec![bcs::to_bytes(&pool_address).unwrap()], + vec![bcs::to_bytes(&new_owners).unwrap()], )) } -/// Move `amount` of coins from pending_inactive to active. -pub fn stake_reactivate_stake(amount: u64) -> TransactionPayload { +/// Add owners then update number of signatures required, in a single operation. +pub fn multisig_account_add_owners_and_update_signatures_required( + new_owners: Vec, + new_num_signatures_required: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("reactivate_stake").to_owned(), + ident_str!("add_owners_and_update_signatures_required").to_owned(), vec![], - vec![bcs::to_bytes(&amount).unwrap()], + vec![ + bcs::to_bytes(&new_owners).unwrap(), + bcs::to_bytes(&new_num_signatures_required).unwrap(), + ], )) } -/// Rotate the consensus key of the validator, it'll take effect in next epoch. -pub fn stake_rotate_consensus_key( - pool_address: AccountAddress, - new_consensus_pubkey: Vec, - proof_of_possession: Vec, +/// Approve a multisig transaction. +pub fn multisig_account_approve_transaction( + multisig_account: AccountAddress, + sequence_number: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5062,71 +5732,105 @@ pub fn stake_rotate_consensus_key( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("rotate_consensus_key").to_owned(), + ident_str!("approve_transaction").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&new_consensus_pubkey).unwrap(), - bcs::to_bytes(&proof_of_possession).unwrap(), + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&sequence_number).unwrap(), ], )) } -/// Allows an owner to change the delegated voter of the stake pool. -pub fn stake_set_delegated_voter(new_voter: AccountAddress) -> TransactionPayload { +/// Creates a new multisig account and add the signer as a single owner. +pub fn multisig_account_create( + num_signatures_required: u64, + metadata_keys: Vec>, + metadata_values: Vec>, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_delegated_voter").to_owned(), + ident_str!("create").to_owned(), vec![], - vec![bcs::to_bytes(&new_voter).unwrap()], + vec![ + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), + ], )) } -/// Allows an owner to change the operator of the stake pool. -pub fn stake_set_operator(new_operator: AccountAddress) -> TransactionPayload { +/// Create a multisig transaction, which will have one approval initially (from the creator). +pub fn multisig_account_create_transaction( + multisig_account: AccountAddress, + payload: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_operator").to_owned(), + ident_str!("create_transaction").to_owned(), vec![], - vec![bcs::to_bytes(&new_operator).unwrap()], + vec![ + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&payload).unwrap(), + ], )) } -/// Similar to unlock_with_cap but will use ownership capability from the signing account. -pub fn stake_unlock(amount: u64) -> TransactionPayload { +/// Create a multisig transaction with a transaction hash instead of the full payload. +/// This means the payload will be stored off chain for gas saving. Later, during execution, the executor will need +/// to provide the full payload, which will be validated against the hash stored on-chain. +pub fn multisig_account_create_transaction_with_hash( + multisig_account: AccountAddress, + payload_hash: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("unlock").to_owned(), + ident_str!("create_transaction_with_hash").to_owned(), vec![], - vec![bcs::to_bytes(&amount).unwrap()], + vec![ + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&payload_hash).unwrap(), + ], )) } -/// Update the network and full node addresses of the validator. This only takes effect in the next epoch. -pub fn stake_update_network_and_fullnode_addresses( - pool_address: AccountAddress, - new_network_addresses: Vec, - new_fullnode_addresses: Vec, +/// Creates a new multisig account on top of an existing account. +/// +/// This offers a migration path for an existing account with a multi-ed25519 auth key (native multisig account). +/// In order to ensure a malicious module cannot obtain backdoor control over an existing account, a signed message +/// with a valid signature from the account's auth key is required. +/// +/// Note that this does not revoke auth key-based control over the account. Owners should separately rotate the auth +/// key after they are fully migrated to the new multisig account. Alternatively, they can call +/// create_with_existing_account_and_revoke_auth_key instead. +pub fn multisig_account_create_with_existing_account( + multisig_address: AccountAddress, + owners: Vec, + num_signatures_required: u64, + account_scheme: u8, + account_public_key: Vec, + create_multisig_account_signed_message: Vec, + metadata_keys: Vec>, + metadata_values: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5134,60 +5838,104 @@ pub fn stake_update_network_and_fullnode_addresses( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("update_network_and_fullnode_addresses").to_owned(), + ident_str!("create_with_existing_account").to_owned(), vec![], vec![ - bcs::to_bytes(&pool_address).unwrap(), - bcs::to_bytes(&new_network_addresses).unwrap(), - bcs::to_bytes(&new_fullnode_addresses).unwrap(), + bcs::to_bytes(&multisig_address).unwrap(), + bcs::to_bytes(&owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&account_scheme).unwrap(), + bcs::to_bytes(&account_public_key).unwrap(), + bcs::to_bytes(&create_multisig_account_signed_message).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), ], )) } -/// Withdraw from `account`'s inactive stake. -pub fn stake_withdraw(withdraw_amount: u64) -> TransactionPayload { +/// Creates a new multisig account on top of an existing account and immediately rotate the origin auth key to 0x0. +/// +/// Note: If the original account is a resource account, this does not revoke all control over it as if any +/// SignerCapability of the resource account still exists, it can still be used to generate the signer for the +/// account. +pub fn multisig_account_create_with_existing_account_and_revoke_auth_key( + multisig_address: AccountAddress, + owners: Vec, + num_signatures_required: u64, + account_scheme: u8, + account_public_key: Vec, + create_multisig_account_signed_message: Vec, + metadata_keys: Vec>, + metadata_values: Vec>, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("stake").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("withdraw").to_owned(), + ident_str!("create_with_existing_account_and_revoke_auth_key").to_owned(), vec![], - vec![bcs::to_bytes(&withdraw_amount).unwrap()], + vec![ + bcs::to_bytes(&multisig_address).unwrap(), + bcs::to_bytes(&owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&account_scheme).unwrap(), + bcs::to_bytes(&account_public_key).unwrap(), + bcs::to_bytes(&create_multisig_account_signed_message).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), + ], )) } -/// Add more stake to an existing staking contract. -pub fn staking_contract_add_stake(operator: AccountAddress, amount: u64) -> TransactionPayload { +/// Private entry function that creates a new multisig account on top of an existing account and immediately rotate +/// the origin auth key to 0x0. +/// +/// Note: If the original account is a resource account, this does not revoke all control over it as if any +/// SignerCapability of the resource account still exists, it can still be used to generate the signer for the +/// account. +pub fn multisig_account_create_with_existing_account_and_revoke_auth_key_call( + owners: Vec, + num_signatures_required: u64, + metadata_keys: Vec>, + metadata_values: Vec>, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("add_stake").to_owned(), + ident_str!("create_with_existing_account_and_revoke_auth_key_call").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), ], )) } -/// Staker can call this function to create a simple staking contract with a specified operator. -pub fn staking_contract_create_staking_contract( - operator: AccountAddress, - voter: AccountAddress, - amount: u64, - commission_percentage: u64, - contract_creation_seed: Vec, +/// Private entry function that creates a new multisig account on top of an existing account. +/// +/// This offers a migration path for an existing account with any type of auth key. +/// +/// Note that this does not revoke auth key-based control over the account. Owners should separately rotate the auth +/// key after they are fully migrated to the new multisig account. Alternatively, they can call +/// create_with_existing_account_and_revoke_auth_key_call instead. +pub fn multisig_account_create_with_existing_account_call( + owners: Vec, + num_signatures_required: u64, + metadata_keys: Vec>, + metadata_values: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5195,25 +5943,30 @@ pub fn staking_contract_create_staking_contract( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("create_staking_contract").to_owned(), + ident_str!("create_with_existing_account_call").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&voter).unwrap(), - bcs::to_bytes(&amount).unwrap(), - bcs::to_bytes(&commission_percentage).unwrap(), - bcs::to_bytes(&contract_creation_seed).unwrap(), + bcs::to_bytes(&owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), ], )) } -/// Allow anyone to distribute already unlocked funds. This does not affect reward compounding and therefore does -/// not need to be restricted to just the staker or operator. -pub fn staking_contract_distribute( - staker: AccountAddress, - operator: AccountAddress, +/// Creates a new multisig account with the specified additional owner list and signatures required. +/// +/// @param additional_owners The owner account who calls this function cannot be in the additional_owners and there +/// cannot be any duplicate owners in the list. +/// @param num_signatures_required The number of signatures required to execute a transaction. Must be at least 1 and +/// at most the total number of owners. +pub fn multisig_account_create_with_owners( + additional_owners: Vec, + num_signatures_required: u64, + metadata_keys: Vec>, + metadata_values: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5221,24 +5974,28 @@ pub fn staking_contract_distribute( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("distribute").to_owned(), + ident_str!("create_with_owners").to_owned(), vec![], vec![ - bcs::to_bytes(&staker).unwrap(), - bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&additional_owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), ], )) } -/// Unlock commission amount from the stake pool. Operator needs to wait for the amount to become withdrawable -/// at the end of the stake pool's lockup period before they can actually can withdraw_commission. +/// Like `create_with_owners`, but removes the calling account after creation. /// -/// Only staker, operator or beneficiary can call this. -pub fn staking_contract_request_commission( - staker: AccountAddress, - operator: AccountAddress, +/// This is for creating a vanity multisig account from a bootstrapping account that should not +/// be an owner after the vanity multisig address has been secured. +pub fn multisig_account_create_with_owners_then_remove_bootstrapper( + owners: Vec, + num_signatures_required: u64, + metadata_keys: Vec>, + metadata_values: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5246,38 +6003,41 @@ pub fn staking_contract_request_commission( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("request_commission").to_owned(), + ident_str!("create_with_owners_then_remove_bootstrapper").to_owned(), vec![], vec![ - bcs::to_bytes(&staker).unwrap(), - bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&owners).unwrap(), + bcs::to_bytes(&num_signatures_required).unwrap(), + bcs::to_bytes(&metadata_keys).unwrap(), + bcs::to_bytes(&metadata_values).unwrap(), ], )) } -/// Convenient function to allow the staker to reset their stake pool's lockup period to start now. -pub fn staking_contract_reset_lockup(operator: AccountAddress) -> TransactionPayload { +/// Remove the next transaction if it has sufficient owner rejections. +pub fn multisig_account_execute_rejected_transaction( + multisig_account: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("reset_lockup").to_owned(), + ident_str!("execute_rejected_transaction").to_owned(), vec![], - vec![bcs::to_bytes(&operator).unwrap()], + vec![bcs::to_bytes(&multisig_account).unwrap()], )) } -/// Allows an operator to change its beneficiary. Any existing unpaid commission rewards will be paid to the new -/// beneficiary. To ensures payment to the current beneficiary, one should first call `distribute` before switching -/// the beneficiary. An operator can set one beneficiary for staking contract pools, not a separate one for each pool. -pub fn staking_contract_set_beneficiary_for_operator( - new_beneficiary: AccountAddress, +/// Remove the next transactions until the final_sequence_number if they have sufficient owner rejections. +pub fn multisig_account_execute_rejected_transactions( + multisig_account: AccountAddress, + final_sequence_number: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5285,19 +6045,21 @@ pub fn staking_contract_set_beneficiary_for_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_beneficiary_for_operator").to_owned(), + ident_str!("execute_rejected_transactions").to_owned(), vec![], - vec![bcs::to_bytes(&new_beneficiary).unwrap()], + vec![ + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&final_sequence_number).unwrap(), + ], )) } -/// Allows staker to switch operator without going through the lenghthy process to unstake. -pub fn staking_contract_switch_operator( - old_operator: AccountAddress, - new_operator: AccountAddress, - new_commission_percentage: u64, +/// Reject a multisig transaction. +pub fn multisig_account_reject_transaction( + multisig_account: AccountAddress, + sequence_number: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5305,81 +6067,81 @@ pub fn staking_contract_switch_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("switch_operator").to_owned(), + ident_str!("reject_transaction").to_owned(), vec![], vec![ - bcs::to_bytes(&old_operator).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), - bcs::to_bytes(&new_commission_percentage).unwrap(), + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&sequence_number).unwrap(), ], )) } -/// Allows staker to switch operator without going through the lenghthy process to unstake, without resetting commission. -pub fn staking_contract_switch_operator_with_same_commission( - old_operator: AccountAddress, - new_operator: AccountAddress, -) -> TransactionPayload { +/// Similar to remove_owners, but only allow removing one owner. +pub fn multisig_account_remove_owner(owner_to_remove: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("switch_operator_with_same_commission").to_owned(), + ident_str!("remove_owner").to_owned(), vec![], - vec![ - bcs::to_bytes(&old_operator).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), - ], + vec![bcs::to_bytes(&owner_to_remove).unwrap()], )) } -/// Unlock all accumulated rewards since the last recorded principals. -pub fn staking_contract_unlock_rewards(operator: AccountAddress) -> TransactionPayload { +/// Remove owners from the multisig account. This can only be invoked by the multisig account itself, through the +/// proposal flow. +/// +/// This function skips any owners who are not in the multisig account's list of owners. +/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This +/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to +/// maliciously alter the owners list. +pub fn multisig_account_remove_owners(owners_to_remove: Vec) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("unlock_rewards").to_owned(), + ident_str!("remove_owners").to_owned(), vec![], - vec![bcs::to_bytes(&operator).unwrap()], + vec![bcs::to_bytes(&owners_to_remove).unwrap()], )) } -/// Staker can call this to request withdrawal of part or all of their staking_contract. -/// This also triggers paying commission to the operator for accounting simplicity. -pub fn staking_contract_unlock_stake(operator: AccountAddress, amount: u64) -> TransactionPayload { +/// Swap an owner in for an old one, without changing required signatures. +pub fn multisig_account_swap_owner( + to_swap_in: AccountAddress, + to_swap_out: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("unlock_stake").to_owned(), + ident_str!("swap_owner").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&to_swap_in).unwrap(), + bcs::to_bytes(&to_swap_out).unwrap(), ], )) } -/// Convenience function to allow a staker to update the commission percentage paid to the operator. -/// TODO: fix the typo in function name. commision -> commission -pub fn staking_contract_update_commision( - operator: AccountAddress, - new_commission_percentage: u64, +/// Swap owners in and out, without changing required signatures. +pub fn multisig_account_swap_owners( + to_swap_in: Vec, + to_swap_out: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5387,21 +6149,22 @@ pub fn staking_contract_update_commision( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("update_commision").to_owned(), + ident_str!("swap_owners").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&new_commission_percentage).unwrap(), + bcs::to_bytes(&to_swap_in).unwrap(), + bcs::to_bytes(&to_swap_out).unwrap(), ], )) } -/// Convenient function to allow the staker to update the voter address in a staking contract they made. -pub fn staking_contract_update_voter( - operator: AccountAddress, - new_voter: AccountAddress, +/// Swap owners in and out, updating number of required signatures. +pub fn multisig_account_swap_owners_and_update_signatures_required( + new_owners: Vec, + owners_to_remove: Vec, + new_num_signatures_required: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5409,20 +6172,28 @@ pub fn staking_contract_update_voter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_contract").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("update_voter").to_owned(), + ident_str!("swap_owners_and_update_signatures_required").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), + bcs::to_bytes(&new_owners).unwrap(), + bcs::to_bytes(&owners_to_remove).unwrap(), + bcs::to_bytes(&new_num_signatures_required).unwrap(), ], )) } -pub fn staking_proxy_set_operator( - old_operator: AccountAddress, - new_operator: AccountAddress, +/// Allow the multisig account to update its own metadata. Note that this overrides the entire existing metadata. +/// If any attributes are not specified in the metadata, they will be removed! +/// +/// This can only be invoked by the multisig account itself, through the proposal flow. +/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This +/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to +/// maliciously alter the number of signatures required. +pub fn multisig_account_update_metadata( + keys: Vec>, + values: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5430,50 +6201,45 @@ pub fn staking_proxy_set_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_operator").to_owned(), + ident_str!("update_metadata").to_owned(), vec![], vec![ - bcs::to_bytes(&old_operator).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), + bcs::to_bytes(&keys).unwrap(), + bcs::to_bytes(&values).unwrap(), ], )) } -pub fn staking_proxy_set_stake_pool_operator(new_operator: AccountAddress) -> TransactionPayload { +/// Update the number of signatures required to execute transaction in the specified multisig account. +/// +/// This can only be invoked by the multisig account itself, through the proposal flow. +/// Note that this function is not public so it can only be invoked directly instead of via a module or script. This +/// ensures that a multisig transaction cannot lead to another module obtaining the multisig signer and using it to +/// maliciously alter the number of signatures required. +pub fn multisig_account_update_signatures_required( + new_num_signatures_required: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_stake_pool_operator").to_owned(), + ident_str!("update_signatures_required").to_owned(), vec![], - vec![bcs::to_bytes(&new_operator).unwrap()], - )) -} - -pub fn staking_proxy_set_stake_pool_voter(new_voter: AccountAddress) -> TransactionPayload { - TransactionPayload::EntryFunction(EntryFunction::new( - ModuleId::new( - AccountAddress::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, - ]), - ident_str!("staking_proxy").to_owned(), - ), - ident_str!("set_stake_pool_voter").to_owned(), - vec![], - vec![bcs::to_bytes(&new_voter).unwrap()], + vec![bcs::to_bytes(&new_num_signatures_required).unwrap()], )) } -pub fn staking_proxy_set_staking_contract_operator( - old_operator: AccountAddress, - new_operator: AccountAddress, +/// Generic function that can be used to either approve or reject a multisig transaction +pub fn multisig_account_vote_transaction( + multisig_account: AccountAddress, + sequence_number: u64, + approved: bool, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5481,20 +6247,24 @@ pub fn staking_proxy_set_staking_contract_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_staking_contract_operator").to_owned(), + ident_str!("vote_transaction").to_owned(), vec![], vec![ - bcs::to_bytes(&old_operator).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&sequence_number).unwrap(), + bcs::to_bytes(&approved).unwrap(), ], )) } -pub fn staking_proxy_set_staking_contract_voter( - operator: AccountAddress, - new_voter: AccountAddress, +/// Generic function that can be used to either approve or reject a batch of transactions within a specified range. +pub fn multisig_account_vote_transactions( + multisig_account: AccountAddress, + starting_sequence_number: u64, + final_sequence_number: u64, + approved: bool, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5502,20 +6272,26 @@ pub fn staking_proxy_set_staking_contract_voter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_staking_contract_voter").to_owned(), + ident_str!("vote_transactions").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&starting_sequence_number).unwrap(), + bcs::to_bytes(&final_sequence_number).unwrap(), + bcs::to_bytes(&approved).unwrap(), ], )) } -pub fn staking_proxy_set_vesting_contract_operator( - old_operator: AccountAddress, - new_operator: AccountAddress, +/// Generic function that can be used to either approve or reject a multisig transaction +/// Retained for backward compatibility: the function with the typographical error in its name +/// will continue to be an accessible entry point. +pub fn multisig_account_vote_transanction( + multisig_account: AccountAddress, + sequence_number: u64, + approved: bool, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5523,20 +6299,33 @@ pub fn staking_proxy_set_vesting_contract_operator( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("multisig_account").to_owned(), ), - ident_str!("set_vesting_contract_operator").to_owned(), + ident_str!("vote_transanction").to_owned(), vec![], vec![ - bcs::to_bytes(&old_operator).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), + bcs::to_bytes(&multisig_account).unwrap(), + bcs::to_bytes(&sequence_number).unwrap(), + bcs::to_bytes(&approved).unwrap(), ], )) } -pub fn staking_proxy_set_vesting_contract_voter( - operator: AccountAddress, - new_voter: AccountAddress, +/// Completes a bridge transfer on the destination chain. +/// +/// @param caller The signer representing the bridge relayer. +/// @param initiator The initiator's Ethereum address as a vector of bytes. +/// @param bridge_transfer_id The unique identifier for the bridge transfer. +/// @param recipient The address of the recipient on the Aptos blockchain. +/// @param amount The amount of assets to be locked. +/// @param nonce The unique nonce for the transfer. +/// @abort If the caller is not the bridge relayer or the transfer has already been processed. +pub fn native_bridge_complete_bridge_transfer( + _bridge_transfer_id: Vec, + _initiator: Vec, + _recipient: AccountAddress, + _amount: u64, + _nonce: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5544,20 +6333,29 @@ pub fn staking_proxy_set_vesting_contract_voter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("native_bridge").to_owned(), ), - ident_str!("set_vesting_contract_voter").to_owned(), + ident_str!("complete_bridge_transfer").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), + bcs::to_bytes(&_bridge_transfer_id).unwrap(), + bcs::to_bytes(&_initiator).unwrap(), + bcs::to_bytes(&_recipient).unwrap(), + bcs::to_bytes(&_amount).unwrap(), + bcs::to_bytes(&_nonce).unwrap(), ], )) } -pub fn staking_proxy_set_voter( - operator: AccountAddress, - new_voter: AccountAddress, +/// Initiate a bridge transfer of MOVE from Movement to Ethereum +/// Anyone can initiate a bridge transfer from the source chain +/// The amount is burnt from the initiator and the module-level nonce is incremented +/// @param initiator The initiator's Ethereum address as a vector of bytes. +/// @param recipient The address of the recipient on the Aptos blockchain. +/// @param amount The amount of assets to be locked. +pub fn native_bridge_initiate_bridge_transfer( + _recipient: Vec, + _amount: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5565,125 +6363,134 @@ pub fn staking_proxy_set_voter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("staking_proxy").to_owned(), + ident_str!("native_bridge").to_owned(), ), - ident_str!("set_voter").to_owned(), + ident_str!("initiate_bridge_transfer").to_owned(), vec![], vec![ - bcs::to_bytes(&operator).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), + bcs::to_bytes(&_recipient).unwrap(), + bcs::to_bytes(&_amount).unwrap(), ], )) } -pub fn transaction_fee_convert_to_aptos_fa_burn_ref() -> TransactionPayload { +/// Updates the bridge fee, requiring relayer validation. +/// +/// @param relayer The signer representing the Relayer. +/// @param new_bridge_fee The new bridge fee to be set. +/// @abort If the new bridge fee is the same as the old bridge fee. +pub fn native_bridge_update_bridge_fee(_new_bridge_fee: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("transaction_fee").to_owned(), + ident_str!("native_bridge").to_owned(), ), - ident_str!("convert_to_aptos_fa_burn_ref").to_owned(), - vec![], + ident_str!("update_bridge_fee").to_owned(), vec![], + vec![bcs::to_bytes(&_new_bridge_fee).unwrap()], )) } -/// Used in on-chain governances to update the major version for the next epoch. -/// Example usage: -/// - `aptos_framework::version::set_for_next_epoch(&framework_signer, new_version);` -/// - `aptos_framework::aptos_governance::reconfigure(&framework_signer);` -pub fn version_set_for_next_epoch(major: u64) -> TransactionPayload { +/// Updates the insurance budget divider, requiring governance validation. +/// +/// @param aptos_framework The signer representing the Aptos framework. +/// @param new_insurance_budget_divider The new insurance budget divider to be set. +/// @abort If the new insurance budget divider is the same as the old insurance budget divider. +pub fn native_bridge_update_insurance_budget_divider( + _new_insurance_budget_divider: u64, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("version").to_owned(), + ident_str!("native_bridge").to_owned(), ), - ident_str!("set_for_next_epoch").to_owned(), + ident_str!("update_insurance_budget_divider").to_owned(), vec![], - vec![bcs::to_bytes(&major).unwrap()], + vec![bcs::to_bytes(&_new_insurance_budget_divider).unwrap()], )) } -/// Deprecated by `set_for_next_epoch()`. -/// -/// WARNING: calling this while randomness is enabled will trigger a new epoch without randomness! +/// Updates the insurance fund, requiring governance validation. /// -/// TODO: update all the tests that reference this function, then disable this function. -pub fn version_set_version(major: u64) -> TransactionPayload { +/// @param aptos_framework The signer representing the Aptos framework. +/// @param new_insurance_fund The new insurance fund to be set. +/// @abort If the new insurance fund is the same as the old insurance fund. +pub fn native_bridge_update_insurance_fund( + _new_insurance_fund: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("version").to_owned(), + ident_str!("native_bridge").to_owned(), ), - ident_str!("set_version").to_owned(), + ident_str!("update_insurance_fund").to_owned(), vec![], - vec![bcs::to_bytes(&major).unwrap()], + vec![bcs::to_bytes(&_new_insurance_fund).unwrap()], )) } -/// Withdraw all funds to the preset vesting contract's withdrawal address. This can only be called if the contract -/// has already been terminated. -pub fn vesting_admin_withdraw(contract_address: AccountAddress) -> TransactionPayload { +pub fn nonce_validation_add_nonce_buckets(count: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("nonce_validation").to_owned(), ), - ident_str!("admin_withdraw").to_owned(), + ident_str!("add_nonce_buckets").to_owned(), vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], + vec![bcs::to_bytes(&count).unwrap()], )) } -/// Distribute any withdrawable stake from the stake pool. -pub fn vesting_distribute(contract_address: AccountAddress) -> TransactionPayload { +pub fn nonce_validation_initialize_nonce_table() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("nonce_validation").to_owned(), ), - ident_str!("distribute").to_owned(), + ident_str!("initialize_nonce_table").to_owned(), + vec![], vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], )) } -/// Call `distribute` for many vesting contracts. -pub fn vesting_distribute_many(contract_addresses: Vec) -> TransactionPayload { +/// Entry function that can be used to transfer, if allow_ungated_transfer is set true. +pub fn object_transfer_call(object: AccountAddress, to: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("object").to_owned(), ), - ident_str!("distribute_many").to_owned(), + ident_str!("transfer_call").to_owned(), vec![], - vec![bcs::to_bytes(&contract_addresses).unwrap()], + vec![bcs::to_bytes(&object).unwrap(), bcs::to_bytes(&to).unwrap()], )) } -/// Remove the beneficiary for the given shareholder. All distributions will sent directly to the shareholder -/// account. -pub fn vesting_reset_beneficiary( - contract_address: AccountAddress, - shareholder: AccountAddress, +/// Creates a new object with a unique address derived from the publisher address and the object seed. +/// Publishes the code passed in the function to the newly created object. +/// The caller must provide package metadata describing the package via `metadata_serialized` and +/// the code to be published via `code`. This contains a vector of modules to be deployed on-chain. +pub fn object_code_deployment_publish( + metadata_serialized: Vec, + code: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5691,36 +6498,37 @@ pub fn vesting_reset_beneficiary( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("object_code_deployment").to_owned(), ), - ident_str!("reset_beneficiary").to_owned(), + ident_str!("publish").to_owned(), vec![], vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&shareholder).unwrap(), + bcs::to_bytes(&metadata_serialized).unwrap(), + bcs::to_bytes(&code).unwrap(), ], )) } -pub fn vesting_reset_lockup(contract_address: AccountAddress) -> TransactionPayload { +/// Revoke all storable permission handle of the signer immediately. +pub fn permissioned_signer_revoke_all_handles() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("permissioned_signer").to_owned(), ), - ident_str!("reset_lockup").to_owned(), + ident_str!("revoke_all_handles").to_owned(), + vec![], vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], )) } -pub fn vesting_set_beneficiary( - contract_address: AccountAddress, - shareholder: AccountAddress, - new_beneficiary: AccountAddress, +/// Revoke a specific storable permission handle immediately. This will disallow owner of +/// the storable permission handle to derive signer from it anymore. +pub fn permissioned_signer_revoke_permission_storage_address( + permissions_storage_addr: AccountAddress, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5728,37 +6536,47 @@ pub fn vesting_set_beneficiary( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("permissioned_signer").to_owned(), ), - ident_str!("set_beneficiary").to_owned(), + ident_str!("revoke_permission_storage_address").to_owned(), vec![], - vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&shareholder).unwrap(), - bcs::to_bytes(&new_beneficiary).unwrap(), - ], + vec![bcs::to_bytes(&permissions_storage_addr).unwrap()], )) } -/// Set the beneficiary for the operator. -pub fn vesting_set_beneficiary_for_operator(new_beneficiary: AccountAddress) -> TransactionPayload { +/// Creates a new resource account and rotates the authentication key to either +/// the optional auth key if it is non-empty (though auth keys are 32-bytes) +/// or the source accounts current auth key. +pub fn resource_account_create_resource_account( + seed: Vec, + optional_auth_key: Vec, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("resource_account").to_owned(), ), - ident_str!("set_beneficiary_for_operator").to_owned(), + ident_str!("create_resource_account").to_owned(), vec![], - vec![bcs::to_bytes(&new_beneficiary).unwrap()], + vec![ + bcs::to_bytes(&seed).unwrap(), + bcs::to_bytes(&optional_auth_key).unwrap(), + ], )) } -pub fn vesting_set_beneficiary_resetter( - contract_address: AccountAddress, - beneficiary_resetter: AccountAddress, +/// Creates a new resource account, transfer the amount of coins from the origin to the resource +/// account, and rotates the authentication key to either the optional auth key if it is +/// non-empty (though auth keys are 32-bytes) or the source accounts current auth key. Note, +/// this function adds additional resource ownership to the resource account and should only be +/// used for resource accounts that need access to `Coin`. +pub fn resource_account_create_resource_account_and_fund( + seed: Vec, + optional_auth_key: Vec, + fund_amount: u64, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5766,21 +6584,24 @@ pub fn vesting_set_beneficiary_resetter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("resource_account").to_owned(), ), - ident_str!("set_beneficiary_resetter").to_owned(), + ident_str!("create_resource_account_and_fund").to_owned(), vec![], vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&beneficiary_resetter).unwrap(), + bcs::to_bytes(&seed).unwrap(), + bcs::to_bytes(&optional_auth_key).unwrap(), + bcs::to_bytes(&fund_amount).unwrap(), ], )) } -pub fn vesting_set_management_role( - contract_address: AccountAddress, - role: Vec, - role_holder: AccountAddress, +/// Creates a new resource account, publishes the package under this account transaction under +/// this account and leaves the signer cap readily available for pickup. +pub fn resource_account_create_resource_account_and_publish_package( + seed: Vec, + metadata_serialized: Vec, + code: Vec>, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5788,69 +6609,83 @@ pub fn vesting_set_management_role( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("resource_account").to_owned(), ), - ident_str!("set_management_role").to_owned(), + ident_str!("create_resource_account_and_publish_package").to_owned(), vec![], vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&role).unwrap(), - bcs::to_bytes(&role_holder).unwrap(), + bcs::to_bytes(&seed).unwrap(), + bcs::to_bytes(&metadata_serialized).unwrap(), + bcs::to_bytes(&code).unwrap(), ], )) } -/// Terminate the vesting contract and send all funds back to the withdrawal address. -pub fn vesting_terminate_vesting_contract(contract_address: AccountAddress) -> TransactionPayload { +/// Add `amount` of coins from the `account` owning the StakePool. +pub fn stake_add_stake(amount: u64) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("terminate_vesting_contract").to_owned(), + ident_str!("add_stake").to_owned(), vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], + vec![bcs::to_bytes(&amount).unwrap()], )) } -/// Unlock any accumulated rewards. -pub fn vesting_unlock_rewards(contract_address: AccountAddress) -> TransactionPayload { +/// Similar to increase_lockup_with_cap but will use ownership capability from the signing account. +pub fn stake_increase_lockup() -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("unlock_rewards").to_owned(), + ident_str!("increase_lockup").to_owned(), + vec![], vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], )) } -/// Call `unlock_rewards` for many vesting contracts. -pub fn vesting_unlock_rewards_many(contract_addresses: Vec) -> TransactionPayload { +/// Initialize the validator account and give ownership to the signing account +/// except it leaves the ValidatorConfig to be set by another entity. +/// Note: this triggers setting the operator and owner, set it to the account's address +/// to set later. +pub fn stake_initialize_stake_owner( + initial_stake_amount: u64, + operator: AccountAddress, + voter: AccountAddress, +) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("unlock_rewards_many").to_owned(), + ident_str!("initialize_stake_owner").to_owned(), vec![], - vec![bcs::to_bytes(&contract_addresses).unwrap()], + vec![ + bcs::to_bytes(&initial_stake_amount).unwrap(), + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&voter).unwrap(), + ], )) } -pub fn vesting_update_commission_percentage( - contract_address: AccountAddress, - new_commission_percentage: u64, +/// Initialize the validator account and give ownership to the signing account. +pub fn stake_initialize_validator( + consensus_pubkey: Vec, + proof_of_possession: Vec, + network_addresses: Vec, + fullnode_addresses: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5858,64 +6693,77 @@ pub fn vesting_update_commission_percentage( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("update_commission_percentage").to_owned(), + ident_str!("initialize_validator").to_owned(), vec![], vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&new_commission_percentage).unwrap(), + bcs::to_bytes(&consensus_pubkey).unwrap(), + bcs::to_bytes(&proof_of_possession).unwrap(), + bcs::to_bytes(&network_addresses).unwrap(), + bcs::to_bytes(&fullnode_addresses).unwrap(), ], )) } -pub fn vesting_update_operator( - contract_address: AccountAddress, - new_operator: AccountAddress, - commission_percentage: u64, -) -> TransactionPayload { +/// This can only called by the operator of the validator/staking pool. +pub fn stake_join_validator_set(pool_address: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("update_operator").to_owned(), + ident_str!("join_validator_set").to_owned(), vec![], - vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), - bcs::to_bytes(&commission_percentage).unwrap(), - ], + vec![bcs::to_bytes(&pool_address).unwrap()], )) } -pub fn vesting_update_operator_with_same_commission( - contract_address: AccountAddress, - new_operator: AccountAddress, -) -> TransactionPayload { +/// Request to have `pool_address` leave the validator set. The validator is only actually removed from the set when +/// the next epoch starts. +/// The last validator in the set cannot leave. This is an edge case that should never happen as long as the network +/// is still operational. +/// +/// Can only be called by the operator of the validator/staking pool. +pub fn stake_leave_validator_set(pool_address: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("update_operator_with_same_commission").to_owned(), + ident_str!("leave_validator_set").to_owned(), vec![], - vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&new_operator).unwrap(), - ], + vec![bcs::to_bytes(&pool_address).unwrap()], )) } -pub fn vesting_update_voter( - contract_address: AccountAddress, - new_voter: AccountAddress, +/// Move `amount` of coins from pending_inactive to active. +pub fn stake_reactivate_stake(amount: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("stake").to_owned(), + ), + ident_str!("reactivate_stake").to_owned(), + vec![], + vec![bcs::to_bytes(&amount).unwrap()], + )) +} + +/// Rotate the consensus key of the validator, it'll take effect in next epoch. +pub fn stake_rotate_consensus_key( + pool_address: AccountAddress, + new_consensus_pubkey: Vec, + proof_of_possession: Vec, ) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( @@ -5923,636 +6771,2172 @@ pub fn vesting_update_voter( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("update_voter").to_owned(), + ident_str!("rotate_consensus_key").to_owned(), vec![], vec![ - bcs::to_bytes(&contract_address).unwrap(), - bcs::to_bytes(&new_voter).unwrap(), + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&new_consensus_pubkey).unwrap(), + bcs::to_bytes(&proof_of_possession).unwrap(), ], )) } -/// Unlock any vested portion of the grant. -pub fn vesting_vest(contract_address: AccountAddress) -> TransactionPayload { +/// Allows an owner to change the delegated voter of the stake pool. +pub fn stake_set_delegated_voter(new_voter: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("vest").to_owned(), + ident_str!("set_delegated_voter").to_owned(), vec![], - vec![bcs::to_bytes(&contract_address).unwrap()], + vec![bcs::to_bytes(&new_voter).unwrap()], )) } -/// Call `vest` for many vesting contracts. -pub fn vesting_vest_many(contract_addresses: Vec) -> TransactionPayload { +/// Allows an owner to change the operator of the stake pool. +pub fn stake_set_operator(new_operator: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( ModuleId::new( AccountAddress::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ]), - ident_str!("vesting").to_owned(), + ident_str!("stake").to_owned(), ), - ident_str!("vest_many").to_owned(), + ident_str!("set_operator").to_owned(), vec![], - vec![bcs::to_bytes(&contract_addresses).unwrap()], + vec![bcs::to_bytes(&new_operator).unwrap()], )) } -mod decoder { - use super::*; - pub fn account_offer_rotation_capability( + +/// Similar to unlock_with_cap but will use ownership capability from the signing account. +pub fn stake_unlock(amount: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("stake").to_owned(), + ), + ident_str!("unlock").to_owned(), + vec![], + vec![bcs::to_bytes(&amount).unwrap()], + )) +} + +/// Update the network and full node addresses of the validator. This only takes effect in the next epoch. +pub fn stake_update_network_and_fullnode_addresses( + pool_address: AccountAddress, + new_network_addresses: Vec, + new_fullnode_addresses: Vec, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("stake").to_owned(), + ), + ident_str!("update_network_and_fullnode_addresses").to_owned(), + vec![], + vec![ + bcs::to_bytes(&pool_address).unwrap(), + bcs::to_bytes(&new_network_addresses).unwrap(), + bcs::to_bytes(&new_fullnode_addresses).unwrap(), + ], + )) +} + +/// Withdraw from `account`'s inactive stake. +pub fn stake_withdraw(withdraw_amount: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("stake").to_owned(), + ), + ident_str!("withdraw").to_owned(), + vec![], + vec![bcs::to_bytes(&withdraw_amount).unwrap()], + )) +} + +/// Add more stake to an existing staking contract. +pub fn staking_contract_add_stake(operator: AccountAddress, amount: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("add_stake").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&amount).unwrap(), + ], + )) +} + +/// Staker can call this function to create a simple staking contract with a specified operator. +pub fn staking_contract_create_staking_contract( + operator: AccountAddress, + voter: AccountAddress, + amount: u64, + commission_percentage: u64, + contract_creation_seed: Vec, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("create_staking_contract").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&voter).unwrap(), + bcs::to_bytes(&amount).unwrap(), + bcs::to_bytes(&commission_percentage).unwrap(), + bcs::to_bytes(&contract_creation_seed).unwrap(), + ], + )) +} + +/// Allow anyone to distribute already unlocked funds. This does not affect reward compounding and therefore does +/// not need to be restricted to just the staker or operator. +pub fn staking_contract_distribute( + staker: AccountAddress, + operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("distribute").to_owned(), + vec![], + vec![ + bcs::to_bytes(&staker).unwrap(), + bcs::to_bytes(&operator).unwrap(), + ], + )) +} + +/// Unlock commission amount from the stake pool. Operator needs to wait for the amount to become withdrawable +/// at the end of the stake pool's lockup period before they can actually can withdraw_commission. +/// +/// Only staker, operator or beneficiary can call this. +pub fn staking_contract_request_commission( + staker: AccountAddress, + operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("request_commission").to_owned(), + vec![], + vec![ + bcs::to_bytes(&staker).unwrap(), + bcs::to_bytes(&operator).unwrap(), + ], + )) +} + +/// Convenient function to allow the staker to reset their stake pool's lockup period to start now. +pub fn staking_contract_reset_lockup(operator: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("reset_lockup").to_owned(), + vec![], + vec![bcs::to_bytes(&operator).unwrap()], + )) +} + +/// Allows an operator to change its beneficiary. Any existing unpaid commission rewards will be paid to the new +/// beneficiary. To ensures payment to the current beneficiary, one should first call `distribute` before switching +/// the beneficiary. An operator can set one beneficiary for staking contract pools, not a separate one for each pool. +pub fn staking_contract_set_beneficiary_for_operator( + new_beneficiary: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("set_beneficiary_for_operator").to_owned(), + vec![], + vec![bcs::to_bytes(&new_beneficiary).unwrap()], + )) +} + +/// Allows staker to switch operator without going through the lenghthy process to unstake. +pub fn staking_contract_switch_operator( + old_operator: AccountAddress, + new_operator: AccountAddress, + new_commission_percentage: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("switch_operator").to_owned(), + vec![], + vec![ + bcs::to_bytes(&old_operator).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + bcs::to_bytes(&new_commission_percentage).unwrap(), + ], + )) +} + +/// Allows staker to switch operator without going through the lenghthy process to unstake, without resetting commission. +pub fn staking_contract_switch_operator_with_same_commission( + old_operator: AccountAddress, + new_operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("switch_operator_with_same_commission").to_owned(), + vec![], + vec![ + bcs::to_bytes(&old_operator).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + ], + )) +} + +/// Unlock all accumulated rewards since the last recorded principals. +pub fn staking_contract_unlock_rewards(operator: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("unlock_rewards").to_owned(), + vec![], + vec![bcs::to_bytes(&operator).unwrap()], + )) +} + +/// Staker can call this to request withdrawal of part or all of their staking_contract. +/// This also triggers paying commission to the operator for accounting simplicity. +pub fn staking_contract_unlock_stake(operator: AccountAddress, amount: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("unlock_stake").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&amount).unwrap(), + ], + )) +} + +/// Convenience function to allow a staker to update the commission percentage paid to the operator. +/// TODO: fix the typo in function name. commision -> commission +pub fn staking_contract_update_commision( + operator: AccountAddress, + new_commission_percentage: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("update_commision").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&new_commission_percentage).unwrap(), + ], + )) +} + +/// Convenient function to allow the staker to update the voter address in a staking contract they made. +pub fn staking_contract_update_voter( + operator: AccountAddress, + new_voter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_contract").to_owned(), + ), + ident_str!("update_voter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_operator( + old_operator: AccountAddress, + new_operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_operator").to_owned(), + vec![], + vec![ + bcs::to_bytes(&old_operator).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_stake_pool_operator(new_operator: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_stake_pool_operator").to_owned(), + vec![], + vec![bcs::to_bytes(&new_operator).unwrap()], + )) +} + +pub fn staking_proxy_set_stake_pool_voter(new_voter: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_stake_pool_voter").to_owned(), + vec![], + vec![bcs::to_bytes(&new_voter).unwrap()], + )) +} + +pub fn staking_proxy_set_staking_contract_operator( + old_operator: AccountAddress, + new_operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_staking_contract_operator").to_owned(), + vec![], + vec![ + bcs::to_bytes(&old_operator).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_staking_contract_voter( + operator: AccountAddress, + new_voter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_staking_contract_voter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_vesting_contract_operator( + old_operator: AccountAddress, + new_operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_vesting_contract_operator").to_owned(), + vec![], + vec![ + bcs::to_bytes(&old_operator).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_vesting_contract_voter( + operator: AccountAddress, + new_voter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_vesting_contract_voter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), + ], + )) +} + +pub fn staking_proxy_set_voter( + operator: AccountAddress, + new_voter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("staking_proxy").to_owned(), + ), + ident_str!("set_voter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&operator).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), + ], + )) +} + +pub fn transaction_fee_convert_to_aptos_fa_burn_ref() -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("transaction_fee").to_owned(), + ), + ident_str!("convert_to_aptos_fa_burn_ref").to_owned(), + vec![], + vec![], + )) +} + +/// Used in on-chain governances to update the major version for the next epoch. +/// Example usage: +/// - `aptos_framework::version::set_for_next_epoch(&framework_signer, new_version);` +/// - `aptos_framework::aptos_governance::reconfigure(&framework_signer);` +pub fn version_set_for_next_epoch(major: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("version").to_owned(), + ), + ident_str!("set_for_next_epoch").to_owned(), + vec![], + vec![bcs::to_bytes(&major).unwrap()], + )) +} + +/// Deprecated by `set_for_next_epoch()`. +/// +/// WARNING: calling this while randomness is enabled will trigger a new epoch without randomness! +/// +/// TODO: update all the tests that reference this function, then disable this function. +pub fn version_set_version(major: u64) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("version").to_owned(), + ), + ident_str!("set_version").to_owned(), + vec![], + vec![bcs::to_bytes(&major).unwrap()], + )) +} + +/// Withdraw all funds to the preset vesting contract's withdrawal address. This can only be called if the contract +/// has already been terminated. +pub fn vesting_admin_withdraw(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("admin_withdraw").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +/// Distribute any withdrawable stake from the stake pool. +pub fn vesting_distribute(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("distribute").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +/// Call `distribute` for many vesting contracts. +pub fn vesting_distribute_many(contract_addresses: Vec) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("distribute_many").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_addresses).unwrap()], + )) +} + +/// Remove the beneficiary for the given shareholder. All distributions will sent directly to the shareholder +/// account. +pub fn vesting_reset_beneficiary( + contract_address: AccountAddress, + shareholder: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("reset_beneficiary").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&shareholder).unwrap(), + ], + )) +} + +pub fn vesting_reset_lockup(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("reset_lockup").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +pub fn vesting_set_beneficiary( + contract_address: AccountAddress, + shareholder: AccountAddress, + new_beneficiary: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("set_beneficiary").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&shareholder).unwrap(), + bcs::to_bytes(&new_beneficiary).unwrap(), + ], + )) +} + +/// Set the beneficiary for the operator. +pub fn vesting_set_beneficiary_for_operator(new_beneficiary: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("set_beneficiary_for_operator").to_owned(), + vec![], + vec![bcs::to_bytes(&new_beneficiary).unwrap()], + )) +} + +pub fn vesting_set_beneficiary_resetter( + contract_address: AccountAddress, + beneficiary_resetter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("set_beneficiary_resetter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&beneficiary_resetter).unwrap(), + ], + )) +} + +pub fn vesting_set_management_role( + contract_address: AccountAddress, + role: Vec, + role_holder: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("set_management_role").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&role).unwrap(), + bcs::to_bytes(&role_holder).unwrap(), + ], + )) +} + +/// Terminate the vesting contract and send all funds back to the withdrawal address. +pub fn vesting_terminate_vesting_contract(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("terminate_vesting_contract").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +/// Unlock any accumulated rewards. +pub fn vesting_unlock_rewards(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("unlock_rewards").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +/// Call `unlock_rewards` for many vesting contracts. +pub fn vesting_unlock_rewards_many(contract_addresses: Vec) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("unlock_rewards_many").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_addresses).unwrap()], + )) +} + +pub fn vesting_update_commission_percentage( + contract_address: AccountAddress, + new_commission_percentage: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("update_commission_percentage").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&new_commission_percentage).unwrap(), + ], + )) +} + +pub fn vesting_update_operator( + contract_address: AccountAddress, + new_operator: AccountAddress, + commission_percentage: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("update_operator").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + bcs::to_bytes(&commission_percentage).unwrap(), + ], + )) +} + +pub fn vesting_update_operator_with_same_commission( + contract_address: AccountAddress, + new_operator: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("update_operator_with_same_commission").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&new_operator).unwrap(), + ], + )) +} + +pub fn vesting_update_voter( + contract_address: AccountAddress, + new_voter: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("update_voter").to_owned(), + vec![], + vec![ + bcs::to_bytes(&contract_address).unwrap(), + bcs::to_bytes(&new_voter).unwrap(), + ], + )) +} + +/// Unlock any vested portion of the grant. +pub fn vesting_vest(contract_address: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("vest").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_address).unwrap()], + )) +} + +/// Call `vest` for many vesting contracts. +pub fn vesting_vest_many(contract_addresses: Vec) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("vesting").to_owned(), + ), + ident_str!("vest_many").to_owned(), + vec![], + vec![bcs::to_bytes(&contract_addresses).unwrap()], + )) +} + +/// Submit a verified claim about yourself. No issuer key is involved on this path: the trust +/// root is the attestor set plus the provider's TLS certificate, not an operator holding a key. +/// +/// @param user The subject. Must be the address the claim names. +/// @param source The source to record the fact in. +/// @param template_id Registered, active template the claim was produced under. +/// @param claim Canonically serialized claim. Must contain the subject and the template id. +/// @param signatures One 65-byte recoverable ECDSA signature per attestor. +/// @param attestor_epoch Epoch whose attestor set signed. +/// @param nullifier 32 bytes binding one identity to one subject, or empty to skip. When set, +/// its lowercase hex must appear in the signed claim, so the attestors vouch for it. +/// @abort If the claim does not bind the subject (or the nullifier), a signer is unknown or +/// repeated, the attestor epoch is retired, the template is revoked, the claim was +/// already used, or fewer than the threshold signed. +pub fn zktls_enroll( + source: AccountAddress, + template_id: Vec, + claim: Vec, + signatures: Vec>, + attestor_epoch: u64, + nullifier: Vec, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("zktls").to_owned(), + ), + ident_str!("enroll").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&template_id).unwrap(), + bcs::to_bytes(&claim).unwrap(), + bcs::to_bytes(&signatures).unwrap(), + bcs::to_bytes(&attestor_epoch).unwrap(), + bcs::to_bytes(&nullifier).unwrap(), + ], + )) +} + +/// Create the verifier for a source. Requires an admin of that source, and obtains the +/// source's resource-account signer through `attestation`'s friend accessor. +pub fn zktls_initialize(source: AccountAddress) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("zktls").to_owned(), + ), + ident_str!("initialize").to_owned(), + vec![], + vec![bcs::to_bytes(&source).unwrap()], + )) +} + +/// Allow a provider template and say what a claim under it grants. +pub fn zktls_register_template( + source: AccountAddress, + template_id: Vec, + grants_level: u8, + ttl_secs: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("zktls").to_owned(), + ), + ident_str!("register_template").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&template_id).unwrap(), + bcs::to_bytes(&grants_level).unwrap(), + bcs::to_bytes(&ttl_secs).unwrap(), + ], + )) +} + +/// Stop accepting new claims under a template. Facts already recorded are untouched; use +/// `attestation::bump_issuer_epoch` with issuer id 0, which invalidates every zkTLS +/// enrollment in the source, or a denial per subject for those. +pub fn zktls_revoke_template(source: AccountAddress, template_id: Vec) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("zktls").to_owned(), + ), + ident_str!("revoke_template").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&template_id).unwrap(), + ], + )) +} + +/// Register a new attestor set under the next epoch. The set it replaces keeps verifying for +/// `previous_grace_secs`, so a claim signed moments before the rotation still verifies; every +/// older set stops verifying immediately. Rotating away a compromised set with a zero grace +/// window cuts it off at once. +/// +/// @param admin An admin of the source. +/// @param source The source address. +/// @param attestor_addresses 20-byte Ethereum-style addresses, no duplicates. +/// @param required How many distinct attestors must sign. At least 1, at most the count. +/// @param previous_grace_secs How long the replaced set keeps verifying. 0 for no grace. +pub fn zktls_set_attestor_set( + source: AccountAddress, + attestor_addresses: Vec>, + required: u64, + previous_grace_secs: u64, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("zktls").to_owned(), + ), + ident_str!("set_attestor_set").to_owned(), + vec![], + vec![ + bcs::to_bytes(&source).unwrap(), + bcs::to_bytes(&attestor_addresses).unwrap(), + bcs::to_bytes(&required).unwrap(), + bcs::to_bytes(&previous_grace_secs).unwrap(), + ], + )) +} +mod decoder { + use super::*; + pub fn account_offer_rotation_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountOfferRotationCapability { + rotation_capability_sig_bytes: bcs::from_bytes(script.args().get(0)?).ok()?, + account_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, + account_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, + recipient_address: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn account_offer_signer_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountOfferSignerCapability { + signer_capability_sig_bytes: bcs::from_bytes(script.args().get(0)?).ok()?, + account_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, + account_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, + recipient_address: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn account_revoke_any_rotation_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountRevokeAnyRotationCapability {}) + } else { + None + } + } + + pub fn account_revoke_any_signer_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountRevokeAnySignerCapability {}) + } else { + None + } + } + + pub fn account_revoke_rotation_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountRevokeRotationCapability { + to_be_revoked_address: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn account_revoke_signer_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountRevokeSignerCapability { + to_be_revoked_address: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn account_rotate_authentication_key( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountRotateAuthenticationKey { + from_scheme: bcs::from_bytes(script.args().get(0)?).ok()?, + from_public_key_bytes: bcs::from_bytes(script.args().get(1)?).ok()?, + to_scheme: bcs::from_bytes(script.args().get(2)?).ok()?, + to_public_key_bytes: bcs::from_bytes(script.args().get(3)?).ok()?, + cap_rotate_key: bcs::from_bytes(script.args().get(4)?).ok()?, + cap_update_table: bcs::from_bytes(script.args().get(5)?).ok()?, + }) + } else { + None + } + } + + pub fn account_rotate_authentication_key_call( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AccountRotateAuthenticationKeyCall { + new_auth_key: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn account_rotate_authentication_key_from_public_key( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountRotateAuthenticationKeyFromPublicKey { + scheme: bcs::from_bytes(script.args().get(0)?).ok()?, + new_public_key_bytes: bcs::from_bytes(script.args().get(1)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_rotate_authentication_key_with_rotation_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountRotateAuthenticationKeyWithRotationCapability { + rotation_cap_offerer_address: bcs::from_bytes(script.args().get(0)?).ok()?, + new_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, + new_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, + cap_update_table: bcs::from_bytes(script.args().get(3)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_set_originating_address( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountSetOriginatingAddress {}) + } else { + None + } + } + + pub fn account_upsert_ed25519_backup_key_on_keyless_account( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountUpsertEd25519BackupKeyOnKeylessAccount { + keyless_public_key: bcs::from_bytes(script.args().get(0)?).ok()?, + backup_public_key: bcs::from_bytes(script.args().get(1)?).ok()?, + backup_key_proof: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_add_authentication_function( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountAbstractionAddAuthenticationFunction { + module_address: bcs::from_bytes(script.args().get(0)?).ok()?, + module_name: bcs::from_bytes(script.args().get(1)?).ok()?, + function_name: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_add_dispatchable_authentication_function( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountAbstractionAddDispatchableAuthenticationFunction { + _module_address: bcs::from_bytes(script.args().get(0)?).ok()?, + _module_name: bcs::from_bytes(script.args().get(1)?).ok()?, + _function_name: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_initialize( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountAbstractionInitialize {}) + } else { + None + } + } + + pub fn account_abstraction_register_derivable_authentication_function( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountAbstractionRegisterDerivableAuthenticationFunction { + module_address: bcs::from_bytes(script.args().get(0)?).ok()?, + module_name: bcs::from_bytes(script.args().get(1)?).ok()?, + function_name: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_remove_authentication_function( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountAbstractionRemoveAuthenticationFunction { + module_address: bcs::from_bytes(script.args().get(0)?).ok()?, + module_name: bcs::from_bytes(script.args().get(1)?).ok()?, + function_name: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_remove_authenticator( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountAbstractionRemoveAuthenticator {}) + } else { + None + } + } + + pub fn account_abstraction_remove_dispatchable_authentication_function( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AccountAbstractionRemoveDispatchableAuthenticationFunction { + _module_address: bcs::from_bytes(script.args().get(0)?).ok()?, + _module_name: bcs::from_bytes(script.args().get(1)?).ok()?, + _function_name: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn account_abstraction_remove_dispatchable_authenticator( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AccountAbstractionRemoveDispatchableAuthenticator {}) + } else { + None + } + } + + pub fn aptos_account_batch_transfer(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountBatchTransfer { + recipients: bcs::from_bytes(script.args().get(0)?).ok()?, + amounts: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_batch_transfer_coins( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountBatchTransferCoins { + coin_type: script.ty_args().get(0)?.clone(), + recipients: bcs::from_bytes(script.args().get(0)?).ok()?, + amounts: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_create_account(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountCreateAccount { + auth_key: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_fungible_transfer_only( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountFungibleTransferOnly { + to: bcs::from_bytes(script.args().get(0)?).ok()?, + amount: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_set_allow_direct_coin_transfers( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountSetAllowDirectCoinTransfers { + allow: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_transfer(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountTransfer { + to: bcs::from_bytes(script.args().get(0)?).ok()?, + amount: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_account_transfer_coins(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosAccountTransferCoins { + coin_type: script.ty_args().get(0)?.clone(), + to: bcs::from_bytes(script.args().get(0)?).ok()?, + amount: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_coin_claim_mint_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AptosCoinClaimMintCapability {}) + } else { + None + } + } + + pub fn aptos_coin_delegate_mint_capability( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosCoinDelegateMintCapability { + to: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_coin_mint(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosCoinMint { + dst_addr: bcs::from_bytes(script.args().get(0)?).ok()?, + amount: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_add_approved_script_hash_script( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AptosGovernanceAddApprovedScriptHashScript { + proposal_id: bcs::from_bytes(script.args().get(0)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn aptos_governance_batch_partial_vote( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernanceBatchPartialVote { + stake_pools: bcs::from_bytes(script.args().get(0)?).ok()?, + proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, + voting_power: bcs::from_bytes(script.args().get(2)?).ok()?, + should_pass: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_batch_vote(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernanceBatchVote { + stake_pools: bcs::from_bytes(script.args().get(0)?).ok()?, + proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, + should_pass: bcs::from_bytes(script.args().get(2)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_create_proposal( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernanceCreateProposal { + stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, + execution_hash: bcs::from_bytes(script.args().get(1)?).ok()?, + metadata_location: bcs::from_bytes(script.args().get(2)?).ok()?, + metadata_hash: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_create_proposal_v2( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernanceCreateProposalV2 { + stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, + execution_hash: bcs::from_bytes(script.args().get(1)?).ok()?, + metadata_location: bcs::from_bytes(script.args().get(2)?).ok()?, + metadata_hash: bcs::from_bytes(script.args().get(3)?).ok()?, + is_multi_step_proposal: bcs::from_bytes(script.args().get(4)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_force_end_epoch( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AptosGovernanceForceEndEpoch {}) + } else { + None + } + } + + pub fn aptos_governance_force_end_epoch_test_only( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AptosGovernanceForceEndEpochTestOnly {}) + } else { + None + } + } + + pub fn aptos_governance_partial_vote( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernancePartialVote { + stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, + proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, + voting_power: bcs::from_bytes(script.args().get(2)?).ok()?, + should_pass: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn aptos_governance_reconfigure(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(_script) = payload { + Some(EntryFunctionCall::AptosGovernanceReconfigure {}) + } else { + None + } + } + + pub fn aptos_governance_vote(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AptosGovernanceVote { + stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, + proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, + should_pass: bcs::from_bytes(script.args().get(2)?).ok()?, + }) + } else { + None + } + } + + pub fn atomic_bridge_counterparty_abort_bridge_transfer( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AtomicBridgeCounterpartyAbortBridgeTransfer { + _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, + }, + ) + } else { + None + } + } + + pub fn atomic_bridge_initiator_complete_bridge_transfer( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountOfferRotationCapability { - rotation_capability_sig_bytes: bcs::from_bytes(script.args().get(0)?).ok()?, - account_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, - account_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, - recipient_address: bcs::from_bytes(script.args().get(3)?).ok()?, - }) + Some( + EntryFunctionCall::AtomicBridgeInitiatorCompleteBridgeTransfer { + _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, + _pre_image: bcs::from_bytes(script.args().get(1)?).ok()?, + }, + ) } else { None } } - pub fn account_offer_signer_capability( + pub fn atomic_bridge_initiator_initiate_bridge_transfer( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountOfferSignerCapability { - signer_capability_sig_bytes: bcs::from_bytes(script.args().get(0)?).ok()?, - account_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, - account_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, - recipient_address: bcs::from_bytes(script.args().get(3)?).ok()?, - }) + Some( + EntryFunctionCall::AtomicBridgeInitiatorInitiateBridgeTransfer { + _recipient: bcs::from_bytes(script.args().get(0)?).ok()?, + _hash_lock: bcs::from_bytes(script.args().get(1)?).ok()?, + _amount: bcs::from_bytes(script.args().get(2)?).ok()?, + }, + ) } else { None } } - pub fn account_revoke_any_rotation_capability( + pub fn atomic_bridge_counterparty_lock_bridge_transfer_assets( payload: &TransactionPayload, ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountRevokeAnyRotationCapability {}) + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AtomicBridgeCounterpartyLockBridgeTransferAssets { + _initiator: bcs::from_bytes(script.args().get(0)?).ok()?, + _bridge_transfer_id: bcs::from_bytes(script.args().get(1)?).ok()?, + _hash_lock: bcs::from_bytes(script.args().get(2)?).ok()?, + _recipient: bcs::from_bytes(script.args().get(3)?).ok()?, + _amount: bcs::from_bytes(script.args().get(4)?).ok()?, + }, + ) } else { None } } - pub fn account_revoke_any_signer_capability( + pub fn atomic_bridge_initiator_refund_bridge_transfer( payload: &TransactionPayload, ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountRevokeAnySignerCapability {}) + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::AtomicBridgeInitiatorRefundBridgeTransfer { + _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, + }, + ) } else { None } } - pub fn account_revoke_rotation_capability( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_add_admins(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountRevokeRotationCapability { - to_be_revoked_address: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationAddAdmins { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + new_admins: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn account_revoke_signer_capability( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_add_guardians(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountRevokeSignerCapability { - to_be_revoked_address: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationAddGuardians { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + new_guardians: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn account_rotate_authentication_key( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_add_issuers(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountRotateAuthenticationKey { - from_scheme: bcs::from_bytes(script.args().get(0)?).ok()?, - from_public_key_bytes: bcs::from_bytes(script.args().get(1)?).ok()?, - to_scheme: bcs::from_bytes(script.args().get(2)?).ok()?, - to_public_key_bytes: bcs::from_bytes(script.args().get(3)?).ok()?, - cap_rotate_key: bcs::from_bytes(script.args().get(4)?).ok()?, - cap_update_table: bcs::from_bytes(script.args().get(5)?).ok()?, + Some(EntryFunctionCall::AttestationAddIssuers { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + new_issuers: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn account_rotate_authentication_key_call( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_add_removers(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AccountRotateAuthenticationKeyCall { - new_auth_key: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationAddRemovers { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + new_removers: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn account_rotate_authentication_key_from_public_key( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_add_sentinels(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountRotateAuthenticationKeyFromPublicKey { - scheme: bcs::from_bytes(script.args().get(0)?).ok()?, - new_public_key_bytes: bcs::from_bytes(script.args().get(1)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationAddSentinels { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + new_sentinels: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn account_rotate_authentication_key_with_rotation_capability( + pub fn attestation_bump_issuer_epoch( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountRotateAuthenticationKeyWithRotationCapability { - rotation_cap_offerer_address: bcs::from_bytes(script.args().get(0)?).ok()?, - new_scheme: bcs::from_bytes(script.args().get(1)?).ok()?, - new_public_key_bytes: bcs::from_bytes(script.args().get(2)?).ok()?, - cap_update_table: bcs::from_bytes(script.args().get(3)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationBumpIssuerEpoch { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + issuer_id: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn account_set_originating_address( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountSetOriginatingAddress {}) + pub fn attestation_create(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationCreate { + admins: bcs::from_bytes(script.args().get(0)?).ok()?, + issuers: bcs::from_bytes(script.args().get(1)?).ok()?, + sentinels: bcs::from_bytes(script.args().get(2)?).ok()?, + removers: bcs::from_bytes(script.args().get(3)?).ok()?, + guardians: bcs::from_bytes(script.args().get(4)?).ok()?, + }) } else { None } } - pub fn account_upsert_ed25519_backup_key_on_keyless_account( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_deny(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountUpsertEd25519BackupKeyOnKeylessAccount { - keyless_public_key: bcs::from_bytes(script.args().get(0)?).ok()?, - backup_public_key: bcs::from_bytes(script.args().get(1)?).ok()?, - backup_key_proof: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationDeny { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + reason: bcs::from_bytes(script.args().get(2)?).ok()?, + effective_at_secs: bcs::from_bytes(script.args().get(3)?).ok()?, + }) } else { None } } - pub fn account_abstraction_add_authentication_function( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_deny_batch(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountAbstractionAddAuthenticationFunction { - module_address: bcs::from_bytes(script.args().get(0)?).ok()?, - module_name: bcs::from_bytes(script.args().get(1)?).ok()?, - function_name: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationDenyBatch { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subjects: bcs::from_bytes(script.args().get(1)?).ok()?, + reason: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn account_abstraction_add_dispatchable_authentication_function( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_issue_batch(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountAbstractionAddDispatchableAuthenticationFunction { - _module_address: bcs::from_bytes(script.args().get(0)?).ok()?, - _module_name: bcs::from_bytes(script.args().get(1)?).ok()?, - _function_name: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationIssueBatch { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subjects: bcs::from_bytes(script.args().get(1)?).ok()?, + levels: bcs::from_bytes(script.args().get(2)?).ok()?, + expires_at_secs: bcs::from_bytes(script.args().get(3)?).ok()?, + reason: bcs::from_bytes(script.args().get(4)?).ok()?, + }) } else { None } } - pub fn account_abstraction_initialize( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountAbstractionInitialize {}) + pub fn attestation_pause(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationPause { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + }) } else { None } } - pub fn account_abstraction_register_derivable_authentication_function( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_publish_root(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountAbstractionRegisterDerivableAuthenticationFunction { - module_address: bcs::from_bytes(script.args().get(0)?).ok()?, - module_name: bcs::from_bytes(script.args().get(1)?).ok()?, - function_name: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPublishRoot { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + digest: bcs::from_bytes(script.args().get(1)?).ok()?, + leaf_count: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn account_abstraction_remove_authentication_function( + pub fn attestation_redeem_attestation( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountAbstractionRemoveAuthenticationFunction { - module_address: bcs::from_bytes(script.args().get(0)?).ok()?, - module_name: bcs::from_bytes(script.args().get(1)?).ok()?, - function_name: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationRedeemAttestation { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + issuer_id: bcs::from_bytes(script.args().get(2)?).ok()?, + issuer_epoch: bcs::from_bytes(script.args().get(3)?).ok()?, + level: bcs::from_bytes(script.args().get(4)?).ok()?, + expires_at_secs: bcs::from_bytes(script.args().get(5)?).ok()?, + issued_at_secs: bcs::from_bytes(script.args().get(6)?).ok()?, + nullifier: bcs::from_bytes(script.args().get(7)?).ok()?, + signature: bcs::from_bytes(script.args().get(8)?).ok()?, + }) } else { None } } - pub fn account_abstraction_remove_authenticator( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountAbstractionRemoveAuthenticator {}) + pub fn attestation_register_issuer(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationRegisterIssuer { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + issuer: bcs::from_bytes(script.args().get(1)?).ok()?, + pubkey: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn account_abstraction_remove_dispatchable_authentication_function( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_remove_admins(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AccountAbstractionRemoveDispatchableAuthenticationFunction { - _module_address: bcs::from_bytes(script.args().get(0)?).ok()?, - _module_name: bcs::from_bytes(script.args().get(1)?).ok()?, - _function_name: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationRemoveAdmins { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + old_admins: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn account_abstraction_remove_dispatchable_authenticator( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AccountAbstractionRemoveDispatchableAuthenticator {}) + pub fn attestation_remove_attribute(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationRemoveAttribute { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + key: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn aptos_account_batch_transfer(payload: &TransactionPayload) -> Option { + pub fn attestation_remove_guardians(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountBatchTransfer { - recipients: bcs::from_bytes(script.args().get(0)?).ok()?, - amounts: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationRemoveGuardians { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + old_guardians: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_account_batch_transfer_coins( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_remove_issuers(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountBatchTransferCoins { - coin_type: script.ty_args().get(0)?.clone(), - recipients: bcs::from_bytes(script.args().get(0)?).ok()?, - amounts: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationRemoveIssuers { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + old_issuers: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_account_create_account(payload: &TransactionPayload) -> Option { + pub fn attestation_remove_removers(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountCreateAccount { - auth_key: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationRemoveRemovers { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + old_removers: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_account_fungible_transfer_only( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_remove_sentinels(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountFungibleTransferOnly { - to: bcs::from_bytes(script.args().get(0)?).ok()?, - amount: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationRemoveSentinels { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + old_sentinels: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_account_set_allow_direct_coin_transfers( + pub fn attestation_revoke_batch(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationRevokeBatch { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subjects: bcs::from_bytes(script.args().get(1)?).ok()?, + reason: bcs::from_bytes(script.args().get(2)?).ok()?, + }) + } else { + None + } + } + + pub fn attestation_rotate_issuer_key( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountSetAllowDirectCoinTransfers { - allow: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationRotateIssuerKey { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + issuer: bcs::from_bytes(script.args().get(1)?).ok()?, + new_pubkey: bcs::from_bytes(script.args().get(2)?).ok()?, }) } else { None } } - pub fn aptos_account_transfer(payload: &TransactionPayload) -> Option { + pub fn attestation_set_attribute(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountTransfer { - to: bcs::from_bytes(script.args().get(0)?).ok()?, - amount: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationSetAttribute { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + key: bcs::from_bytes(script.args().get(2)?).ok()?, + value: bcs::from_bytes(script.args().get(3)?).ok()?, }) } else { None } } - pub fn aptos_account_transfer_coins(payload: &TransactionPayload) -> Option { + pub fn attestation_set_floor_epoch(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosAccountTransferCoins { - coin_type: script.ty_args().get(0)?.clone(), - to: bcs::from_bytes(script.args().get(0)?).ok()?, - amount: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationSetFloorEpoch { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + epoch: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_coin_claim_mint_capability( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AptosCoinClaimMintCapability {}) + pub fn attestation_suspend(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationSuspend { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + reason: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn aptos_coin_delegate_mint_capability( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_undeny(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosCoinDelegateMintCapability { - to: bcs::from_bytes(script.args().get(0)?).ok()?, + Some(EntryFunctionCall::AttestationUndeny { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_coin_mint(payload: &TransactionPayload) -> Option { + pub fn attestation_unpause(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosCoinMint { - dst_addr: bcs::from_bytes(script.args().get(0)?).ok()?, - amount: bcs::from_bytes(script.args().get(1)?).ok()?, + Some(EntryFunctionCall::AttestationUnpause { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn attestation_unsuspend(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationUnsuspend { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + subject: bcs::from_bytes(script.args().get(1)?).ok()?, + reason: bcs::from_bytes(script.args().get(2)?).ok()?, }) } else { None } } - pub fn aptos_governance_add_approved_script_hash_script( + pub fn attestation_authorization_prune_nonces( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AptosGovernanceAddApprovedScriptHashScript { - proposal_id: bcs::from_bytes(script.args().get(0)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationAuthorizationPruneNonces { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + nonces: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn aptos_governance_batch_partial_vote( + pub fn attestation_policy_activate_pending( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernanceBatchPartialVote { - stake_pools: bcs::from_bytes(script.args().get(0)?).ok()?, - proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, - voting_power: bcs::from_bytes(script.args().get(2)?).ok()?, - should_pass: bcs::from_bytes(script.args().get(3)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyActivatePending { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, }) } else { None } } - pub fn aptos_governance_batch_vote(payload: &TransactionPayload) -> Option { + pub fn attestation_policy_add_admins( + payload: &TransactionPayload, + ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernanceBatchVote { - stake_pools: bcs::from_bytes(script.args().get(0)?).ok()?, - proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, - should_pass: bcs::from_bytes(script.args().get(2)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyAddAdmins { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + new_admins: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_governance_create_proposal( + pub fn attestation_policy_add_guardians( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernanceCreateProposal { - stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, - execution_hash: bcs::from_bytes(script.args().get(1)?).ok()?, - metadata_location: bcs::from_bytes(script.args().get(2)?).ok()?, - metadata_hash: bcs::from_bytes(script.args().get(3)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyAddGuardians { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + new_guardians: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn aptos_governance_create_proposal_v2( + pub fn attestation_policy_cancel_pending( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernanceCreateProposalV2 { - stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, - execution_hash: bcs::from_bytes(script.args().get(1)?).ok()?, - metadata_location: bcs::from_bytes(script.args().get(2)?).ok()?, - metadata_hash: bcs::from_bytes(script.args().get(3)?).ok()?, - is_multi_step_proposal: bcs::from_bytes(script.args().get(4)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyCancelPending { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, }) } else { None } } - pub fn aptos_governance_force_end_epoch( + pub fn attestation_policy_clear_step_up( payload: &TransactionPayload, ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AptosGovernanceForceEndEpoch {}) + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationPolicyClearStepUp { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + action: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn aptos_governance_force_end_epoch_test_only( - payload: &TransactionPayload, - ) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AptosGovernanceForceEndEpochTestOnly {}) + pub fn attestation_policy_create(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationPolicyCreate { + admins: bcs::from_bytes(script.args().get(0)?).ok()?, + guardians: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn aptos_governance_partial_vote( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_policy_pause(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernancePartialVote { - stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, - proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, - voting_power: bcs::from_bytes(script.args().get(2)?).ok()?, - should_pass: bcs::from_bytes(script.args().get(3)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyPause { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, }) } else { None } } - pub fn aptos_governance_reconfigure(payload: &TransactionPayload) -> Option { - if let TransactionPayload::EntryFunction(_script) = payload { - Some(EntryFunctionCall::AptosGovernanceReconfigure {}) + pub fn attestation_policy_remove_admins( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::AttestationPolicyRemoveAdmins { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + old_admins: bcs::from_bytes(script.args().get(1)?).ok()?, + }) } else { None } } - pub fn aptos_governance_vote(payload: &TransactionPayload) -> Option { + pub fn attestation_policy_remove_guardians( + payload: &TransactionPayload, + ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some(EntryFunctionCall::AptosGovernanceVote { - stake_pool: bcs::from_bytes(script.args().get(0)?).ok()?, - proposal_id: bcs::from_bytes(script.args().get(1)?).ok()?, - should_pass: bcs::from_bytes(script.args().get(2)?).ok()?, + Some(EntryFunctionCall::AttestationPolicyRemoveGuardians { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + old_guardians: bcs::from_bytes(script.args().get(1)?).ok()?, }) } else { None } } - pub fn atomic_bridge_counterparty_abort_bridge_transfer( + pub fn attestation_policy_set_authorizer( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AtomicBridgeCounterpartyAbortBridgeTransfer { - _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPolicySetAuthorizer { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + pubkey: bcs::from_bytes(script.args().get(1)?).ok()?, + max_ttl_secs: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn atomic_bridge_initiator_complete_bridge_transfer( + pub fn attestation_policy_set_step_up( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AtomicBridgeInitiatorCompleteBridgeTransfer { - _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, - _pre_image: bcs::from_bytes(script.args().get(1)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPolicySetStepUp { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + action: bcs::from_bytes(script.args().get(1)?).ok()?, + threshold: bcs::from_bytes(script.args().get(2)?).ok()?, + }) } else { None } } - pub fn atomic_bridge_initiator_initiate_bridge_transfer( + pub fn attestation_policy_stage_attr_rules( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AtomicBridgeInitiatorInitiateBridgeTransfer { - _recipient: bcs::from_bytes(script.args().get(0)?).ok()?, - _hash_lock: bcs::from_bytes(script.args().get(1)?).ok()?, - _amount: bcs::from_bytes(script.args().get(2)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPolicyStageAttrRules { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + sources: bcs::from_bytes(script.args().get(1)?).ok()?, + keys: bcs::from_bytes(script.args().get(2)?).ok()?, + ops: bcs::from_bytes(script.args().get(3)?).ok()?, + values: bcs::from_bytes(script.args().get(4)?).ok()?, + }) } else { None } } - pub fn atomic_bridge_counterparty_lock_bridge_transfer_assets( + pub fn attestation_policy_stage_body( payload: &TransactionPayload, ) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AtomicBridgeCounterpartyLockBridgeTransferAssets { - _initiator: bcs::from_bytes(script.args().get(0)?).ok()?, - _bridge_transfer_id: bcs::from_bytes(script.args().get(1)?).ok()?, - _hash_lock: bcs::from_bytes(script.args().get(2)?).ok()?, - _recipient: bcs::from_bytes(script.args().get(3)?).ok()?, - _amount: bcs::from_bytes(script.args().get(4)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPolicyStageBody { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + require_any_sources: bcs::from_bytes(script.args().get(1)?).ok()?, + require_any_levels: bcs::from_bytes(script.args().get(2)?).ok()?, + require_all_sources: bcs::from_bytes(script.args().get(3)?).ok()?, + require_all_levels: bcs::from_bytes(script.args().get(4)?).ok()?, + deny_any: bcs::from_bytes(script.args().get(5)?).ok()?, + chain_deny: bcs::from_bytes(script.args().get(6)?).ok()?, + effective_at_secs: bcs::from_bytes(script.args().get(7)?).ok()?, + }) } else { None } } - pub fn atomic_bridge_initiator_refund_bridge_transfer( - payload: &TransactionPayload, - ) -> Option { + pub fn attestation_policy_unpause(payload: &TransactionPayload) -> Option { if let TransactionPayload::EntryFunction(script) = payload { - Some( - EntryFunctionCall::AtomicBridgeInitiatorRefundBridgeTransfer { - _bridge_transfer_id: bcs::from_bytes(script.args().get(0)?).ok()?, - }, - ) + Some(EntryFunctionCall::AttestationPolicyUnpause { + policy: bcs::from_bytes(script.args().get(0)?).ok()?, + }) } else { None } @@ -6722,6 +9106,20 @@ mod decoder { } } + pub fn delegation_pool_enable_partial_governance_voting_if_needed( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::DelegationPoolEnablePartialGovernanceVotingIfNeeded { + pool_address: bcs::from_bytes(script.args().get(0)?).ok()?, + }, + ) + } else { + None + } + } + pub fn delegation_pool_evict_delegator( payload: &TransactionPayload, ) -> Option { @@ -8140,6 +10538,68 @@ mod decoder { None } } + + pub fn zktls_enroll(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::ZktlsEnroll { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + template_id: bcs::from_bytes(script.args().get(1)?).ok()?, + claim: bcs::from_bytes(script.args().get(2)?).ok()?, + signatures: bcs::from_bytes(script.args().get(3)?).ok()?, + attestor_epoch: bcs::from_bytes(script.args().get(4)?).ok()?, + nullifier: bcs::from_bytes(script.args().get(5)?).ok()?, + }) + } else { + None + } + } + + pub fn zktls_initialize(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::ZktlsInitialize { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + }) + } else { + None + } + } + + pub fn zktls_register_template(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::ZktlsRegisterTemplate { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + template_id: bcs::from_bytes(script.args().get(1)?).ok()?, + grants_level: bcs::from_bytes(script.args().get(2)?).ok()?, + ttl_secs: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } + + pub fn zktls_revoke_template(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::ZktlsRevokeTemplate { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + template_id: bcs::from_bytes(script.args().get(1)?).ok()?, + }) + } else { + None + } + } + + pub fn zktls_set_attestor_set(payload: &TransactionPayload) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some(EntryFunctionCall::ZktlsSetAttestorSet { + source: bcs::from_bytes(script.args().get(0)?).ok()?, + attestor_addresses: bcs::from_bytes(script.args().get(1)?).ok()?, + required: bcs::from_bytes(script.args().get(2)?).ok()?, + previous_grace_secs: bcs::from_bytes(script.args().get(3)?).ok()?, + }) + } else { + None + } + } } type EntryFunctionDecoderMap = std::collections::HashMap< @@ -8334,6 +10794,178 @@ static SCRIPT_FUNCTION_DECODER_MAP: once_cell::sync::Lazy