feat(escrow_wip): EscrowDatum / EscrowRedeemer / EscrowValue codecs
⚠ WIP — UNAUDITED. Feature-gated behind `escrow_wip`; out of default builds.
Mirrors aiken validator at aiken-escrow/escrow/validators/escrow.ak (also
WIP). Spec at audits/2026-05-09-escrow-spec.md.
Five-redeemer two-party agreement-with-veto escrow:
- Open → both sign Agree → Agreed{at} → Settle (after lock_period)
- Agreed → either party Veto → per-contributor refund
- Open → Refund (after open_deadline) → per-contributor refund
Datum: ProductIsData (Constr 0 [a, b, recipient, deadline, lock, state, deposits]).
EscrowState: enum Open | Agreed{at} encoded as Constr 0/1 (with payload).
EscrowDeposit: per-contributor (pkh, Value) entry, Constr 0.
EscrowValue: Plutus Map<PolicyId, Map<AssetName, Int>>; preserves on-chain
ordering for cbor-equality checks the validator does on continuing-output
deposits diff.
Tests: 10 codec roundtrips (Open/Agreed states, ada-only/multi-asset values,
deposit lookup by pkh, value-merge sum). All pass under
`cargo test -p aldabra-dao --features escrow_wip escrow::`.
Builders + MCP tools land in follow-up commits on this branch.
This commit is contained in:
parent
29dc6c8443
commit
f89877bf8e
3 changed files with 488 additions and 0 deletions
|
|
@ -71,6 +71,13 @@ thiserror = { workspace = true }
|
|||
# Logging.
|
||||
tracing = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# WIP / unaudited two-party escrow validator + builders + types. Compiled
|
||||
# out of default builds until external audit lands. Enable with
|
||||
# --features escrow_wip from the workspace root.
|
||||
escrow_wip = []
|
||||
|
||||
[dev-dependencies]
|
||||
# DaoStore tests use a temp dir as the data root.
|
||||
tempfile = "3"
|
||||
|
|
|
|||
478
crates/aldabra-dao/src/agora/escrow.rs
Normal file
478
crates/aldabra-dao/src/agora/escrow.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
//! Escrow datum + redeemer encoding.
|
||||
//!
|
||||
//! ⚠️ WIP / UNAUDITED. Feature-gated behind `escrow_wip`. Preprod-only.
|
||||
//!
|
||||
//! Mirrors the on-chain validator at `aiken-escrow/escrow/validators/escrow.ak`.
|
||||
//! See `audits/2026-05-09-escrow-spec.md` for the full state machine.
|
||||
//!
|
||||
//! ## Datum shape
|
||||
//!
|
||||
//! ```text
|
||||
//! EscrowDatum Constr 0
|
||||
//! party_a: bytes(28)
|
||||
//! party_b: bytes(28)
|
||||
//! recipient: bytes(28)
|
||||
//! open_deadline_ms: Int
|
||||
//! lock_period_ms: Int
|
||||
//! state: EscrowState
|
||||
//! deposits: [Deposit]
|
||||
//!
|
||||
//! EscrowState
|
||||
//! Open Constr 0 []
|
||||
//! Agreed { agreed_at_ms } Constr 1 [Int]
|
||||
//!
|
||||
//! Deposit Constr 0
|
||||
//! contributor: bytes(28)
|
||||
//! value: Value (Map PolicyId (Map AssetName Int))
|
||||
//! ```
|
||||
//!
|
||||
//! ## Redeemer shape
|
||||
//!
|
||||
//! ```text
|
||||
//! Deposit { contributor } Constr 0 [bytes(28)]
|
||||
//! Agree Constr 1 []
|
||||
//! Veto Constr 2 []
|
||||
//! Settle Constr 3 []
|
||||
//! Refund Constr 4 []
|
||||
//! ```
|
||||
//!
|
||||
//! `Value` (the Plutus Value type) is encoded as `Map PolicyId (Map AssetName
|
||||
//! Int)`. ADA appears as `policy = empty bytes` and `asset_name = empty
|
||||
//! bytes`. Order isn't strictly canonical on chain but our encoding emits
|
||||
//! ADA first within a policy, then asset names lexicographically.
|
||||
|
||||
use pallas_codec::utils::KeyValuePairs;
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
use crate::agora::plutus_data::{as_array, as_bytes, as_constr, as_int, as_map, bytes, constr, int, product};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Length of a Cardano payment-credential hash (Blake2b-224).
|
||||
pub const PKH_LEN: usize = 28;
|
||||
|
||||
/// Length of an asset PolicyId (also Blake2b-224 of the minting policy script).
|
||||
pub const POLICY_LEN: usize = 28;
|
||||
|
||||
/// Plutus `Value` — `Map PolicyId (Map AssetName Int)`.
|
||||
///
|
||||
/// Outer Vec preserves on-chain ordering when re-encoding. ADA is encoded
|
||||
/// as `policy = []` with a single `asset_name = []` entry.
|
||||
///
|
||||
/// We don't bother with a HashMap-based representation because escrows are
|
||||
/// tiny (typically <10 distinct (policy, asset) pairs) and exact byte
|
||||
/// reproduction matters for validator equality checks via `cbor.serialise`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EscrowValue {
|
||||
/// Each entry: `(policy_id_28b_or_empty_for_ada, [(asset_name, qty)])`.
|
||||
pub policies: Vec<(Vec<u8>, Vec<(Vec<u8>, i128)>)>,
|
||||
}
|
||||
|
||||
impl EscrowValue {
|
||||
/// Empty value (no assets).
|
||||
pub fn empty() -> Self {
|
||||
Self { policies: vec![] }
|
||||
}
|
||||
|
||||
/// Helper: pure-ADA value with given lovelace amount.
|
||||
pub fn ada(lovelace: u64) -> Self {
|
||||
Self {
|
||||
policies: vec![(Vec::new(), vec![(Vec::new(), lovelace as i128)])],
|
||||
}
|
||||
}
|
||||
|
||||
/// Total lovelace in this value (0 if no ADA entry).
|
||||
pub fn lovelace(&self) -> u64 {
|
||||
for (policy, assets) in &self.policies {
|
||||
if policy.is_empty() {
|
||||
for (name, qty) in assets {
|
||||
if name.is_empty() {
|
||||
return (*qty).max(0) as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
// Map<bytes, Map<bytes, int>>
|
||||
let mut outer = Vec::with_capacity(self.policies.len());
|
||||
for (policy, assets) in &self.policies {
|
||||
let mut inner = Vec::with_capacity(assets.len());
|
||||
for (name, qty) in assets {
|
||||
inner.push((bytes(name), int(*qty)?));
|
||||
}
|
||||
outer.push((bytes(policy), PlutusData::Map(KeyValuePairs::from(inner))));
|
||||
}
|
||||
Ok(PlutusData::Map(KeyValuePairs::from(outer)))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let outer = as_map(pd)?;
|
||||
let mut policies = Vec::with_capacity(outer.len());
|
||||
for (policy_pd, inner_pd) in outer {
|
||||
let policy = as_bytes(policy_pd)?;
|
||||
let inner = as_map(inner_pd)?;
|
||||
let mut assets = Vec::with_capacity(inner.len());
|
||||
for (name_pd, qty_pd) in inner {
|
||||
let name = as_bytes(name_pd)?;
|
||||
let qty = as_int(qty_pd)?;
|
||||
assets.push((name, qty));
|
||||
}
|
||||
policies.push((policy, assets));
|
||||
}
|
||||
Ok(Self { policies })
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-contributor accounting entry. Mirrors aiken `Deposit { contributor, value }`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EscrowDeposit {
|
||||
pub contributor: [u8; PKH_LEN],
|
||||
pub value: EscrowValue,
|
||||
}
|
||||
|
||||
impl EscrowDeposit {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(constr(
|
||||
0,
|
||||
vec![bytes(&self.contributor), self.value.to_plutus_data()?],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let (idx, fields) = as_constr(pd)?;
|
||||
if idx != 0 || fields.len() != 2 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"EscrowDeposit expects Constr 0 with 2 fields, got Constr {idx} with {} fields",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let contributor_bytes = as_bytes(&fields[0])?;
|
||||
let contributor = pkh_from_bytes(&contributor_bytes, "EscrowDeposit.contributor")?;
|
||||
let value = EscrowValue::from_plutus_data(&fields[1])?;
|
||||
Ok(Self { contributor, value })
|
||||
}
|
||||
}
|
||||
|
||||
/// Escrow lifecycle state. Mirrors aiken `EscrowState`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EscrowState {
|
||||
Open,
|
||||
/// Both parties have signed Agree. `agreed_at_ms` = the validity range
|
||||
/// upper bound of the Agree tx, in POSIX ms.
|
||||
Agreed { agreed_at_ms: i64 },
|
||||
}
|
||||
|
||||
impl EscrowState {
|
||||
pub fn to_plutus_data(self) -> DaoResult<PlutusData> {
|
||||
match self {
|
||||
EscrowState::Open => Ok(constr(0, vec![])),
|
||||
EscrowState::Agreed { agreed_at_ms } => Ok(constr(1, vec![int(agreed_at_ms as i128)?])),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let (idx, fields) = as_constr(pd)?;
|
||||
match (idx, fields.len()) {
|
||||
(0, 0) => Ok(EscrowState::Open),
|
||||
(1, 1) => {
|
||||
let n = as_int(&fields[0])?;
|
||||
let agreed_at_ms = i64::try_from(n).map_err(|_| {
|
||||
DaoError::Datum(format!("EscrowState::Agreed agreed_at_ms {n} overflows i64"))
|
||||
})?;
|
||||
Ok(EscrowState::Agreed { agreed_at_ms })
|
||||
}
|
||||
(i, n) => Err(DaoError::Datum(format!(
|
||||
"EscrowState expects Constr 0 [] or 1 [Int], got Constr {i} with {n} fields"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full datum living at the escrow script.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EscrowDatum {
|
||||
pub party_a: [u8; PKH_LEN],
|
||||
pub party_b: [u8; PKH_LEN],
|
||||
pub recipient: [u8; PKH_LEN],
|
||||
pub open_deadline_ms: i64,
|
||||
pub lock_period_ms: i64,
|
||||
pub state: EscrowState,
|
||||
pub deposits: Vec<EscrowDeposit>,
|
||||
}
|
||||
|
||||
impl EscrowDatum {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
let deposits_pd = product(
|
||||
self.deposits
|
||||
.iter()
|
||||
.map(|d| d.to_plutus_data())
|
||||
.collect::<DaoResult<Vec<_>>>()?,
|
||||
);
|
||||
Ok(constr(
|
||||
0,
|
||||
vec![
|
||||
bytes(&self.party_a),
|
||||
bytes(&self.party_b),
|
||||
bytes(&self.recipient),
|
||||
int(self.open_deadline_ms as i128)?,
|
||||
int(self.lock_period_ms as i128)?,
|
||||
self.state.to_plutus_data()?,
|
||||
deposits_pd,
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let (idx, fields) = as_constr(pd)?;
|
||||
if idx != 0 || fields.len() != 7 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"EscrowDatum expects Constr 0 with 7 fields, got Constr {idx} with {} fields",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let party_a = pkh_from_bytes(&as_bytes(&fields[0])?, "EscrowDatum.party_a")?;
|
||||
let party_b = pkh_from_bytes(&as_bytes(&fields[1])?, "EscrowDatum.party_b")?;
|
||||
let recipient = pkh_from_bytes(&as_bytes(&fields[2])?, "EscrowDatum.recipient")?;
|
||||
let open_deadline_ms = i64::try_from(as_int(&fields[3])?)
|
||||
.map_err(|_| DaoError::Datum("EscrowDatum.open_deadline_ms overflows i64".into()))?;
|
||||
let lock_period_ms = i64::try_from(as_int(&fields[4])?)
|
||||
.map_err(|_| DaoError::Datum("EscrowDatum.lock_period_ms overflows i64".into()))?;
|
||||
let state = EscrowState::from_plutus_data(&fields[5])?;
|
||||
let deposits = as_array(&fields[6])?
|
||||
.iter()
|
||||
.map(EscrowDeposit::from_plutus_data)
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(Self {
|
||||
party_a,
|
||||
party_b,
|
||||
recipient,
|
||||
open_deadline_ms,
|
||||
lock_period_ms,
|
||||
state,
|
||||
deposits,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience: total value held across all deposit entries.
|
||||
pub fn total_value(&self) -> EscrowValue {
|
||||
let mut acc = EscrowValue::empty();
|
||||
for d in &self.deposits {
|
||||
acc = value_merge(&acc, &d.value);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Look up a deposit entry by contributor PKH.
|
||||
pub fn deposit_for(&self, pkh: &[u8; PKH_LEN]) -> Option<&EscrowDeposit> {
|
||||
self.deposits.iter().find(|d| &d.contributor == pkh)
|
||||
}
|
||||
}
|
||||
|
||||
/// Redeemer cases. Mirrors aiken `EscrowRedeemer`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EscrowRedeemer {
|
||||
Deposit { contributor: [u8; PKH_LEN] },
|
||||
Agree,
|
||||
Veto,
|
||||
Settle,
|
||||
Refund,
|
||||
}
|
||||
|
||||
impl EscrowRedeemer {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(match self {
|
||||
EscrowRedeemer::Deposit { contributor } => constr(0, vec![bytes(contributor)]),
|
||||
EscrowRedeemer::Agree => constr(1, vec![]),
|
||||
EscrowRedeemer::Veto => constr(2, vec![]),
|
||||
EscrowRedeemer::Settle => constr(3, vec![]),
|
||||
EscrowRedeemer::Refund => constr(4, vec![]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
fn pkh_from_bytes(bs: &[u8], field: &str) -> DaoResult<[u8; PKH_LEN]> {
|
||||
if bs.len() != PKH_LEN {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"{field} expects {PKH_LEN} bytes, got {}",
|
||||
bs.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; PKH_LEN];
|
||||
out.copy_from_slice(bs);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Component-wise add of two EscrowValues (preserves first-seen order of
|
||||
/// policies and asset names). Used for tracking total value held.
|
||||
pub fn value_merge(a: &EscrowValue, b: &EscrowValue) -> EscrowValue {
|
||||
let mut out = a.clone();
|
||||
for (b_policy, b_assets) in &b.policies {
|
||||
let policy_idx = out.policies.iter().position(|(p, _)| p == b_policy);
|
||||
match policy_idx {
|
||||
Some(i) => {
|
||||
for (b_name, b_qty) in b_assets {
|
||||
let name_idx = out.policies[i].1.iter().position(|(n, _)| n == b_name);
|
||||
match name_idx {
|
||||
Some(j) => out.policies[i].1[j].1 += *b_qty,
|
||||
None => out.policies[i].1.push((b_name.clone(), *b_qty)),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => out.policies.push((b_policy.clone(), b_assets.clone())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pkh(seed: u8) -> [u8; PKH_LEN] {
|
||||
[seed; PKH_LEN]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_value_ada_roundtrip() {
|
||||
let v = EscrowValue::ada(1_500_000);
|
||||
let pd = v.to_plutus_data().unwrap();
|
||||
let v2 = EscrowValue::from_plutus_data(&pd).unwrap();
|
||||
assert_eq!(v, v2);
|
||||
assert_eq!(v2.lovelace(), 1_500_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_value_multi_asset_roundtrip() {
|
||||
let v = EscrowValue {
|
||||
policies: vec![
|
||||
(Vec::new(), vec![(Vec::new(), 2_000_000)]),
|
||||
(
|
||||
vec![0xaa; POLICY_LEN],
|
||||
vec![(b"TRP".to_vec(), 100), (b"MAP".to_vec(), 5)],
|
||||
),
|
||||
],
|
||||
};
|
||||
let pd = v.to_plutus_data().unwrap();
|
||||
let v2 = EscrowValue::from_plutus_data(&pd).unwrap();
|
||||
assert_eq!(v, v2);
|
||||
assert_eq!(v2.lovelace(), 2_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_state_open_roundtrip() {
|
||||
let s = EscrowState::Open;
|
||||
let pd = s.to_plutus_data().unwrap();
|
||||
assert_eq!(EscrowState::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_state_agreed_roundtrip() {
|
||||
let s = EscrowState::Agreed { agreed_at_ms: 1_700_000_000_000 };
|
||||
let pd = s.to_plutus_data().unwrap();
|
||||
assert_eq!(EscrowState::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_deposit_roundtrip() {
|
||||
let d = EscrowDeposit {
|
||||
contributor: pkh(0xa1),
|
||||
value: EscrowValue::ada(3_000_000),
|
||||
};
|
||||
let pd = d.to_plutus_data().unwrap();
|
||||
assert_eq!(EscrowDeposit::from_plutus_data(&pd).unwrap(), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_datum_open_empty_deposits_roundtrip() {
|
||||
let d = EscrowDatum {
|
||||
party_a: pkh(0xa1),
|
||||
party_b: pkh(0xb2),
|
||||
recipient: pkh(0xb2),
|
||||
open_deadline_ms: 1_700_000_000_000,
|
||||
lock_period_ms: 60 * 60 * 1000,
|
||||
state: EscrowState::Open,
|
||||
deposits: vec![],
|
||||
};
|
||||
let pd = d.to_plutus_data().unwrap();
|
||||
let d2 = EscrowDatum::from_plutus_data(&pd).unwrap();
|
||||
assert_eq!(d, d2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_datum_agreed_with_deposits_roundtrip() {
|
||||
let d = EscrowDatum {
|
||||
party_a: pkh(0xa1),
|
||||
party_b: pkh(0xb2),
|
||||
recipient: pkh(0xc3),
|
||||
open_deadline_ms: 1_700_000_000_000,
|
||||
lock_period_ms: 30 * 60 * 1000,
|
||||
state: EscrowState::Agreed { agreed_at_ms: 1_700_000_500_000 },
|
||||
deposits: vec![
|
||||
EscrowDeposit {
|
||||
contributor: pkh(0xa1),
|
||||
value: EscrowValue::ada(5_000_000),
|
||||
},
|
||||
EscrowDeposit {
|
||||
contributor: pkh(0xb2),
|
||||
value: EscrowValue::ada(7_000_000),
|
||||
},
|
||||
],
|
||||
};
|
||||
let pd = d.to_plutus_data().unwrap();
|
||||
let d2 = EscrowDatum::from_plutus_data(&pd).unwrap();
|
||||
assert_eq!(d, d2);
|
||||
assert_eq!(d2.total_value().lovelace(), 12_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_redeemer_all_constrs() {
|
||||
for r in [
|
||||
EscrowRedeemer::Deposit { contributor: pkh(0xa1) },
|
||||
EscrowRedeemer::Agree,
|
||||
EscrowRedeemer::Veto,
|
||||
EscrowRedeemer::Settle,
|
||||
EscrowRedeemer::Refund,
|
||||
] {
|
||||
// Just ensure encoding doesn't panic and yields a Constr.
|
||||
let pd = r.to_plutus_data().unwrap();
|
||||
let (_idx, _fields) = as_constr(&pd).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_merge_sums_components() {
|
||||
let a = EscrowValue::ada(1_000_000);
|
||||
let b = EscrowValue {
|
||||
policies: vec![
|
||||
(Vec::new(), vec![(Vec::new(), 2_500_000)]),
|
||||
(vec![0x11; POLICY_LEN], vec![(b"TRP".to_vec(), 50)]),
|
||||
],
|
||||
};
|
||||
let merged = value_merge(&a, &b);
|
||||
assert_eq!(merged.lovelace(), 3_500_000);
|
||||
assert_eq!(merged.policies.len(), 2);
|
||||
assert_eq!(merged.policies[1].1[0], (b"TRP".to_vec(), 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deposit_for_finds_match() {
|
||||
let d = EscrowDatum {
|
||||
party_a: pkh(0xa1),
|
||||
party_b: pkh(0xb2),
|
||||
recipient: pkh(0xb2),
|
||||
open_deadline_ms: 0,
|
||||
lock_period_ms: 0,
|
||||
state: EscrowState::Open,
|
||||
deposits: vec![EscrowDeposit {
|
||||
contributor: pkh(0xa1),
|
||||
value: EscrowValue::ada(1_000_000),
|
||||
}],
|
||||
};
|
||||
assert!(d.deposit_for(&pkh(0xa1)).is_some());
|
||||
assert!(d.deposit_for(&pkh(0xff)).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,9 @@ pub mod authority_token;
|
|||
pub mod plutus_data;
|
||||
pub mod reference_scripts;
|
||||
|
||||
#[cfg(feature = "escrow_wip")]
|
||||
pub mod escrow;
|
||||
|
||||
pub use governor::{GovernorDatum, GovernorRedeemer};
|
||||
pub use proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue