diff --git a/bvm/Shaders/Explorer/Parser.cpp b/bvm/Shaders/Explorer/Parser.cpp index 69b0bb34b7..1e872ada2a 100644 --- a/bvm/Shaders/Explorer/Parser.cpp +++ b/bvm/Shaders/Explorer/Parser.cpp @@ -26,6 +26,7 @@ namespace Testnet { } #include "../minter/contract.h" #include "../blackhole/contract.h" +#include "../asset_lists/contract.h" #include "../sidechain_pos/contract_l1.h" #include "../sidechain_pos/contract_l2.h" #include "../pbft/pbft_dpos.h" @@ -287,6 +288,7 @@ void DocAddFixedPoint(const char* sz, uint64_t val, uint64_t one, uint32_t nDigs macro(DaoAccumulator, DaoAccumulator::s_pSID) \ macro(DaoVote, DaoVote::s_pSID) \ macro(Bridge_L1, SidechainPos::L1::s_pSID) \ + macro(AssetLists, AssetLists::s_pSID) \ #define HandleContractsWrappers(macro) \ macro(Upgradable, Upgradable::s_SID) \ @@ -513,6 +515,11 @@ struct ParserContext void On_PBFT_Status(const char*, I_PBFT::State::Validator::Status); void On_PBFT_Commission(uint16_t, bool bIsTbl = false); + void OnState_AssetLists_Accounts(); + void OnState_AssetLists_Lists(); + void OnState_AssetLists_Balances(); + void OnState_AssetLists_PendingTransfers(); + }; bool ParserContext::Parse() @@ -3473,6 +3480,352 @@ void ParserContext::WriteDaoVoteCfg(const DaoVote::Cfg& cfg) DocAddPk("Admin", cfg.m_pkAdmin); } +// --------------------------------------------------------------------------- +// AssetLists +// --------------------------------------------------------------------------- + +void ParserContext::OnState_AssetLists_Accounts() +{ + Env::DocGroup gr("Accounts"); + DocSetType("table"); + + { + Env::DocGroup grH(""); + DocAddTableHeader("Account PK"); + DocAddTableHeader("Created"); + DocAddTableHeader("Proposals"); + } + + Env::Key_T k0, k1; + _POD_(k0.m_Prefix.m_Cid) = m_Cid; + _POD_(k1.m_Prefix.m_Cid) = m_Cid; + k0.m_KeyInContract.m_Tag = AssetLists::Tags::s_Account; + k1.m_KeyInContract.m_Tag = AssetLists::Tags::s_Account; + _POD_(k0.m_KeyInContract.m_pkAccount).SetZero(); + _POD_(k1.m_KeyInContract.m_pkAccount).SetObject(0xff); + + for (Env::VarReader r(k0, k1); ; ) + { + Env::Key_T ekey; + AssetLists::Account acct; + if (!r.MoveNext_T(ekey, acct)) + break; + + Env::DocGroup grRow(""); + DocAddPk("pk", ekey.m_KeyInContract.m_pkAccount); + DocAddHeight("created", acct.m_hCreated); + Env::DocAddNum("proposals", acct.m_nProposals); + } +} + +void ParserContext::OnState_AssetLists_Lists() +{ + Env::DocGroup gr("Lists"); + DocSetType("table"); + + { + Env::DocGroup grH(""); + DocAddTableHeader("Account PK"); + DocAddTableHeader("List PK"); + DocAddTableHeader("Created"); + DocAddTableHeader("Type"); + DocAddTableHeader("Assets"); + DocAddTableHeader("Proposals"); + } + + Env::Key_T k0, k1; + _POD_(k0.m_Prefix.m_Cid) = m_Cid; + _POD_(k1.m_Prefix.m_Cid) = m_Cid; + k0.m_KeyInContract.m_Tag = AssetLists::Tags::s_List; + k1.m_KeyInContract.m_Tag = AssetLists::Tags::s_List; + _POD_(k0.m_KeyInContract.m_pkAccount).SetZero(); + _POD_(k0.m_KeyInContract.m_pkList).SetZero(); + _POD_(k1.m_KeyInContract.m_pkAccount).SetObject(0xff); + _POD_(k1.m_KeyInContract.m_pkList).SetObject(0xff); + + for (Env::VarReader r(k0, k1); ; ) + { + Env::Key_T ekey; + AssetLists::List lst; + if (!r.MoveNext_T(ekey, lst)) + break; + + Env::DocGroup grRow(""); + DocAddPk("account_pk", ekey.m_KeyInContract.m_pkAccount); + DocAddPk("list_pk", ekey.m_KeyInContract.m_pkList); + DocAddHeight("created", lst.m_hCreated); + Env::DocAddText("type", lst.m_ListType == AssetLists::ListType::s_Single ? "single" : "multi"); + Env::DocAddNum("assets", lst.m_nAssets); + Env::DocAddNum("proposals", lst.m_nProposals); + } +} + +void ParserContext::OnState_AssetLists_Balances() +{ + Env::DocGroup gr("Account Balances"); + DocSetType("table"); + + { + Env::DocGroup grH(""); + DocAddTableHeader("Account PK"); + DocAddTableHeader("Balance"); + } + + Env::Key_T k0, k1; + _POD_(k0.m_Prefix.m_Cid) = m_Cid; + _POD_(k1.m_Prefix.m_Cid) = m_Cid; + k0.m_KeyInContract.m_Tag = AssetLists::Tags::s_AccountBalance; + k1.m_KeyInContract.m_Tag = AssetLists::Tags::s_AccountBalance; + _POD_(k0.m_KeyInContract.m_pkAccount).SetZero(); + _POD_(k1.m_KeyInContract.m_pkAccount).SetObject(0xff); + + for (Env::VarReader r(k0, k1); ; ) + { + Env::Key_T ekey; + AssetLists::AccountBalance bal; + if (!r.MoveNext_T(ekey, bal)) + break; + + Env::DocGroup grRow(""); + DocAddPk("account_pk", ekey.m_KeyInContract.m_pkAccount); + DocAddAmount("balance", bal.m_Amount); + } +} + +void ParserContext::OnState_AssetLists_PendingTransfers() +{ + Env::DocGroup gr("Pending Transfers"); + DocSetType("table"); + + { + Env::DocGroup grH(""); + DocAddTableHeader("Source Account PK"); + DocAddTableHeader("List PK"); + DocAddTableHeader("Dest Account PK"); + DocAddTableHeader("Expires"); + } + + Env::Key_T k0, k1; + _POD_(k0.m_Prefix.m_Cid) = m_Cid; + _POD_(k1.m_Prefix.m_Cid) = m_Cid; + k0.m_KeyInContract.m_Tag = AssetLists::Tags::s_PendingTransfer; + k1.m_KeyInContract.m_Tag = AssetLists::Tags::s_PendingTransfer; + _POD_(k0.m_KeyInContract.m_pkAccountSrc).SetZero(); + _POD_(k0.m_KeyInContract.m_pkList).SetZero(); + _POD_(k1.m_KeyInContract.m_pkAccountSrc).SetObject(0xff); + _POD_(k1.m_KeyInContract.m_pkList).SetObject(0xff); + + for (Env::VarReader r(k0, k1); ; ) + { + Env::Key_T ekey; + AssetLists::PendingTransfer pt; + if (!r.MoveNext_T(ekey, pt)) + break; + + Env::DocGroup grRow(""); + DocAddPk("source_account", ekey.m_KeyInContract.m_pkAccountSrc); + DocAddPk("list_pk", ekey.m_KeyInContract.m_pkList); + DocAddPk("dest_account", pt.m_pkAccountDest); + DocAddHeight("expires", pt.m_hExpire); + } +} + +void ParserContext::OnMethod_AssetLists(uint32_t /* iVer */) +{ + switch (m_iMethod) + { + case AssetLists::Method::Init::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Create"); + GroupArgs gr; + WriteUpgradeSettings(pArg->m_Upgradable); + DocAddMonoblob("dao_vault", pArg->m_Settings.m_cidDaoVault); + DocAddAmount("fee_account", pArg->m_Settings.m_FeeAccount); + DocAddAmount("fee_list", pArg->m_Settings.m_FeeList); + DocAddAmount("fee_proposal", pArg->m_Settings.m_FeeProposal); + Env::DocAddNum("proposal_ttl", pArg->m_Settings.m_ProposalTtl); + } + } + break; + + case AssetLists::Method::CreateAccount::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Create Account"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + Env::DocAddNum("threshold", (uint32_t) pArg->m_Threshold); + Env::DocAddNum("signers", (uint32_t) pArg->m_nSigners); + } + } + break; + + case AssetLists::Method::ProposeAccountAction::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Propose Account Action"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("proposer", pArg->m_pkProposer); + Env::DocAddNum("action", (uint32_t) pArg->m_Action); + } + } + break; + + case AssetLists::Method::VoteAccountProposal::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Vote Account Proposal"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + Env::DocAddNum("proposal", pArg->m_ProposalID); + Env::DocAddNum("signer_idx", (uint32_t) pArg->m_SignerIdx); + Env::DocAddNum("yes", (uint32_t) pArg->m_bYes); + } + } + break; + + case AssetLists::Method::CancelAccountProposal::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Cancel Account Proposal"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + Env::DocAddNum("proposal", pArg->m_ProposalID); + DocAddPk("proposer", pArg->m_pkProposer); + } + } + break; + + case AssetLists::Method::ProposeListAction::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Propose List Action"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("list_pk", pArg->m_pkList); + DocAddPk("proposer", pArg->m_pkProposer); + Env::DocAddNum("action", (uint32_t) pArg->m_Action); + } + } + break; + + case AssetLists::Method::VoteListProposal::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Vote List Proposal"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("list_pk", pArg->m_pkList); + Env::DocAddNum("proposal", pArg->m_ProposalID); + Env::DocAddNum("signer_idx", (uint32_t) pArg->m_SignerIdx); + Env::DocAddNum("yes", (uint32_t) pArg->m_bYes); + } + } + break; + + case AssetLists::Method::CancelListProposal::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Cancel List Proposal"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("list_pk", pArg->m_pkList); + Env::DocAddNum("proposal", pArg->m_ProposalID); + DocAddPk("proposer", pArg->m_pkProposer); + } + } + break; + + case AssetLists::Method::CleanupProposal::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Cleanup Proposal"); + GroupArgs gr; + Env::DocAddNum("account_proposal", (uint32_t) pArg->m_bAccountProposal); + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("list_pk", pArg->m_pkList); + Env::DocAddNum("proposal", pArg->m_ProposalID); + } + } + break; + + case AssetLists::Method::ClaimBalance::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Claim Balance"); + GroupArgs gr; + DocAddPk("recipient", pArg->m_pkRecipient); + DocAddPk("account_pk", pArg->m_pkAccount); + } + } + break; + + case AssetLists::Method::CleanupTransfer::s_iMethod: + { + auto pArg = get_ArgsAs(); + if (pArg) + { + OnMethod("Cleanup Transfer"); + GroupArgs gr; + DocAddPk("account_pk", pArg->m_pkAccount); + DocAddPk("list_pk", pArg->m_pkList); + } + } + break; + + default: + OnUpgrade3Method(); + break; + } +} + +void ParserContext::OnState_AssetLists(uint32_t /* iVer */) +{ + WriteUpgrade3State(); + + Env::Key_T k; + _POD_(k.m_Prefix.m_Cid) = m_Cid; + k.m_KeyInContract = AssetLists::State::s_Key; + + AssetLists::State s; + if (!Env::VarReader::Read_T(k, s)) + return; + + DocAddAmount("Fee (account creation)", s.m_Settings.m_FeeAccount); + DocAddAmount("Fee (list creation)", s.m_Settings.m_FeeList); + DocAddAmount("Fee (proposal, non-signer)", s.m_Settings.m_FeeProposal); + Env::DocAddNum("Proposal TTL (blocks)", s.m_Settings.m_ProposalTtl); + Env::DocAddNum("Total accounts", s.m_nAccounts); + Env::DocAddNum("Total lists", s.m_nLists); + + OnState_AssetLists_Accounts(); + OnState_AssetLists_Lists(); + OnState_AssetLists_Balances(); + OnState_AssetLists_PendingTransfers(); +} + BEAM_EXPORT void Method_0(const ShaderID& sid, const ContractID& cid, uint32_t iMethod, const void* pArg, uint32_t nArg) diff --git a/bvm/Shaders/asset_lists/README.md b/bvm/Shaders/asset_lists/README.md new file mode 100644 index 0000000000..097c33911a --- /dev/null +++ b/bvm/Shaders/asset_lists/README.md @@ -0,0 +1,266 @@ +# AssetLists Contract + +A Beam BVM shader that lets users register on-chain accounts and publish curated named lists of Beam Asset IDs. All list data is pure metadata — no tokens are locked. Account and list creation carry a non-refundable BEAM fee forwarded to the DAO Vault to deter spam. + +The contract is governed by M-of-N multi-sig at both account and list level, using a multi-transaction proposal/vote system suited to dapp UIs. + +--- + +## Core Concepts + +### Account + +An account is an on-chain identity anchored by a dedicated public key (`pkAccount`). It stores a profile (name, website, description) and owns one or more lists. Every account has a **SignerSet** — an M-of-N set of public keys whose approval is required for any account-level or list-level change. + +Accounts accumulate a BEAM balance from non-signer proposal fees. This balance can be withdrawn via the `s_WithdrawBalance` proposal action (M-of-N governed). + +### List + +A list belongs to an account and is identified by its own public key (`pkList`). Lists come in two types, fixed at creation: + +| Type | Description | Use case | +|---|---|---| +| **Single** | Flat sequence of Asset IDs | Approved assets, watchlists, blocklists | +| **Multi** | Mapping of `realAssetID → [fakeAssetID, ...]` | Bridge/imposter registries where one canonical asset maps to several equivalent representations | + +Lists optionally carry their own **ListSigners** (a separate M-of-N set). When no ListSigners entry is present, the parent account's SignerSet governs the list. + +Lists can be transferred to another account via the `s_InitiateTransfer` / `s_AcceptTransfer` handshake (both require M-of-N approval from the respective accounts). + +### Multi-sig (M-of-N) + +All mutable operations on an account or list go through a proposal/vote cycle: + +1. **Propose** — any party submits a proposal for a specific action. If the proposer is a member of the governing SignerSet, their yes-vote is auto-cast. If that single vote already reaches the threshold the action executes immediately (single-signer fast path — no second transaction needed). +2. **Vote** — each signer casts yes or no on an open proposal. +3. **Auto-execute / auto-reject** — the contract executes the action as soon as `yes_count >= M`, or discards the proposal as soon as `no_count >= N - M + 1` (reject threshold). +4. **Cancel** — only the original proposer may cancel an open proposal before it resolves. +5. **Cleanup** — anyone may delete an expired proposal (height > `m_hExpire`) to reclaim contract storage. + +Proposals are scoped: **account proposals** govern the account and its signer set; **list proposals** govern a specific list. + +### Non-signer Proposal Fee + +When a party who is **not** a member of the governing SignerSet submits a proposal, they must pay `m_FeeProposal` groth. The fee is split 50/50: half goes directly to the DAO Vault, half accumulates in the target account's on-chain balance. Setting `m_FeeProposal = 0` disables the fee entirely. + +--- + +## Permission Summary + +### Anyone (no signature required) + +| Action | Method | +|---|---| +| Create an account (with account key sig) | `CreateAccount` (Method 3) | +| Propose any account or list action | `ProposeAccountAction` / `ProposeListAction` | +| Vote on an open proposal (requires signer key) | `VoteAccountProposal` / `VoteListProposal` | +| Delete an **expired** proposal | `CleanupProposal` (Method 10) | +| Delete an **expired** pending transfer | `CleanupTransfer` (Method 12) | + +> Note: Non-signer proposers pay `m_FeeProposal` (if non-zero). Proposals from non-signers have no auto-vote — they still require M yes-votes from the actual signers. + +### Account Signer (holds a key in the account's SignerSet) + +All of the above (fee-free), plus: + +| Action | Proposal type | Notes | +|---|---|---| +| Update account profile (name, website, description) | Account | `AccountActions::s_UpdateInfo` | +| Add a signer to the account SignerSet + set new threshold | Account | `AccountActions::s_AddSigner` | +| Remove a signer from the account SignerSet + set new threshold | Account | `AccountActions::s_RemoveSigner`; cannot remove last signer | +| Delete the account | Account | `AccountActions::s_DeleteAccount`; all lists must be deleted first | +| Create a new list | Account | `AccountActions::s_CreateList`; also requires sig of new `pkList` | +| Withdraw accumulated balance to a designated recipient | Account | `AccountActions::s_WithdrawBalance`; creates a `PendingClaim` record | +| Initiate a list transfer to another account | Account | `AccountActions::s_InitiateTransfer`; creates a `PendingTransfer` record | +| Cancel a pending list transfer | Account | `AccountActions::s_CancelTransfer` | +| Accept an incoming list transfer (on destination account) | Account | `AccountActions::s_AcceptTransfer`; migrates all list storage | + +### List Signer (holds a key in the list's effective SignerSet) + +The effective SignerSet for a list is the list's own ListSigners if present, otherwise the account's SignerSet. + +| Action | Proposal type | Constraint | +|---|---|---| +| Update list profile (name, description) | List | `ListActions::s_UpdateInfo` | +| Add an Asset ID to a **single** list | List | `ListActions::s_AddAsset` — list type must be `single` | +| Remove an Asset ID from a **single** list | List | `ListActions::s_RemoveAsset` — list type must be `single` | +| Add or replace an asset group in a **multi** list | List | `ListActions::s_AddAssetGroup` — list type must be `multi`; upsert semantics | +| Remove an asset group from a **multi** list | List | `ListActions::s_RemoveAssetGroup` — list type must be `multi` | +| Install list-specific managers (ListSigners) | List | `ListActions::s_SetListManagers` | +| Remove list-specific managers | List | `ListActions::s_ClearListManagers` | +| Delete the list and all its entries | List | `ListActions::s_DeleteList` | + +### Designated Recipient + +| Action | Method | Notes | +|---|---|---| +| Claim an approved balance withdrawal | `ClaimBalance` (Method 11) | Requires sig of `m_pkRecipient`; consumes `PendingClaim` record | + +--- + +## Methods + +| # | Name | Caller / Sig required | Description | +|---|---|---|---| +| 0 | `Init` | Contract deployer | Deploy contract, set DAO Vault CID, creation fees, proposal fee, and proposal TTL | +| 3 | `CreateAccount` | `sig(pkAccount)` | Register account + initial signer set; fee sent to DAO Vault | +| 4 | `ProposeAccountAction` | `sig(pkProposer)` | Submit a proposal for an account-scoped action; non-signers pay `m_FeeProposal` | +| 5 | `VoteAccountProposal` | `sig(signers[idx])` | Cast yes/no on an account proposal | +| 6 | `CancelAccountProposal` | `sig(original proposer)` | Withdraw an open account proposal | +| 7 | `ProposeListAction` | `sig(pkProposer)` | Submit a proposal for a list-scoped action; non-signers pay `m_FeeProposal` | +| 8 | `VoteListProposal` | `sig(signers[idx])` | Cast yes/no on a list proposal | +| 9 | `CancelListProposal` | `sig(original proposer)` | Withdraw an open list proposal | +| 10 | `CleanupProposal` | Anyone (no sig) | Delete an expired proposal | +| 11 | `ClaimBalance` | `sig(pkRecipient)` | Recipient collects BEAM approved by a `s_WithdrawBalance` proposal | +| 12 | `CleanupTransfer` | Anyone (no sig) | Delete an expired `PendingTransfer` record | + +Methods 1–2 are reserved for the `upgradable3` upgrade mechanism. + +--- + +## Deployment + +### Build + +Compile both binaries with the shader-sdk WASM toolchain (Clang targeting wasm32): + +```bash +make_shader.bat bvm/Shaders/asset_lists/contract.cpp +make_shader.bat bvm/Shaders/asset_lists/app.cpp +``` + +After the first successful build, record the `ShaderID` (hash of `contract.wasm`) and fill it into the `s_pSID[]` array in `contract.h`, then rebuild `app.wasm`. The app uses `s_pSID` for `EnumAndDumpContracts` and version verification at upgrade time. + +### Init parameters (Method 0) + +`Method::Init` embeds two sub-structs that must be populated together: + +**`Upgradable3::Settings`** — upgrade governance: + +| Field | Type | Description | +|---|---|---| +| `m_hMinUpgradeDelay` | `Height` | Minimum blocks between `ScheduleUpgrade` and the upgrade taking effect. A value of `0` allows immediate upgrades; use a non-zero value (e.g. 1440 ≈ 24 h) so co-admins can veto a rogue schedule. | +| `m_MinApprovers` | `uint32_t` | How many admin keys must co-sign each upgrade control transaction (M-of-N). Must be ≥ 1 and ≤ the number of non-zero entries in `m_pAdmin`. | +| `m_pAdmin[32]` | `PubKey[32]` | Up to 32 admin public keys. Unused slots stay zero. The wallet app auto-fills the deployer's key into the first free slot if it is not already present. | + +**`AssetLists::Settings`** — contract fee config: + +| Field | Type | Description | +|---|---|---| +| `m_cidDaoVault` | `ContractID` | DAO Vault contract to receive fee payments. Locked in at init via `Env::RefAdd`; cannot be changed after deployment. | +| `m_FeeAccount` | `Amount` (groth) | Non-refundable fee paid on `CreateAccount`, forwarded directly to the DAO Vault. | +| `m_FeeList` | `Amount` (groth) | Non-refundable fee charged when a `CreateList` proposal executes, forwarded to the DAO Vault. | +| `m_FeeProposal` | `Amount` (groth) | Fee paid by non-signer proposers; split 50/50 between the DAO Vault and the target account's balance. Set to `0` to disable. | +| `m_ProposalTtl` | `Height` | Default proposal lifetime in blocks (e.g. 1440 ≈ 24 h). Proposals older than this can be cleaned up by anyone via `CleanupProposal`. | + +The `ContractID` is derived deterministically as `Hash(ShaderID || Init_args)`, so the same binary deployed with the same parameters always produces the same address. + +--- + +## Upgradability + +The contract uses the `upgradable3` pattern (Method 2). The live WASM bytecode can be replaced without touching any application state. + +### Upgrade flow + +1. **Schedule** — M-of-N admins co-sign a `ScheduleUpgrade` call (type 3 sub-type of Method 2). The call embeds the new `contract.wasm` bytecode and a target block height `hTarget`. The contract enforces `hTarget ≥ currentHeight + m_hMinUpgradeDelay`. + +2. **Wait** — other admins have `m_hMinUpgradeDelay` blocks to inspect the scheduled bytecode. During this window a new `ScheduleUpgrade` call (also requiring M-of-N) can overwrite the pending upgrade, effectively vetoing it. + +3. **Execute** — once `hTarget` is reached, *anyone* (no signature required) can finalize the swap by calling `ExplicitUpgrade` (type 1 sub-type of Method 2). The new WASM goes live and `OnUpgraded(prevVersion)` is invoked to perform any state migration. + +### Admin key management (also Method 2) + +Both operations below require M-of-N admin co-signatures, coordinated via the `MultiSigRitual` protocol in `upgradable3/app_common_impl.h`: + +| Sub-type | Action | +|---|---| +| `ReplaceAdmin` (4) | Swap one entry in `m_pAdmin[]` — use for key rotation or removing a compromised admin. | +| `SetApprovers` (5) | Change the `m_MinApprovers` threshold — new value must remain in [1, active admin count]. | + +### `s_pSID` and version verification + +`contract.h` contains `s_pSID[]` — a list of known `ShaderID` values in version order. The wallet app checks the ShaderID of the bytecode you are about to schedule against this list to prevent accidentally deploying an unrecognized binary. Pass `bSkipVerifyVer = 1` to the `schedule_upgrade` app action only when deploying a build whose ShaderID has not yet been added to `s_pSID` (e.g. during development). Keep `s_pSID` up-to-date by appending each released ShaderID in version order. + +--- + +## Technical Details + +### Storage Layout + +All keys share a flat contract key-value store, namespaced by a leading tag byte: + +| Tag | Key | Value | +|---|---|---| +| `0` (Settings) | `uint8_t` | `State` — global fee config + account/list counts | +| `1` (Account) | `{tag, pkAccount}` | `Account` header + packed strings `[nName, name, nWeb, website, nDesc(u16), desc]` | +| `2` (AccountSigners) | `{tag, pkAccount}` | `SignerSet` header + `PubKey[nSigners]` | +| `3` (AccountProposal) | `{tag, pkAccount, proposalID(BE)}` | `Proposal` header + action payload | +| `4` (List) | `{tag, pkAccount, pkList}` | `List` header + packed strings `[nName, name, nDesc(u16), desc]` | +| `5` (ListSigners) | `{tag, pkAccount, pkList}` | `SignerSet` header + `PubKey[nSigners]` | +| `6` (ListProposal) | `{tag, pkAccount, pkList, proposalID(BE)}` | `Proposal` header + action payload | +| `7` (ListAsset) | `{tag, pkAccount, pkList, assetID}` | 1-byte dummy — presence = membership (single lists) | +| `8` (ListAssetGroup) | `{tag, pkAccount, pkList, realAssetID(BE)}` | `AssetGroup` header + `AssetID[nFakes]` (multi lists) | +| `9` (AccountBalance) | `{tag, pkAccount}` | `AccountBalance` — accumulated groth from non-signer proposal fees | +| `10` (PendingClaim) | `{tag, pkRecipient, pkAccount}` | `PendingClaim` — approved withdrawal amount awaiting `ClaimBalance` call | +| `11` (PendingTransfer) | `{tag, pkAccountSrc, pkList}` | `PendingTransfer` — in-progress list transfer offer from source to destination | + +Proposal IDs are stored big-endian in keys so that `VarReader` range scans enumerate them in ascending numeric order. + +### Variable-Length Values + +Account and list values store a fixed-size header immediately followed by packed string bytes. Because `SaveVar_T` only writes `sizeof(T)` bytes, any update that patches header fields (e.g., incrementing `m_nAssets`) must read the full raw value into a buffer, patch the header in-place, and write back the full byte count — the string suffix must be preserved. + +### Fees + +| Event | Fee | +|---|---| +| `CreateAccount` | `m_FeeAccount` BEAM → DAO Vault (immediate, on account creation) | +| `CreateList` proposal executes | `m_FeeList` BEAM → DAO Vault | +| Non-signer `ProposeAccountAction` or `ProposeListAction` | `m_FeeProposal` groth: half → DAO Vault, half → target account balance | + +Fees are non-refundable. The DAO Vault CID is locked in at contract init via `Env::RefAdd`. + +### Balance Withdrawal Flow + +1. Account signer proposes `s_WithdrawBalance` with a recipient public key and amount. +2. On M-of-N approval the contract deducts from `AccountBalance` and creates a `PendingClaim` record (additive — multiple approvals to the same recipient stack). +3. The designated recipient calls `ClaimBalance` (Method 11) — requires their signature — to receive the BEAM. + +For a 1-of-1 account the propose and execute happen in one transaction (single-signer fast path). + +### List Transfer Flow + +1. Source account proposes `s_InitiateTransfer` naming the destination account and list. On approval a `PendingTransfer` record is created with a TTL. +2. Destination account proposes `s_AcceptTransfer` naming the source account and list. On approval all list storage is migrated to the destination's namespace: the list header (with `m_nProposals` reset to 0), any list-specific signers, all asset entries (single or multi). Stale list proposals on the source are discarded. +3. Either side can abandon: the source proposes `s_CancelTransfer`; the destination simply never accepts. Expired `PendingTransfer` records can be cleaned up by anyone via `CleanupTransfer` (Method 12). + +`m_nLists` is unchanged globally — the list moves, it is not created or destroyed. + +### Limits + +| Constant | Value | +|---|---| +| Max signers per SignerSet | 8 (bitmask fits in `uint8_t`) | +| Max fake Asset IDs per multi-asset group | 16 | +| Account name max length | 64 bytes | +| Account website max length | 128 bytes | +| Account description max length | 256 bytes | +| List name max length | 64 bytes | +| List description max length | 256 bytes | + +### Single-Signer Fast Path + +When an account or list has threshold M = 1 (solo operation), a `ProposeXAction` call auto-casts the proposer's yes-vote and executes the action immediately — no separate `VoteXProposal` transaction is needed. + +### Upgradability + +The contract uses the `upgradable3` pattern. The upgrade governance (approver set, required approvals) is configured at init time via `Upgradable3::Settings`. + +--- + +## Planned Extensions (TODO) + +1. **Weighted voting** — signers carry individual weights; proposals pass when the cumulative yes-weight reaches a configured threshold. +2. **Per-action TTL floors** — sensitive operations (`DeleteAccount`, `RemoveSigner`) enforce a longer minimum proposal lifetime so co-signers have more time to veto. +3. **Signer rotation quorum lock** — prevent reducing N below a floor value so a compromised signer cannot unilaterally strip all co-signers. diff --git a/bvm/Shaders/asset_lists/contract.cpp b/bvm/Shaders/asset_lists/contract.cpp new file mode 100644 index 0000000000..33df89bb07 --- /dev/null +++ b/bvm/Shaders/asset_lists/contract.cpp @@ -0,0 +1,1306 @@ +#include "../common.h" +#include "../Math.h" +#include "contract.h" +#include "../upgradable3/contract_impl.h" +#include "../dao-vault/contract.h" + +namespace AssetLists { + +// ============================================================ +// Buffer-size constants +// ============================================================ + +static const uint32_t s_AccountStrBufMax = + 1 + Account::s_NameMaxLen + + 1 + Account::s_WebsiteMaxLen + + 2 + Account::s_DescMaxLen; + +static const uint32_t s_ListStrBufMax = + 1 + List::s_NameMaxLen + + 2 + List::s_DescMaxLen; + +static const uint32_t s_AccountValMax = sizeof(Account) + s_AccountStrBufMax; +static const uint32_t s_ListValMax = sizeof(List) + s_ListStrBufMax; + +// Largest possible payload per action type: +// s_UpdateInfo (account) = header + 64 + 128 + 256 = 452 +// s_UpdateInfo (list) = header + 64 + 256 = 323 +// s_CreateList = sizeof(PayloadCreateList) + 64 + 256 +// s_SetListManagers = s_SignerSetMaxValSize +// Use the account UpdateInfo size as the conservative upper bound. +static const uint32_t s_ProposalPayloadMax = + sizeof(PayloadUpdateAccountInfo) + + Account::s_NameMaxLen + Account::s_WebsiteMaxLen + Account::s_DescMaxLen; + +static const uint32_t s_ProposalValMax = sizeof(Proposal) + s_ProposalPayloadMax; + +// ============================================================ +// State helper +// ============================================================ + +struct MyState : public State +{ + MyState() { Env::LoadVar_T((uint8_t) State::s_Key, *this); } + void Save() { Env::SaveVar_T((uint8_t) State::s_Key, *this); } +}; + +// ============================================================ +// Helpers +// ============================================================ + +static void SendFee(const ContractID& cid, Amount amount) +{ + if (!amount) return; + DaoVault::Method::Deposit arg; + arg.m_Aid = 0; // BEAM + arg.m_Amount = amount; + Env::CallFar_T(cid, arg); +} + +// Accumulate BEAM into an account's on-chain balance (from proposal fees) +static void AddAccountBalance(const PubKey& pkAccount, Amount amount) +{ + if (!amount) return; + AccountBalance::Key bk; + _POD_(bk.m_pkAccount) = pkAccount; + AccountBalance bal; + if (!Env::LoadVar_T(bk, bal)) + bal.m_Amount = 0; + Strict::Add(bal.m_Amount, amount); + Env::SaveVar_T(bk, bal); +} + +// Count set bits in a uint8_t (popcount) +static uint8_t CountBits(uint8_t v) +{ + uint8_t n = 0; + for (; v; v &= v - 1) n++; + return n; +} + +// Compare two PubKeys — returns true if equal +static bool PkEq(const PubKey& a, const PubKey& b) +{ + return !Env::Memcmp(&a, &b, sizeof(PubKey)); +} + +// Load a full variable-length value into buf. Halts if fewer than nMin bytes read. +template +static uint32_t LoadFull(const TKey& key, void* pBuf, uint32_t nBufMax, uint32_t nMin) +{ + uint32_t n = Env::LoadVar(&key, sizeof(key), pBuf, nBufMax, KeyTag::Internal); + Env::Halt_if(n < nMin); + return n; +} + +template +static void SaveFull(const TKey& key, const void* pBuf, uint32_t nBytes) +{ + Env::SaveVar(&key, sizeof(key), pBuf, nBytes, KeyTag::Internal); +} + +// ============================================================ +// String packing helpers +// ============================================================ + +static uint32_t PackAccountStrings(uint8_t* dst, + uint8_t nName, uint8_t nWeb, uint16_t nDesc, const void* pSrc) +{ + Env::Halt_if(nName > Account::s_NameMaxLen); + Env::Halt_if(nWeb > Account::s_WebsiteMaxLen); + Env::Halt_if(nDesc > Account::s_DescMaxLen); + + uint8_t* p = dst; + const uint8_t* s = reinterpret_cast(pSrc); + + *p++ = nName; Env::Memcpy(p, s, nName); p += nName; s += nName; + *p++ = nWeb; Env::Memcpy(p, s, nWeb); p += nWeb; s += nWeb; + p[0] = (uint8_t) nDesc; p[1] = (uint8_t)(nDesc >> 8); p += 2; + Env::Memcpy(p, s, nDesc); p += nDesc; + return (uint32_t)(p - dst); +} + +static uint32_t PackListStrings(uint8_t* dst, + uint8_t nName, uint16_t nDesc, const void* pSrc) +{ + Env::Halt_if(nName > List::s_NameMaxLen); + Env::Halt_if(nDesc > List::s_DescMaxLen); + + uint8_t* p = dst; + const uint8_t* s = reinterpret_cast(pSrc); + + *p++ = nName; Env::Memcpy(p, s, nName); p += nName; s += nName; + p[0] = (uint8_t) nDesc; p[1] = (uint8_t)(nDesc >> 8); p += 2; + Env::Memcpy(p, s, nDesc); p += nDesc; + return (uint32_t)(p - dst); +} + +// ============================================================ +// SignerSet helpers +// ============================================================ + +static uint32_t LoadSigners(const AccountSigners::Key& sk, + uint8_t (&buf)[s_SignerSetMaxValSize]) +{ + return LoadFull(sk, buf, sizeof(buf), sizeof(SignerSet)); +} + +static uint32_t LoadAccountSigners(const PubKey& pkAccount, + uint8_t (&buf)[s_SignerSetMaxValSize]) +{ + AccountSigners::Key sk; + _POD_(sk.m_pkAccount) = pkAccount; + return LoadSigners(sk, buf); +} + +// Load the effective signer set for a list: +// uses list-specific managers if present, otherwise falls back to account signers. +static uint32_t LoadListEffectiveSigners(const PubKey& pkAccount, const PubKey& pkList, + uint8_t (&buf)[s_SignerSetMaxValSize]) +{ + ListSigners::Key lsk; + _POD_(lsk.m_pkAccount) = pkAccount; + _POD_(lsk.m_pkList) = pkList; + + uint32_t n = Env::LoadVar(&lsk, sizeof(lsk), buf, sizeof(buf), KeyTag::Internal); + if (n >= sizeof(SignerSet)) + return n; + + return LoadAccountSigners(pkAccount, buf); +} + +// Returns the 0-based index of pk in ss, or ss.m_nSigners if not found. +static uint8_t FindSignerIdx(const SignerSet& ss, const PubKey& pk) +{ + for (uint8_t i = 0; i < ss.m_nSigners; i++) + if (PkEq(ss.Signers()[i], pk)) + return i; + return ss.m_nSigners; // not found +} + +// ============================================================ +// Proposal helpers +// ============================================================ + +struct VoteResult { bool execute; bool reject; }; + +// Apply a vote to a proposal. Updates YesMask/NoMask and returns the outcome. +// Halts if expired, or if this signer already voted. +static VoteResult ApplyVote(Proposal& p, const SignerSet& ss, + uint8_t signerIdx, uint8_t bYes) +{ + Env::Halt_if(Env::get_Height() > p.m_hExpire); // expired + Env::Halt_if((p.m_YesMask | p.m_NoMask) >> signerIdx & 1); // already voted + + if (bYes) + { + p.m_YesMask |= (uint8_t)(1u << signerIdx); + return { CountBits(p.m_YesMask) >= ss.m_Threshold, false }; + } + else + { + p.m_NoMask |= (uint8_t)(1u << signerIdx); + return { false, CountBits(p.m_NoMask) >= ss.RejectThreshold() }; + } +} + +// Try to auto-cast a yes vote from the proposer if they are a signer. +// Returns the vote outcome so the caller can decide whether to execute immediately. +static VoteResult AutoVoteIfSigner(Proposal& p, const SignerSet& ss, + const PubKey& pkProposer) +{ + uint8_t idx = FindSignerIdx(ss, pkProposer); + if (idx < ss.m_nSigners) + return ApplyVote(p, ss, idx, 1 /*yes*/); + return { false, false }; +} + +// ============================================================ +// Account action executors +// ============================================================ + +static void ExecUpdateAccountInfo(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadUpdateAccountInfo)); + const auto& pay = *reinterpret_cast(&p + 1); + + Account::Key ak; + _POD_(ak.m_pkAccount) = pkAccount; + + uint8_t buf[s_AccountValMax]; + uint32_t nOld = LoadFull(ak, buf, sizeof(buf), sizeof(Account)); + Account& hdr = *reinterpret_cast(buf); + + uint8_t strBuf[s_AccountStrBufMax]; + uint32_t nStr = PackAccountStrings(strBuf, pay.m_nNameLen, pay.m_nWebsiteLen, + pay.m_nDescLen, &pay + 1); + Env::Memcpy(buf + sizeof(Account), strBuf, nStr); + SaveFull(ak, buf, sizeof(Account) + nStr); + (void) nOld; (void) hdr; +} + +static void ExecDeleteAccount(const PubKey& pkAccount) +{ + Account::Key ak; + _POD_(ak.m_pkAccount) = pkAccount; + Env::Halt_if(!Env::DelVar_T(ak)); + + AccountSigners::Key sk; + _POD_(sk.m_pkAccount) = pkAccount; + Env::DelVar_T(sk); + + MyState s; + s.m_nAccounts--; + s.Save(); +} + +static void ExecAddSigner(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadAddSigner)); + const auto& pay = *reinterpret_cast(&p + 1); + + AccountSigners::Key sk; + _POD_(sk.m_pkAccount) = pkAccount; + + uint8_t buf[s_SignerSetMaxValSize]; + LoadFull(sk, buf, sizeof(buf), sizeof(SignerSet)); + SignerSet& ss = *reinterpret_cast(buf); + + Env::Halt_if(ss.m_nSigners >= s_MaxSigners); + _POD_(ss.Signers()[ss.m_nSigners]) = pay.m_pkNew; + ss.m_nSigners++; + ss.m_Threshold = pay.m_NewThreshold; + Env::Halt_if(!ss.IsValid()); + SaveFull(sk, buf, ss.ValSize()); +} + +static void ExecRemoveSigner(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadRemoveSigner)); + const auto& pay = *reinterpret_cast(&p + 1); + + AccountSigners::Key sk; + _POD_(sk.m_pkAccount) = pkAccount; + + uint8_t buf[s_SignerSetMaxValSize]; + LoadFull(sk, buf, sizeof(buf), sizeof(SignerSet)); + SignerSet& ss = *reinterpret_cast(buf); + + Env::Halt_if(pay.m_SignerIdx >= ss.m_nSigners); + Env::Halt_if(ss.m_nSigners <= 1); // cannot remove last signer + + PubKey* signers = ss.Signers(); + for (uint8_t i = pay.m_SignerIdx; i + 1 < ss.m_nSigners; i++) + _POD_(signers[i]) = signers[i + 1]; + + ss.m_nSigners--; + ss.m_Threshold = pay.m_NewThreshold; + Env::Halt_if(!ss.IsValid()); + SaveFull(sk, buf, ss.ValSize()); +} + +static void ExecCreateList(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadCreateList)); + const auto& pay = *reinterpret_cast(&p + 1); + Env::Halt_if(pay.m_ListType != ListType::s_Single && + pay.m_ListType != ListType::s_Multi); + + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pay.m_pkList; + + List chk; + Env::Halt_if(Env::LoadVar_T(lk, chk)); // must not already exist + + uint8_t listBuf[s_ListValMax]; + List& hdr = *reinterpret_cast(listBuf); + hdr.m_hCreated = Env::get_Height(); + hdr.m_nAssets = 0; + hdr.m_nProposals = 0; + hdr.m_ListType = pay.m_ListType; + + uint8_t strBuf[s_ListStrBufMax]; + uint32_t nStr = PackListStrings(strBuf, pay.m_nNameLen, pay.m_nDescLen, &pay + 1); + Env::Memcpy(listBuf + sizeof(List), strBuf, nStr); + SaveFull(lk, listBuf, sizeof(List) + nStr); + + MyState s; + s.m_nLists++; + s.Save(); + SendFee(s.m_Settings.m_cidDaoVault, s.m_Settings.m_FeeList); +} + +// ============================================================ +// Account action executors — balance withdrawal & list transfer +// ============================================================ + +static void ExecWithdrawBalance(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadWithdrawBalance)); + const auto& pay = *reinterpret_cast(&p + 1); + Env::Halt_if(!pay.m_Amount); + + AccountBalance::Key bk; + _POD_(bk.m_pkAccount) = pkAccount; + AccountBalance bal; + Env::Halt_if(!Env::LoadVar_T(bk, bal)); + Env::Halt_if(pay.m_Amount > bal.m_Amount); + + // Create (or add to) a PendingClaim for the designated recipient + PendingClaim::Key ck; + _POD_(ck.m_pkRecipient) = pay.m_pkRecipient; + _POD_(ck.m_pkAccount) = pkAccount; + PendingClaim claim; + if (Env::LoadVar_T(ck, claim)) + Strict::Add(claim.m_Amount, pay.m_Amount); + else + claim.m_Amount = pay.m_Amount; + Env::SaveVar_T(ck, claim); + + // Deduct from account balance; delete the record when empty + bal.m_Amount -= pay.m_Amount; + if (!bal.m_Amount) + Env::DelVar_T(bk); + else + Env::SaveVar_T(bk, bal); +} + +static void ExecInitiateTransfer(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadInitiateTransfer)); + const auto& pay = *reinterpret_cast(&p + 1); + + // List must exist and belong to this account + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pay.m_pkList; + List lst; + Env::Halt_if(!Env::LoadVar_T(lk, lst)); + + // Destination account must exist + Account::Key ak; + _POD_(ak.m_pkAccount) = pay.m_pkAccountDest; + Account acct; + Env::Halt_if(!Env::LoadVar_T(ak, acct)); + + // No duplicate pending transfer for this list + PendingTransfer::Key ptk; + _POD_(ptk.m_pkAccountSrc) = pkAccount; + _POD_(ptk.m_pkList) = pay.m_pkList; + PendingTransfer pt; + Env::Halt_if(Env::LoadVar_T(ptk, pt)); // must not already exist + + _POD_(pt.m_pkAccountDest) = pay.m_pkAccountDest; + pt.m_hExpire = Env::get_Height() + MyState().m_Settings.m_ProposalTtl; + Env::SaveVar_T(ptk, pt); +} + +static void ExecCancelTransfer(const PubKey& pkAccount, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadCancelTransfer)); + const auto& pay = *reinterpret_cast(&p + 1); + + PendingTransfer::Key ptk; + _POD_(ptk.m_pkAccountSrc) = pkAccount; + _POD_(ptk.m_pkList) = pay.m_pkList; + Env::Halt_if(!Env::DelVar_T(ptk)); +} + +static void ExecAcceptTransfer(const PubKey& pkAccountDest, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadAcceptTransfer)); + const auto& pay = *reinterpret_cast(&p + 1); + + // Load and validate pending transfer + PendingTransfer::Key ptk; + _POD_(ptk.m_pkAccountSrc) = pay.m_pkAccountSrc; + _POD_(ptk.m_pkList) = pay.m_pkList; + PendingTransfer pt; + Env::Halt_if(!Env::LoadVar_T(ptk, pt)); + Env::Halt_if(!PkEq(pt.m_pkAccountDest, pkAccountDest)); + Env::Halt_if(Env::get_Height() > pt.m_hExpire); + + const PubKey& pkSrc = pay.m_pkAccountSrc; + const PubKey& pkDest = pkAccountDest; + const PubKey& pkList = pay.m_pkList; + + // Destination must not already own a list with the same pkList + List::Key dstLk; + _POD_(dstLk.m_pkAccount) = pkDest; + _POD_(dstLk.m_pkList) = pkList; + List chk; + Env::Halt_if(Env::LoadVar_T(dstLk, chk)); + + // — migrate list header + strings — + List::Key srcLk; + _POD_(srcLk.m_pkAccount) = pkSrc; + _POD_(srcLk.m_pkList) = pkList; + uint8_t lstBuf[s_ListValMax]; + uint32_t nListBytes = LoadFull(srcLk, lstBuf, sizeof(lstBuf), sizeof(List)); + List& lst = *reinterpret_cast(lstBuf); + lst.m_nProposals = 0; // pending proposals are stale; reset the counter + SaveFull(dstLk, lstBuf, nListBytes); + Env::DelVar_T(srcLk); + + // — discard all pending list proposals (stale after ownership change) — + { + ListProposal::Key p0, p1; + p0.m_Tag = Tags::s_ListProposal; _POD_(p0.m_pkAccount) = pkSrc; _POD_(p0.m_pkList) = pkList; p0.m_ID = 0; + p1.m_Tag = Tags::s_ListProposal; _POD_(p1.m_pkAccount) = pkSrc; _POD_(p1.m_pkList) = pkList; p1.m_ID = static_cast(-1); + for (Env::VarReader vr(p0, p1); ; ) + { + ListProposal::Key ekey; Proposal prop; + if (!vr.MoveNext_T(ekey, prop)) break; + Env::DelVar_T(ekey); + } + } + + // — migrate list-specific signers if present — + { + ListSigners::Key srcLsk; + srcLsk.m_Tag = Tags::s_ListSigners; _POD_(srcLsk.m_pkAccount) = pkSrc; _POD_(srcLsk.m_pkList) = pkList; + ListSigners::Key dstLsk; + dstLsk.m_Tag = Tags::s_ListSigners; _POD_(dstLsk.m_pkAccount) = pkDest; _POD_(dstLsk.m_pkList) = pkList; + uint8_t ssBuf[s_SignerSetMaxValSize]; + uint32_t n = Env::LoadVar(&srcLsk, sizeof(srcLsk), ssBuf, sizeof(ssBuf), KeyTag::Internal); + if (n >= sizeof(SignerSet)) + { + SaveFull(dstLsk, ssBuf, n); + Env::DelVar_T(srcLsk); + } + } + + // — migrate asset entries — + if (lst.m_ListType == ListType::s_Single) + { + ListAsset::Key k0, k1; + k0.m_Tag = Tags::s_ListAsset; _POD_(k0.m_pkAccount) = pkSrc; _POD_(k0.m_pkList) = pkList; k0.m_Aid = 0; + k1.m_Tag = Tags::s_ListAsset; _POD_(k1.m_pkAccount) = pkSrc; _POD_(k1.m_pkList) = pkList; k1.m_Aid = static_cast(-1); + for (Env::VarReader vr(k0, k1); ; ) + { + ListAsset::Key srcKey; ListAsset la; + if (!vr.MoveNext_T(srcKey, la)) break; + ListAsset::Key dstKey = srcKey; + _POD_(dstKey.m_pkAccount) = pkDest; + Env::SaveVar_T(dstKey, la); + Env::DelVar_T(srcKey); + } + } + else + { + AssetGroup::Key k0, k1; + k0.m_Tag = Tags::s_ListAssetGroup; _POD_(k0.m_pkAccount) = pkSrc; _POD_(k0.m_pkList) = pkList; k0.m_RealAid = 0; + k1.m_Tag = Tags::s_ListAssetGroup; _POD_(k1.m_pkAccount) = pkSrc; _POD_(k1.m_pkList) = pkList; k1.m_RealAid = static_cast(-1); + uint8_t agBuf[s_AssetGroupMaxValSize]; + for (Env::VarReader vr(k0, k1); ; ) + { + AssetGroup::Key srcKey; AssetGroup agHdr; + if (!vr.MoveNext_T(srcKey, agHdr)) break; + uint32_t nBytes = LoadFull(srcKey, agBuf, sizeof(agBuf), sizeof(AssetGroup)); + AssetGroup::Key dstKey = srcKey; + _POD_(dstKey.m_pkAccount) = pkDest; + SaveFull(dstKey, agBuf, nBytes); + Env::DelVar_T(srcKey); + } + } + + // — remove the pending transfer record — + Env::DelVar_T(ptk); + // m_nLists is unchanged globally (the list moved, not created or destroyed) +} + +static void DispatchAccountAction(const PubKey& pkAccount, + const Proposal& p, uint32_t nPropBytes) +{ + uint32_t nPay = nPropBytes > sizeof(Proposal) ? nPropBytes - sizeof(Proposal) : 0; + switch (p.m_Action) + { + case AccountActions::s_UpdateInfo: ExecUpdateAccountInfo(pkAccount, p, nPay); break; + case AccountActions::s_DeleteAccount: ExecDeleteAccount(pkAccount); break; + case AccountActions::s_AddSigner: ExecAddSigner(pkAccount, p, nPay); break; + case AccountActions::s_RemoveSigner: ExecRemoveSigner(pkAccount, p, nPay); break; + case AccountActions::s_CreateList: ExecCreateList(pkAccount, p, nPay); break; + case AccountActions::s_WithdrawBalance: ExecWithdrawBalance(pkAccount, p, nPay); break; + case AccountActions::s_InitiateTransfer: ExecInitiateTransfer(pkAccount, p, nPay); break; + case AccountActions::s_CancelTransfer: ExecCancelTransfer(pkAccount, p, nPay); break; + case AccountActions::s_AcceptTransfer: ExecAcceptTransfer(pkAccount, p, nPay); break; + default: Env::Halt(); + } +} + +// ============================================================ +// List action executors +// ============================================================ + +static void ExecUpdateListInfo(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadUpdateListInfo)); + const auto& pay = *reinterpret_cast(&p + 1); + + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pkList; + + uint8_t buf[s_ListValMax]; + uint32_t nOld = LoadFull(lk, buf, sizeof(buf), sizeof(List)); + + uint8_t strBuf[s_ListStrBufMax]; + uint32_t nStr = PackListStrings(strBuf, pay.m_nNameLen, pay.m_nDescLen, &pay + 1); + Env::Memcpy(buf + sizeof(List), strBuf, nStr); + SaveFull(lk, buf, sizeof(List) + nStr); + (void) nOld; +} + +// Delete all ListAsset entries for a single-asset list +static void DeleteSingleAssetEntries(const PubKey& pkAccount, const PubKey& pkList) +{ + ListAsset::Key k0, k1; + k0.m_Tag = Tags::s_ListAsset; _POD_(k0.m_pkAccount) = pkAccount; _POD_(k0.m_pkList) = pkList; k0.m_Aid = 0; + k1.m_Tag = Tags::s_ListAsset; _POD_(k1.m_pkAccount) = pkAccount; _POD_(k1.m_pkList) = pkList; k1.m_Aid = static_cast(-1); + + for (Env::VarReader vr(k0, k1); ; ) + { + ListAsset::Key ekey; ListAsset la; + if (!vr.MoveNext_T(ekey, la)) break; + Env::DelVar_T(ekey); + } +} + +// Delete all AssetGroup entries for a multi-asset list +static void DeleteMultiAssetEntries(const PubKey& pkAccount, const PubKey& pkList) +{ + AssetGroup::Key k0, k1; + k0.m_Tag = Tags::s_ListAssetGroup; _POD_(k0.m_pkAccount) = pkAccount; _POD_(k0.m_pkList) = pkList; k0.m_RealAid = 0; + k1.m_Tag = Tags::s_ListAssetGroup; _POD_(k1.m_pkAccount) = pkAccount; _POD_(k1.m_pkList) = pkList; k1.m_RealAid = static_cast(-1); + + for (Env::VarReader vr(k0, k1); ; ) + { + AssetGroup::Key ekey; AssetGroup ag; + if (!vr.MoveNext_T(ekey, ag)) break; + Env::DelVar_T(ekey); + } +} + +static void ExecDeleteList(const PubKey& pkAccount, const PubKey& pkList) +{ + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pkList; + + List lst; + Env::Halt_if(!Env::LoadVar_T(lk, lst)); + + if (lst.m_nAssets > 0) + { + if (lst.m_ListType == ListType::s_Single) + DeleteSingleAssetEntries(pkAccount, pkList); + else + DeleteMultiAssetEntries(pkAccount, pkList); + } + + // Remove list-specific signers if any + ListSigners::Key lsk; + _POD_(lsk.m_pkAccount) = pkAccount; + _POD_(lsk.m_pkList) = pkList; + Env::DelVar_T(lsk); + + Env::DelVar_T(lk); + + MyState s; + s.m_nLists--; + s.Save(); +} + +// Single-asset: add or remove one AssetID entry +static void ExecSingleAssetOp(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPayload, bool bAdd) +{ + Env::Halt_if(nPayload < sizeof(PayloadAsset)); + const auto& pay = *reinterpret_cast(&p + 1); + + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pkList; + + uint8_t listBuf[s_ListValMax]; + uint32_t nListBytes = LoadFull(lk, listBuf, sizeof(listBuf), sizeof(List)); + List& lst = *reinterpret_cast(listBuf); + Env::Halt_if(lst.m_ListType != ListType::s_Single); + + ListAsset::Key lak; + lak.m_Tag = Tags::s_ListAsset; + _POD_(lak.m_pkAccount) = pkAccount; + _POD_(lak.m_pkList) = pkList; + lak.m_Aid = pay.m_Aid; + + if (bAdd) + { + ListAsset existing; + Env::Halt_if(Env::LoadVar_T(lak, existing)); + ListAsset la; la.m_Dummy = 0; + Env::SaveVar_T(lak, la); + lst.m_nAssets++; + } + else + { + Env::Halt_if(!Env::DelVar_T(lak)); + lst.m_nAssets--; + } + SaveFull(lk, listBuf, nListBytes); +} + +// Multi-asset: add or replace a group entry (upsert by real AssetID) +static void ExecAddAssetGroup(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadAddAssetGroup)); + const auto& pay = *reinterpret_cast(&p + 1); + Env::Halt_if(pay.m_nFakes > s_MaxFakes); + Env::Halt_if(nPayload < sizeof(PayloadAddAssetGroup) + pay.m_nFakes * sizeof(AssetID)); + + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pkList; + + uint8_t listBuf[s_ListValMax]; + uint32_t nListBytes = LoadFull(lk, listBuf, sizeof(listBuf), sizeof(List)); + List& lst = *reinterpret_cast(listBuf); + Env::Halt_if(lst.m_ListType != ListType::s_Multi); + + AssetGroup::Key gk; + gk.m_Tag = Tags::s_ListAssetGroup; + _POD_(gk.m_pkAccount) = pkAccount; + _POD_(gk.m_pkList) = pkList; + gk.m_RealAid = Utils::FromBE(pay.m_RealAid); // big-endian in key + + // Check whether this group already exists (for counter tracking) + bool bExisting = false; + { + AssetGroup existing; + bExisting = Env::LoadVar_T(gk, existing); + } + + // Build and save the new group value + uint8_t agBuf[s_AssetGroupMaxValSize]; + AssetGroup& ag = *reinterpret_cast(agBuf); + ag.m_nFakes = pay.m_nFakes; + Env::Memcpy(agBuf + sizeof(AssetGroup), + reinterpret_cast(&pay + 1), + pay.m_nFakes * sizeof(AssetID)); + SaveFull(gk, agBuf, ag.ValSize()); + + if (!bExisting) + { + lst.m_nAssets++; + SaveFull(lk, listBuf, nListBytes); + } +} + +// Multi-asset: remove a group by real AssetID +static void ExecRemoveAssetGroup(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(PayloadRemoveAssetGroup)); + const auto& pay = *reinterpret_cast(&p + 1); + + List::Key lk; + _POD_(lk.m_pkAccount) = pkAccount; + _POD_(lk.m_pkList) = pkList; + + uint8_t listBuf[s_ListValMax]; + uint32_t nListBytes = LoadFull(lk, listBuf, sizeof(listBuf), sizeof(List)); + List& lst = *reinterpret_cast(listBuf); + Env::Halt_if(lst.m_ListType != ListType::s_Multi); + + AssetGroup::Key gk; + gk.m_Tag = Tags::s_ListAssetGroup; + _POD_(gk.m_pkAccount) = pkAccount; + _POD_(gk.m_pkList) = pkList; + gk.m_RealAid = Utils::FromBE(pay.m_RealAid); + + Env::Halt_if(!Env::DelVar_T(gk)); + + lst.m_nAssets--; + SaveFull(lk, listBuf, nListBytes); +} + +static void ExecSetListManagers(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPayload) +{ + Env::Halt_if(nPayload < sizeof(SignerSet)); + const SignerSet& ss = *reinterpret_cast(&p + 1); + Env::Halt_if(!ss.IsValid()); + Env::Halt_if(nPayload < ss.ValSize()); + + ListSigners::Key lsk; + _POD_(lsk.m_pkAccount) = pkAccount; + _POD_(lsk.m_pkList) = pkList; + SaveFull(lsk, &ss, ss.ValSize()); +} + +static void ExecClearListManagers(const PubKey& pkAccount, const PubKey& pkList) +{ + ListSigners::Key lsk; + _POD_(lsk.m_pkAccount) = pkAccount; + _POD_(lsk.m_pkList) = pkList; + Env::DelVar_T(lsk); // no-op if not present +} + +static void DispatchListAction(const PubKey& pkAccount, const PubKey& pkList, + const Proposal& p, uint32_t nPropBytes) +{ + uint32_t nPay = nPropBytes > sizeof(Proposal) ? nPropBytes - sizeof(Proposal) : 0; + switch (p.m_Action) + { + case ListActions::s_UpdateInfo: + ExecUpdateListInfo(pkAccount, pkList, p, nPay); break; + case ListActions::s_DeleteList: + ExecDeleteList(pkAccount, pkList); break; + case ListActions::s_AddAsset: + ExecSingleAssetOp(pkAccount, pkList, p, nPay, true); break; + case ListActions::s_RemoveAsset: + ExecSingleAssetOp(pkAccount, pkList, p, nPay, false); break; + case ListActions::s_AddAssetGroup: + ExecAddAssetGroup(pkAccount, pkList, p, nPay); break; + case ListActions::s_RemoveAssetGroup: + ExecRemoveAssetGroup(pkAccount, pkList, p, nPay); break; + case ListActions::s_SetListManagers: + ExecSetListManagers(pkAccount, pkList, p, nPay); break; + case ListActions::s_ClearListManagers: + ExecClearListManagers(pkAccount, pkList); break; + default: Env::Halt(); + } +} + +// ============================================================ +// Proposal creation helper (shared between ProposeAccount/List) +// ============================================================ + +// Builds a Proposal into propBuf, copying nPayload bytes from pPayload. +// Tries to auto-cast a yes vote from the proposer if they are in the signer set. +// Returns the VoteResult so the caller can decide whether to execute immediately. +static VoteResult BuildProposal( + uint8_t* propBuf, uint32_t& nPropBytes, + const PubKey& pkProposer, uint8_t action, + const void* pPayload, uint32_t nPayload, + const SignerSet& ss) +{ + Proposal& prop = *reinterpret_cast(propBuf); + _POD_(prop.m_pkProposer) = pkProposer; + prop.m_hExpire = Env::get_Height() + MyState().m_Settings.m_ProposalTtl; + prop.m_Action = action; + prop.m_YesMask = 0; + prop.m_NoMask = 0; + Env::Memcpy(propBuf + sizeof(Proposal), pPayload, nPayload); + nPropBytes = sizeof(Proposal) + nPayload; + + return AutoVoteIfSigner(prop, ss, pkProposer); +} + +// ============================================================ +// Ctor / Dtor +// ============================================================ + +BEAM_EXPORT void Ctor(const Method::Init& r) +{ + r.m_Upgradable.TestNumApprovers(); + r.m_Upgradable.Save(); + Env::Halt_if(!Env::RefAdd(r.m_Settings.m_cidDaoVault)); + + State s; + _POD_(s).SetZero(); + _POD_(s.m_Settings) = r.m_Settings; + Env::SaveVar_T((uint8_t) State::s_Key, s); +} + +BEAM_EXPORT void Dtor(void*) +{ + State s; + Env::LoadVar_T((uint8_t) State::s_Key, s); + Env::RefRelease(s.m_Settings.m_cidDaoVault); + Env::DelVar_T((uint8_t) State::s_Key); +} + +BEAM_EXPORT void Method_2(void*) {} // upgrade hook + +// ============================================================ +// Method_3 — CreateAccount +// ============================================================ + +BEAM_EXPORT void Method_3(const Method::CreateAccount& r) +{ + Env::Halt_if(r.m_nSigners < 1 || r.m_nSigners > s_MaxSigners); + Env::Halt_if(r.m_Threshold < 1 || r.m_Threshold > r.m_nSigners); + + // Variable tail: signers[], name[], website[], desc[] + const uint8_t* pTail = reinterpret_cast(&r + 1); + + // Build SignerSet + uint8_t ssBuf[s_SignerSetMaxValSize]; + SignerSet& ss = *reinterpret_cast(ssBuf); + ss.m_Threshold = r.m_Threshold; + ss.m_nSigners = r.m_nSigners; + uint32_t nSignersBytes = r.m_nSigners * sizeof(PubKey); + Env::Memcpy(ss.Signers(), pTail, nSignersBytes); + pTail += nSignersBytes; + + // Pack profile strings + uint8_t strBuf[s_AccountStrBufMax]; + uint32_t nStr = PackAccountStrings(strBuf, r.m_nNameLen, r.m_nWebsiteLen, r.m_nDescLen, pTail); + + // Ensure account does not exist + Account::Key ak; + _POD_(ak.m_pkAccount) = r.m_pkAccount; + Account existing; + Env::Halt_if(Env::LoadVar_T(ak, existing)); + + // Save account header + strings + uint8_t accountBuf[s_AccountValMax]; + Account& acct = *reinterpret_cast(accountBuf); + acct.m_hCreated = Env::get_Height(); + acct.m_nProposals = 0; + Env::Memcpy(accountBuf + sizeof(Account), strBuf, nStr); + SaveFull(ak, accountBuf, sizeof(Account) + nStr); + + // Save signer set + AccountSigners::Key sk; + _POD_(sk.m_pkAccount) = r.m_pkAccount; + SaveFull(sk, ssBuf, ss.ValSize()); + + MyState s; + s.m_nAccounts++; + s.Save(); + SendFee(s.m_Settings.m_cidDaoVault, s.m_Settings.m_FeeAccount); + + Env::AddSig(r.m_pkAccount); +} + +// ============================================================ +// Method_4 — ProposeAccountAction +// ============================================================ + +BEAM_EXPORT void Method_4(const Method::ProposeAccountAction& r) +{ + // Account must exist; load it (we need m_nProposals) + Account::Key ak; + _POD_(ak.m_pkAccount) = r.m_pkAccount; + uint8_t acctBuf[s_AccountValMax]; + uint32_t nAcct = LoadFull(ak, acctBuf, sizeof(acctBuf), sizeof(Account)); + Account& acct = *reinterpret_cast(acctBuf); + + // Load signer set + uint8_t ssBuf[s_SignerSetMaxValSize]; + LoadAccountSigners(r.m_pkAccount, ssBuf); + const SignerSet& ss = *reinterpret_cast(ssBuf); + + // Determine payload extent + const uint8_t* pPayload = reinterpret_cast(&r + 1); + uint32_t nPayload = 0; + + switch (r.m_Action) + { + case AccountActions::s_UpdateInfo: + { + const auto& pay = *reinterpret_cast(pPayload); + nPayload = sizeof(PayloadUpdateAccountInfo) + + pay.m_nNameLen + pay.m_nWebsiteLen + (uint32_t)pay.m_nDescLen; + break; + } + case AccountActions::s_DeleteAccount: + nPayload = 0; + break; + case AccountActions::s_AddSigner: + nPayload = sizeof(PayloadAddSigner); + break; + case AccountActions::s_RemoveSigner: + nPayload = sizeof(PayloadRemoveSigner); + break; + case AccountActions::s_CreateList: + { + const auto& pay = *reinterpret_cast(pPayload); + nPayload = sizeof(PayloadCreateList) + + pay.m_nNameLen + (uint32_t)pay.m_nDescLen; + Env::AddSig(pay.m_pkList); // prove control of new list identity key + break; + } + case AccountActions::s_WithdrawBalance: + nPayload = sizeof(PayloadWithdrawBalance); + break; + case AccountActions::s_InitiateTransfer: + nPayload = sizeof(PayloadInitiateTransfer); + break; + case AccountActions::s_CancelTransfer: + nPayload = sizeof(PayloadCancelTransfer); + break; + case AccountActions::s_AcceptTransfer: + nPayload = sizeof(PayloadAcceptTransfer); + break; + default: Env::Halt(); + } + + // Non-signer proposal fee: half to DAO Vault, half to account balance + { + MyState s; + bool bIsSigner = FindSignerIdx(ss, r.m_pkProposer) < ss.m_nSigners; + if (!bIsSigner) + { + Amount fee = s.m_Settings.m_FeeProposal; + if (fee) + { + Env::FundsLock(0, fee); + Amount halfDao = fee / 2; + SendFee(s.m_Settings.m_cidDaoVault, halfDao); + AddAccountBalance(r.m_pkAccount, fee - halfDao); + } + } + } + + // Build proposal; try auto-vote if proposer is a signer + AccountProposal::Key pk; + pk.m_Tag = Tags::s_AccountProposal; + _POD_(pk.m_pkAccount) = r.m_pkAccount; + pk.m_ID = Utils::FromBE(acct.m_nProposals); + + uint8_t propBuf[s_ProposalValMax]; + uint32_t nProp = 0; + VoteResult vr = BuildProposal(propBuf, nProp, + r.m_pkProposer, r.m_Action, pPayload, nPayload, ss); + + if (vr.execute) + { + Proposal& prop = *reinterpret_cast(propBuf); + DispatchAccountAction(r.m_pkAccount, prop, nProp); + // proposal not stored; no ID increment needed? We still increment + // so IDs remain unique across cancelled/fast-path proposals. + } + else + { + SaveFull(pk, propBuf, nProp); + } + + acct.m_nProposals++; + SaveFull(ak, acctBuf, nAcct); + + Env::AddSig(r.m_pkProposer); +} + +// ============================================================ +// Method_5 — VoteAccountProposal +// ============================================================ + +BEAM_EXPORT void Method_5(const Method::VoteAccountProposal& r) +{ + uint8_t ssBuf[s_SignerSetMaxValSize]; + LoadAccountSigners(r.m_pkAccount, ssBuf); + const SignerSet& ss = *reinterpret_cast(ssBuf); + Env::Halt_if(r.m_SignerIdx >= ss.m_nSigners); + + AccountProposal::Key pk; + pk.m_Tag = Tags::s_AccountProposal; + _POD_(pk.m_pkAccount) = r.m_pkAccount; + pk.m_ID = Utils::FromBE(r.m_ProposalID); + + uint8_t propBuf[s_ProposalValMax]; + uint32_t nProp = LoadFull(pk, propBuf, sizeof(propBuf), sizeof(Proposal)); + Proposal& prop = *reinterpret_cast(propBuf); + + VoteResult vr = ApplyVote(prop, ss, r.m_SignerIdx, r.m_bYes); + + if (vr.execute) + { + DispatchAccountAction(r.m_pkAccount, prop, nProp); + Env::DelVar_T(pk); + } + else if (vr.reject) + { + Env::DelVar_T(pk); + } + else + { + SaveFull(pk, propBuf, nProp); + } + + Env::AddSig(ss.Signers()[r.m_SignerIdx]); +} + +// ============================================================ +// Method_6 — CancelAccountProposal +// ============================================================ + +BEAM_EXPORT void Method_6(const Method::CancelAccountProposal& r) +{ + AccountProposal::Key pk; + pk.m_Tag = Tags::s_AccountProposal; + _POD_(pk.m_pkAccount) = r.m_pkAccount; + pk.m_ID = Utils::FromBE(r.m_ProposalID); + + Proposal prop; + Env::Halt_if(!Env::LoadVar_T(pk, prop)); + Env::Halt_if(!PkEq(prop.m_pkProposer, r.m_pkProposer)); + Env::DelVar_T(pk); + + Env::AddSig(r.m_pkProposer); +} + +// ============================================================ +// Method_7 — ProposeListAction +// ============================================================ + +BEAM_EXPORT void Method_7(const Method::ProposeListAction& r) +{ + List::Key lk; + _POD_(lk.m_pkAccount) = r.m_pkAccount; + _POD_(lk.m_pkList) = r.m_pkList; + uint8_t listBuf[s_ListValMax]; + uint32_t nList = LoadFull(lk, listBuf, sizeof(listBuf), sizeof(List)); + List& lst = *reinterpret_cast(listBuf); + + uint8_t ssBuf[s_SignerSetMaxValSize]; + LoadListEffectiveSigners(r.m_pkAccount, r.m_pkList, ssBuf); + const SignerSet& ss = *reinterpret_cast(ssBuf); + + const uint8_t* pPayload = reinterpret_cast(&r + 1); + uint32_t nPayload = 0; + + switch (r.m_Action) + { + case ListActions::s_UpdateInfo: + { + const auto& pay = *reinterpret_cast(pPayload); + nPayload = sizeof(PayloadUpdateListInfo) + + pay.m_nNameLen + (uint32_t)pay.m_nDescLen; + break; + } + case ListActions::s_DeleteList: + nPayload = 0; + break; + case ListActions::s_AddAsset: + case ListActions::s_RemoveAsset: + Env::Halt_if(lst.m_ListType != ListType::s_Single); + nPayload = sizeof(PayloadAsset); + break; + case ListActions::s_AddAssetGroup: + { + Env::Halt_if(lst.m_ListType != ListType::s_Multi); + const auto& pay = *reinterpret_cast(pPayload); + Env::Halt_if(pay.m_nFakes > s_MaxFakes); + nPayload = sizeof(PayloadAddAssetGroup) + pay.m_nFakes * sizeof(AssetID); + break; + } + case ListActions::s_RemoveAssetGroup: + Env::Halt_if(lst.m_ListType != ListType::s_Multi); + nPayload = sizeof(PayloadRemoveAssetGroup); + break; + case ListActions::s_SetListManagers: + { + const SignerSet& newSS = *reinterpret_cast(pPayload); + Env::Halt_if(!newSS.IsValid()); + nPayload = newSS.ValSize(); + break; + } + case ListActions::s_ClearListManagers: + nPayload = 0; + break; + default: Env::Halt(); + } + + // Non-signer proposal fee: half to DAO Vault, half to account balance + { + MyState s; + bool bIsSigner = FindSignerIdx(ss, r.m_pkProposer) < ss.m_nSigners; + if (!bIsSigner) + { + Amount fee = s.m_Settings.m_FeeProposal; + if (fee) + { + Env::FundsLock(0, fee); + Amount halfDao = fee / 2; + SendFee(s.m_Settings.m_cidDaoVault, halfDao); + AddAccountBalance(r.m_pkAccount, fee - halfDao); + } + } + } + + ListProposal::Key lpk; + lpk.m_Tag = Tags::s_ListProposal; + _POD_(lpk.m_pkAccount) = r.m_pkAccount; + _POD_(lpk.m_pkList) = r.m_pkList; + lpk.m_ID = Utils::FromBE(lst.m_nProposals); + + uint8_t propBuf[s_ProposalValMax]; + uint32_t nProp = 0; + VoteResult vr = BuildProposal(propBuf, nProp, + r.m_pkProposer, r.m_Action, pPayload, nPayload, ss); + + if (vr.execute) + { + Proposal& prop = *reinterpret_cast(propBuf); + DispatchListAction(r.m_pkAccount, r.m_pkList, prop, nProp); + } + else + { + SaveFull(lpk, propBuf, nProp); + } + + lst.m_nProposals++; + SaveFull(lk, listBuf, nList); + + Env::AddSig(r.m_pkProposer); +} + +// ============================================================ +// Method_8 — VoteListProposal +// ============================================================ + +BEAM_EXPORT void Method_8(const Method::VoteListProposal& r) +{ + uint8_t ssBuf[s_SignerSetMaxValSize]; + LoadListEffectiveSigners(r.m_pkAccount, r.m_pkList, ssBuf); + const SignerSet& ss = *reinterpret_cast(ssBuf); + Env::Halt_if(r.m_SignerIdx >= ss.m_nSigners); + + ListProposal::Key lpk; + lpk.m_Tag = Tags::s_ListProposal; + _POD_(lpk.m_pkAccount) = r.m_pkAccount; + _POD_(lpk.m_pkList) = r.m_pkList; + lpk.m_ID = Utils::FromBE(r.m_ProposalID); + + uint8_t propBuf[s_ProposalValMax]; + uint32_t nProp = LoadFull(lpk, propBuf, sizeof(propBuf), sizeof(Proposal)); + Proposal& prop = *reinterpret_cast(propBuf); + + VoteResult vr = ApplyVote(prop, ss, r.m_SignerIdx, r.m_bYes); + + if (vr.execute) + { + DispatchListAction(r.m_pkAccount, r.m_pkList, prop, nProp); + Env::DelVar_T(lpk); + } + else if (vr.reject) + { + Env::DelVar_T(lpk); + } + else + { + SaveFull(lpk, propBuf, nProp); + } + + Env::AddSig(ss.Signers()[r.m_SignerIdx]); +} + +// ============================================================ +// Method_9 — CancelListProposal +// ============================================================ + +BEAM_EXPORT void Method_9(const Method::CancelListProposal& r) +{ + ListProposal::Key lpk; + lpk.m_Tag = Tags::s_ListProposal; + _POD_(lpk.m_pkAccount) = r.m_pkAccount; + _POD_(lpk.m_pkList) = r.m_pkList; + lpk.m_ID = Utils::FromBE(r.m_ProposalID); + + Proposal prop; + Env::Halt_if(!Env::LoadVar_T(lpk, prop)); + Env::Halt_if(!PkEq(prop.m_pkProposer, r.m_pkProposer)); + Env::DelVar_T(lpk); + + Env::AddSig(r.m_pkProposer); +} + +// ============================================================ +// Method_10 — CleanupProposal (anyone can delete expired proposals) +// ============================================================ + +BEAM_EXPORT void Method_10(const Method::CleanupProposal& r) +{ + Proposal prop; + + if (r.m_bAccountProposal) + { + AccountProposal::Key pk; + pk.m_Tag = Tags::s_AccountProposal; + _POD_(pk.m_pkAccount) = r.m_pkAccount; + pk.m_ID = Utils::FromBE(r.m_ProposalID); + Env::Halt_if(!Env::LoadVar_T(pk, prop)); + Env::Halt_if(Env::get_Height() <= prop.m_hExpire); + Env::DelVar_T(pk); + } + else + { + ListProposal::Key lpk; + lpk.m_Tag = Tags::s_ListProposal; + _POD_(lpk.m_pkAccount) = r.m_pkAccount; + _POD_(lpk.m_pkList) = r.m_pkList; + lpk.m_ID = Utils::FromBE(r.m_ProposalID); + Env::Halt_if(!Env::LoadVar_T(lpk, prop)); + Env::Halt_if(Env::get_Height() <= prop.m_hExpire); + Env::DelVar_T(lpk); + } +} + +// ============================================================ +// Method_11 — ClaimBalance +// ============================================================ + +BEAM_EXPORT void Method_11(const Method::ClaimBalance& r) +{ + PendingClaim::Key ck; + _POD_(ck.m_pkRecipient) = r.m_pkRecipient; + _POD_(ck.m_pkAccount) = r.m_pkAccount; + + PendingClaim claim; + Env::Halt_if(!Env::LoadVar_T(ck, claim)); + Env::DelVar_T(ck); + + Env::FundsUnlock(0, claim.m_Amount); + Env::AddSig(r.m_pkRecipient); +} + +// ============================================================ +// Method_12 — CleanupTransfer (anyone can delete expired transfer records) +// ============================================================ + +BEAM_EXPORT void Method_12(const Method::CleanupTransfer& r) +{ + PendingTransfer::Key ptk; + _POD_(ptk.m_pkAccountSrc) = r.m_pkAccount; + _POD_(ptk.m_pkList) = r.m_pkList; + + PendingTransfer pt; + Env::Halt_if(!Env::LoadVar_T(ptk, pt)); + Env::Halt_if(Env::get_Height() <= pt.m_hExpire); + Env::DelVar_T(ptk); + // No signature required +} + +} // namespace AssetLists + +// ============================================================ +// Upgradable3 +// ============================================================ + +namespace Upgradable3 { + + const uint32_t g_CurrentVersion = _countof(AssetLists::s_pSID) - 1; + uint32_t get_CurrentVersion() { return g_CurrentVersion; } + + void OnUpgraded(uint32_t nPrevVersion) + { + if constexpr (g_CurrentVersion) + Env::Halt_if(nPrevVersion != g_CurrentVersion - 1); + else + Env::Halt(); + } + +} // namespace Upgradable3 diff --git a/bvm/Shaders/asset_lists/contract.h b/bvm/Shaders/asset_lists/contract.h new file mode 100644 index 0000000000..51fca66bdd --- /dev/null +++ b/bvm/Shaders/asset_lists/contract.h @@ -0,0 +1,619 @@ +#pragma once +#include "../upgradable3/contract.h" + +namespace AssetLists +{ + static const ShaderID s_pSID[] = { + { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 }, // placeholder + }; + +#pragma pack(push, 1) + + // ---------------------------------------------------------------- + // Key tags + // ---------------------------------------------------------------- + + struct Tags + { + static const uint8_t s_Settings = 0; + static const uint8_t s_Account = 1; + static const uint8_t s_AccountSigners = 2; + static const uint8_t s_AccountProposal = 3; + static const uint8_t s_List = 4; + static const uint8_t s_ListSigners = 5; + static const uint8_t s_ListProposal = 6; + static const uint8_t s_ListAsset = 7; // single-asset list entry + static const uint8_t s_ListAssetGroup = 8; // multi-asset list entry + static const uint8_t s_AccountBalance = 9; // claimable BEAM accumulated from proposal fees + static const uint8_t s_PendingClaim = 10; // approved withdrawal awaiting recipient claim + static const uint8_t s_PendingTransfer = 11; // pending list transfer offer (src → dst) + }; + + // ---------------------------------------------------------------- + // List type discriminator + // ---------------------------------------------------------------- + + struct ListType + { + // Flat list of AssetIDs. Example: "approved assets", "watchlist". + static const uint8_t s_Single = 0; + + // Mapping of real AssetID -> array of associated/fake AssetIDs. + // Useful for imposter/bridge asset registries where one canonical + // asset maps to multiple equivalent representations. + static const uint8_t s_Multi = 1; + }; + + // ---------------------------------------------------------------- + // Global settings & state + // ---------------------------------------------------------------- + + static const uint8_t s_MaxSigners = 8; // fits in uint8_t bitmask + static const uint8_t s_MaxFakes = 16; // max fake IDs per multi-asset group entry + + struct Settings + { + ContractID m_cidDaoVault; + Amount m_FeeAccount; // groth, charged on CreateAccount (non-refundable) + Amount m_FeeList; // groth, charged when CreateList proposal executes + Amount m_FeeProposal; // groth, charged when a non-signer creates a proposal; split 50/50 DAO/account + Height m_ProposalTtl; // default proposal lifetime in blocks + }; + + struct State + { + static const uint8_t s_Key = Tags::s_Settings; + Settings m_Settings; + uint32_t m_nAccounts; + uint32_t m_nLists; + }; + + // ---------------------------------------------------------------- + // SignerSet (variable-length value stored under *Signers keys) + // + // Layout in storage: SignerSet header + PubKey signers[m_nSigners] + // ---------------------------------------------------------------- + + struct SignerSet + { + uint8_t m_Threshold; // M + uint8_t m_nSigners; // N (1 .. s_MaxSigners) + + bool IsValid() const + { + return m_nSigners >= 1 + && m_nSigners <= s_MaxSigners + && m_Threshold >= 1 + && m_Threshold <= m_nSigners; + } + + uint32_t ValSize() const + { + return sizeof(SignerSet) + (uint32_t)m_nSigners * sizeof(PubKey); + } + + const PubKey* Signers() const { return reinterpret_cast(this + 1); } + PubKey* Signers() { return reinterpret_cast< PubKey*>(this + 1); } + + // Minimum no-votes needed to make the yes-threshold permanently unreachable. + uint8_t RejectThreshold() const { return m_nSigners - m_Threshold + 1; } + }; + + // Maximum serialized size of a SignerSet (header + all signers). + // Defined here (outside struct body) to avoid sizeof on incomplete type. + static const uint32_t s_SignerSetMaxValSize = sizeof(SignerSet) + s_MaxSigners * sizeof(PubKey); + + // ---------------------------------------------------------------- + // Account + // + // Key: { s_Account, pkAccount } + // Value: Account header + packed strings: + // [uint8_t nNameLen, name[], uint8_t nWebsiteLen, website[], uint16_t nDescLen, desc[]] + // ---------------------------------------------------------------- + + struct Account + { + struct Key + { + uint8_t m_Tag = Tags::s_Account; + PubKey m_pkAccount; + }; + + Height m_hCreated; + uint32_t m_nProposals; // monotonically increasing; used as proposal IDs + + static const uint32_t s_NameMaxLen = 64; + static const uint32_t s_WebsiteMaxLen = 128; + static const uint32_t s_DescMaxLen = 256; + }; + + // Key: { s_AccountSigners, pkAccount } + // Value: SignerSet header + PubKey[m_nSigners] + struct AccountSigners + { + struct Key + { + uint8_t m_Tag = Tags::s_AccountSigners; + PubKey m_pkAccount; + }; + }; + + // ---------------------------------------------------------------- + // List + // + // Key: { s_List, pkAccount, pkList } + // Value: List header + packed strings: + // [uint8_t nNameLen, name[], uint16_t nDescLen, desc[]] + // + // m_ListType is fixed at creation and cannot be changed. + // If no ListSigners entry exists the account's SignerSet governs + // all list operations (fallback). + // ---------------------------------------------------------------- + + struct List + { + struct Key + { + uint8_t m_Tag = Tags::s_List; + PubKey m_pkAccount; + PubKey m_pkList; + }; + + Height m_hCreated; + uint32_t m_nAssets; // item count: AssetIDs (single) or groups (multi) + uint32_t m_nProposals; // monotonically increasing; used as proposal IDs + uint8_t m_ListType; // ListType::s_Single or ListType::s_Multi + + static const uint32_t s_NameMaxLen = 64; + static const uint32_t s_DescMaxLen = 256; + }; + + // Key: { s_ListSigners, pkAccount, pkList } + // Value: SignerSet header + PubKey[m_nSigners] + // Optional: if absent the account SignerSet governs list operations. + struct ListSigners + { + struct Key + { + uint8_t m_Tag = Tags::s_ListSigners; + PubKey m_pkAccount; + PubKey m_pkList; + }; + }; + + // ---------------------------------------------------------------- + // Single-asset list entry + // + // Key: { s_ListAsset, pkAccount, pkList, AssetID } + // Value: ListAsset (1-byte dummy) — presence = membership + // ---------------------------------------------------------------- + + struct ListAsset + { + struct Key + { + uint8_t m_Tag = Tags::s_ListAsset; + PubKey m_pkAccount; + PubKey m_pkList; + AssetID m_Aid; + }; + uint8_t m_Dummy; + }; + + // ---------------------------------------------------------------- + // Multi-asset list entry (one group = one real asset + N fake assets) + // + // Key: { s_ListAssetGroup, pkAccount, pkList, realAssetID (big-endian) } + // Value: AssetGroup header + AssetID fakeIds[m_nFakes] + // + // Adding or replacing a group is done via s_AddAssetGroup; the entire + // fake-ID array is replaced atomically with each update. + // ---------------------------------------------------------------- + + struct AssetGroup + { + struct Key + { + uint8_t m_Tag = Tags::s_ListAssetGroup; + PubKey m_pkAccount; + PubKey m_pkList; + AssetID m_RealAid; // stored big-endian for enumeration in sorted order + }; + + uint8_t m_nFakes; // number of fake AssetIDs that follow + + uint32_t ValSize() const + { + return sizeof(AssetGroup) + (uint32_t)m_nFakes * sizeof(AssetID); + } + + const AssetID* Fakes() const { return reinterpret_cast(this + 1); } + AssetID* Fakes() { return reinterpret_cast< AssetID*>(this + 1); } + }; + + // Maximum serialized size of an AssetGroup (header + all fake IDs). + // Defined here (outside struct body) to avoid sizeof on incomplete type. + static const uint32_t s_AssetGroupMaxValSize = sizeof(AssetGroup) + s_MaxFakes * sizeof(AssetID); + + // ---------------------------------------------------------------- + // AccountBalance (accumulated from non-signer proposal fees) + // + // Key: { s_AccountBalance, pkAccount } + // Value: AccountBalance (Amount) + // ---------------------------------------------------------------- + + struct AccountBalance + { + struct Key + { + uint8_t m_Tag = Tags::s_AccountBalance; + PubKey m_pkAccount; + }; + Amount m_Amount; + }; + + // ---------------------------------------------------------------- + // PendingClaim (created by s_WithdrawBalance execution) + // + // Key: { s_PendingClaim, pkRecipient, pkAccount } + // Value: PendingClaim (Amount) + // + // Consumed by Method_11 ClaimBalance — the recipient calls this to + // collect their BEAM via FundsUnlock. + // ---------------------------------------------------------------- + + struct PendingClaim + { + struct Key + { + uint8_t m_Tag = Tags::s_PendingClaim; + PubKey m_pkRecipient; + PubKey m_pkAccount; // source account whose balance this originated from + }; + Amount m_Amount; + }; + + // ---------------------------------------------------------------- + // PendingTransfer (created by s_InitiateTransfer execution) + // + // Key: { s_PendingTransfer, pkAccountSrc, pkList } + // Value: PendingTransfer + // + // Consumed by s_AcceptTransfer (migrates all list storage) or + // s_CancelTransfer (source aborts). Anyone may delete expired + // records via Method_12 CleanupTransfer. + // ---------------------------------------------------------------- + + struct PendingTransfer + { + struct Key + { + uint8_t m_Tag = Tags::s_PendingTransfer; + PubKey m_pkAccountSrc; + PubKey m_pkList; + }; + PubKey m_pkAccountDest; + Height m_hExpire; + }; + + // ---------------------------------------------------------------- + // Proposal + // + // Each account and each list maintains its own monotonic proposal counter. + // Stored as: Proposal header + action-specific payload bytes. + // + // Keys: + // account proposal: { s_AccountProposal, pkAccount, uint32_t ID (big-endian) } + // list proposal: { s_ListProposal, pkAccount, pkList, uint32_t ID (big-endian) } + // + // Votes are tracked as uint8_t bitmasks (one bit per signer index). + // ---------------------------------------------------------------- + + struct Proposal + { + PubKey m_pkProposer; // only the proposer can cancel + Height m_hExpire; + uint8_t m_Action; // AccountActions::* or ListActions::* + uint8_t m_YesMask; // bitmask of signers who voted yes + uint8_t m_NoMask; // bitmask of signers who voted no + // followed by action-specific payload + }; + + struct AccountProposal + { + struct Key + { + uint8_t m_Tag = Tags::s_AccountProposal; + PubKey m_pkAccount; + uint32_t m_ID; // big-endian + }; + }; + + struct ListProposal + { + struct Key + { + uint8_t m_Tag = Tags::s_ListProposal; + PubKey m_pkAccount; + PubKey m_pkList; + uint32_t m_ID; // big-endian + }; + }; + + // ---------------------------------------------------------------- + // Action type constants + // ---------------------------------------------------------------- + + struct AccountActions + { + static const uint8_t s_UpdateInfo = 0; // update name/website/description + static const uint8_t s_DeleteAccount = 1; // delete account (all lists must be cleared first) + static const uint8_t s_AddSigner = 2; // append signer + set new threshold + static const uint8_t s_RemoveSigner = 3; // remove signer by index + set new threshold + static const uint8_t s_CreateList = 4; // create a new list under this account + static const uint8_t s_WithdrawBalance = 5; // designate a recipient for accumulated fee balance + static const uint8_t s_InitiateTransfer = 6; // propose to transfer a list to another account + static const uint8_t s_CancelTransfer = 7; // cancel a pending list transfer + static const uint8_t s_AcceptTransfer = 8; // destination account accepts an incoming transfer + }; + + struct ListActions + { + static const uint8_t s_UpdateInfo = 0; // update name/description + static const uint8_t s_DeleteList = 1; // delete list + all entries + static const uint8_t s_AddAsset = 2; // single-asset: add AssetID + static const uint8_t s_RemoveAsset = 3; // single-asset: remove AssetID + static const uint8_t s_AddAssetGroup = 4; // multi-asset: add/replace group + static const uint8_t s_RemoveAssetGroup = 5; // multi-asset: remove group by real AssetID + static const uint8_t s_SetListManagers = 6; // install list-specific signer set + static const uint8_t s_ClearListManagers = 7; // remove list-specific signer set + }; + + // ---------------------------------------------------------------- + // Action payloads (stored inline after Proposal header) + // ---------------------------------------------------------------- + + struct PayloadUpdateAccountInfo + { + uint8_t m_nNameLen; + uint8_t m_nWebsiteLen; + uint16_t m_nDescLen; + // followed by: name[], website[], desc[] + }; + + struct PayloadAddSigner + { + PubKey m_pkNew; + uint8_t m_NewThreshold; + }; + + struct PayloadRemoveSigner + { + uint8_t m_SignerIdx; + uint8_t m_NewThreshold; + }; + + // AccountActions::s_CreateList + // When proposing, Env::AddSig(m_pkList) is called to prove control of the list key. + struct PayloadCreateList + { + PubKey m_pkList; + uint8_t m_ListType; // ListType::s_Single or ListType::s_Multi + uint8_t m_nNameLen; + uint16_t m_nDescLen; + // followed by: name[], desc[] + }; + + struct PayloadUpdateListInfo + { + uint8_t m_nNameLen; + uint16_t m_nDescLen; + // followed by: name[], desc[] + }; + + // ListActions::s_AddAsset / s_RemoveAsset (single-asset lists) + struct PayloadAsset + { + AssetID m_Aid; + }; + + // ListActions::s_AddAssetGroup (multi-asset lists) + // Replaces the entire fake-ID array for m_RealAid (upsert semantics). + struct PayloadAddAssetGroup + { + AssetID m_RealAid; + uint8_t m_nFakes; + // followed by: AssetID fakeIds[m_nFakes] + }; + + // ListActions::s_RemoveAssetGroup (multi-asset lists) + struct PayloadRemoveAssetGroup + { + AssetID m_RealAid; + }; + + // ListActions::s_SetListManagers + // payload IS a SignerSet (header + PubKeys[]) + + // AccountActions::s_WithdrawBalance + struct PayloadWithdrawBalance + { + PubKey m_pkRecipient; // who will receive the BEAM via ClaimBalance + Amount m_Amount; // how much to designate (must be <= AccountBalance) + }; + + // AccountActions::s_InitiateTransfer + struct PayloadInitiateTransfer + { + PubKey m_pkAccountDest; // destination account that must accept + PubKey m_pkList; // list being transferred + }; + + // AccountActions::s_CancelTransfer + struct PayloadCancelTransfer + { + PubKey m_pkList; // list whose pending transfer to cancel + }; + + // AccountActions::s_AcceptTransfer (proposed on the DESTINATION account) + struct PayloadAcceptTransfer + { + PubKey m_pkAccountSrc; // source account that initiated the transfer + PubKey m_pkList; // list being accepted + }; + + // ---------------------------------------------------------------- + // Methods + // ---------------------------------------------------------------- + + namespace Method + { + struct Init + { + static const uint32_t s_iMethod = 0; + Upgradable3::Settings m_Upgradable; + Settings m_Settings; + }; + + // Create account + initial signer set. Requires Env::AddSig(m_pkAccount). + // Fee forwarded to DaoVault immediately. + // + // Variable tail: PubKey signers[m_nSigners], name[], website[], desc[] + struct CreateAccount + { + static const uint32_t s_iMethod = 3; + PubKey m_pkAccount; + uint8_t m_Threshold; + uint8_t m_nSigners; + uint8_t m_nNameLen; + uint8_t m_nWebsiteLen; + uint16_t m_nDescLen; + // followed by: PubKey signers[m_nSigners], name[], website[], desc[] + }; + + // Submit an account-scoped proposal. Requires Env::AddSig(m_pkProposer). + // For action s_CreateList: also requires Env::AddSig(payload.m_pkList). + // If m_pkProposer is a member of the account's signer set their vote is + // cast automatically; if that vote alone reaches the threshold the action + // executes in the same transaction (single-signer fast path). + struct ProposeAccountAction + { + static const uint32_t s_iMethod = 4; + PubKey m_pkAccount; + PubKey m_pkProposer; + uint8_t m_Action; // AccountActions::* + // followed by action payload + }; + + // Vote on an account proposal. Requires Env::AddSig(signers[m_SignerIdx]). + // Auto-executes on yes-threshold; auto-rejects on no-threshold. + // Expired proposals cannot be voted on. + struct VoteAccountProposal + { + static const uint32_t s_iMethod = 5; + PubKey m_pkAccount; + uint32_t m_ProposalID; + uint8_t m_SignerIdx; + uint8_t m_bYes; // 1 = yes, 0 = no + }; + + // Cancel an account proposal. Only the original proposer can cancel. + // Requires Env::AddSig(m_pkProposer). + struct CancelAccountProposal + { + static const uint32_t s_iMethod = 6; + PubKey m_pkAccount; + uint32_t m_ProposalID; + PubKey m_pkProposer; + }; + + // Submit a list-scoped proposal. Requires Env::AddSig(m_pkProposer). + // Uses list managers if set, otherwise falls back to account signers. + // Same auto-vote/fast-path rules as ProposeAccountAction. + struct ProposeListAction + { + static const uint32_t s_iMethod = 7; + PubKey m_pkAccount; + PubKey m_pkList; + PubKey m_pkProposer; + uint8_t m_Action; // ListActions::* + // followed by action payload + }; + + // Vote on a list proposal. Requires Env::AddSig(signers[m_SignerIdx]). + struct VoteListProposal + { + static const uint32_t s_iMethod = 8; + PubKey m_pkAccount; + PubKey m_pkList; + uint32_t m_ProposalID; + uint8_t m_SignerIdx; + uint8_t m_bYes; + }; + + // Cancel a list proposal. Only the original proposer can cancel. + // Requires Env::AddSig(m_pkProposer). + struct CancelListProposal + { + static const uint32_t s_iMethod = 9; + PubKey m_pkAccount; + PubKey m_pkList; + uint32_t m_ProposalID; + PubKey m_pkProposer; + }; + + // Delete an expired proposal. Anyone can call; no signature required. + struct CleanupProposal + { + static const uint32_t s_iMethod = 10; + uint8_t m_bAccountProposal; // 1 = account proposal, 0 = list proposal + PubKey m_pkAccount; + PubKey m_pkList; // ignored when m_bAccountProposal == 1 + uint32_t m_ProposalID; + }; + + // Collect BEAM approved by a s_WithdrawBalance proposal execution. + // The designated recipient builds a transaction and calls this method. + // Requires Env::AddSig(m_pkRecipient). No voting needed — approval + // was already granted when the proposal executed. + struct ClaimBalance + { + static const uint32_t s_iMethod = 11; + PubKey m_pkRecipient; // must match PendingClaim key + PubKey m_pkAccount; // source account (identifies which PendingClaim) + }; + + // Delete an expired PendingTransfer record. Anyone can call; no sig required. + struct CleanupTransfer + { + static const uint32_t s_iMethod = 12; + PubKey m_pkAccount; // source account of the transfer + PubKey m_pkList; + }; + + } // namespace Method + + // ---------------------------------------------------------------- + // TODO: future multi-sig / governance extensions + // + // 1. Weighted voting + // Each signer carries a weight; proposals pass when total yes-weight + // >= configured weight_threshold instead of a flat vote count. + // + // 2. Per-action-type TTL floors + // Sensitive operations (DeleteAccount, RemoveSigner) enforce a longer + // minimum TTL so other signers have more time to cast a veto. + // + // 3. Signer rotation quorum lock + // Prevent reducing N below a floor value, so a compromised signer + // cannot unilaterally strip all co-signers. + // + // 4. Cross-account list transfer + // A proposal that moves a list from one account to another, requiring + // M-of-N approval from both source and destination accounts. + // ---------------------------------------------------------------- + +#pragma pack(pop) + +} // namespace AssetLists