merge: bring kayos/audit-cleanup into main — DAO + Conway + audit closeouts
audit-cleanup is the consolidating branch from Tracks #20-#38: 4 DAO MCP
write paths (proposal_create/cosign/vote/advance), 1 stake retract path,
8 chain_* Koios passthroughs, Conway DRep tools, vote-TTL/posix_time fix
(Track #30), MCP json_object_schema fix (Track #37), retract-cooldown
gating fix (Track #38), pallas large-bytestring fix, ref-script wallet
support, fmt + clippy --fix sweep.
main was 18 wallet/plutus-phase commits ahead since the branch fork at
2f3d975. audit-cleanup carried equivalent functionality (chain_tx_info,
min_utxo guard, plutus mint, etc.) so all 11 conflicts resolved by
taking audit-cleanup's version. Tree post-merge is byte-identical to
origin/kayos/audit-cleanup.
This commit is contained in:
commit
29dc6c8443
47 changed files with 12592 additions and 342 deletions
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Use system git for fetch (respects ~/.git-credentials and SSH keys),
|
||||
# so credentials never get baked into Cargo.lock URLs.
|
||||
#
|
||||
# Required because the [patch.crates-io] block in Cargo.toml points at
|
||||
# the LAN-only Sulkta-Coop/pallas fork. Without this, cargo's internal
|
||||
# libgit2 client would prompt for creds and bake them into Cargo.lock.
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
79
Cargo.lock
generated
79
Cargo.lock
generated
|
|
@ -97,6 +97,30 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aldabra-dao"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"aldabra-chain",
|
||||
"aldabra-core",
|
||||
"async-trait",
|
||||
"bech32",
|
||||
"hex",
|
||||
"pallas-addresses",
|
||||
"pallas-codec",
|
||||
"pallas-crypto",
|
||||
"pallas-primitives",
|
||||
"pallas-traverse",
|
||||
"pallas-txbuilder",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aldabra-mcp"
|
||||
version = "0.0.1"
|
||||
|
|
@ -104,7 +128,10 @@ dependencies = [
|
|||
"age",
|
||||
"aldabra-chain",
|
||||
"aldabra-core",
|
||||
"aldabra-dao",
|
||||
"anyhow",
|
||||
"hex",
|
||||
"pallas-addresses",
|
||||
"rmcp",
|
||||
"rpassword",
|
||||
"serde",
|
||||
|
|
@ -482,6 +509,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
|
|
@ -1130,6 +1163,12 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
|
|
@ -1253,7 +1292,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||
[[package]]
|
||||
name = "pallas-addresses"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"base58",
|
||||
"bech32",
|
||||
|
|
@ -1268,7 +1307,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-codec"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"minicbor",
|
||||
|
|
@ -1279,7 +1318,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-crypto"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"cryptoxide",
|
||||
"hex",
|
||||
|
|
@ -1293,7 +1332,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-primitives"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"base58",
|
||||
"bech32",
|
||||
|
|
@ -1308,7 +1347,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-traverse"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"itertools",
|
||||
|
|
@ -1324,7 +1363,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-txbuilder"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"pallas-addresses",
|
||||
|
|
@ -1341,7 +1380,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pallas-wallet"
|
||||
version = "0.32.1"
|
||||
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
|
||||
source = "git+ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git?branch=feat-aux-data#8091abd1b45c716453b7360def29311cf4600c0d"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"bip39",
|
||||
|
|
@ -1800,6 +1839,19 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
|
|
@ -2128,6 +2180,19 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
|
|
|||
22
Cargo.toml
22
Cargo.toml
|
|
@ -1,16 +1,17 @@
|
|||
# Cargo workspace root for aldabra.
|
||||
#
|
||||
# Three crates:
|
||||
# Four crates:
|
||||
# aldabra-core — key derivation, signing, types, mnemonic handling
|
||||
# aldabra-chain — pluggable chain backends (Koios, Ogmios). Trait-first.
|
||||
# aldabra-mcp — binary; the MCP server, glues core+chain together.
|
||||
# aldabra-dao — Agora-on-Cardano DAO interaction; multi-DAO from day 1.
|
||||
# aldabra-mcp — binary; the MCP server, glues core+chain+dao together.
|
||||
#
|
||||
# Named for the Aldabra giant tortoise (Aldabrachelys gigantea) — endemic
|
||||
# to the Aldabra atoll in the Seychelles, up to 250 kg, 150-year lifespan.
|
||||
# Long-lived, defended, slow but unstoppable. Fitting metaphor for a
|
||||
# wallet that holds your money.
|
||||
#
|
||||
# Workspace deps are pinned here so all three crates use the same versions.
|
||||
# Workspace deps are pinned here so all crates use the same versions.
|
||||
# Add a dep here, then reference it in each crate's Cargo.toml as
|
||||
# foo = { workspace = true }
|
||||
[workspace]
|
||||
|
|
@ -18,6 +19,7 @@ resolver = "2"
|
|||
members = [
|
||||
"crates/aldabra-core",
|
||||
"crates/aldabra-chain",
|
||||
"crates/aldabra-dao",
|
||||
"crates/aldabra-mcp",
|
||||
]
|
||||
|
||||
|
|
@ -99,10 +101,10 @@ rpassword = "7"
|
|||
# against the same commit. PR upstream pending; switch back to
|
||||
# crates.io once merged.
|
||||
[patch.crates-io]
|
||||
pallas-codec = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-crypto = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-primitives = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-traverse = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-addresses = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-wallet = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-txbuilder = { git = "http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-codec = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-crypto = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-primitives = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-traverse = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-addresses = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-wallet = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
pallas-txbuilder = { git = "ssh://git@192.168.0.5:23/Sulkta-Coop/pallas.git", branch = "feat-aux-data" }
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -1,3 +1,4 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
# aldabra — Cardano lite wallet over MCP.
|
||||
#
|
||||
# Multi-stage:
|
||||
|
|
@ -44,7 +45,15 @@ COPY crates ./crates
|
|||
# trick above leaves stale build artifacts otherwise.
|
||||
RUN find crates -name '*.rs' -exec touch {} +
|
||||
|
||||
RUN cargo build --release --bin aldabra && \
|
||||
# Fetch the pallas patch dep via HTTP+PAT at build time. Source URLs
|
||||
# stay SSH (Cargo.toml + Cargo.lock) — the rewrite is git-CLI-level
|
||||
# only, so no credential gets baked into the lock file or the image.
|
||||
# Pass `--secret id=git_credentials,src=<file>` where <file> is one
|
||||
# line: http://USER:PAT@192.168.0.5:3001
|
||||
RUN --mount=type=secret,id=git_credentials,target=/root/.git-credentials,mode=0400,required=true \
|
||||
git config --global credential.helper store && \
|
||||
git config --global url."http://192.168.0.5:3001/".insteadOf "ssh://git@192.168.0.5:23/" && \
|
||||
cargo build --release --bin aldabra && \
|
||||
strip target/release/aldabra
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
|
|
|||
|
|
@ -108,17 +108,43 @@ pub struct KoiosClient {
|
|||
}
|
||||
|
||||
impl KoiosClient {
|
||||
/// Construct a client with the default 10-second timeout.
|
||||
/// Construct a client with the default 10-second timeout and no
|
||||
/// bearer (public-tier; subject to free-tier daily quotas).
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_timeout(base_url, DEFAULT_TIMEOUT)
|
||||
Self::with_timeout_and_bearer(base_url, DEFAULT_TIMEOUT, None)
|
||||
}
|
||||
|
||||
/// Construct a client with a custom request timeout.
|
||||
/// Construct a client with a custom request timeout, no bearer.
|
||||
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
|
||||
Self::with_timeout_and_bearer(base_url, timeout, None)
|
||||
}
|
||||
|
||||
/// Construct a client with optional `Authorization: Bearer <token>`
|
||||
/// applied to every request. Used for paid-tier Koios access — the
|
||||
/// JWT comes from the operator-supplied `ALDABRA_KOIOS_BEARER` env
|
||||
/// var (NEVER from the on-disk config, NEVER hardcoded). Pass
|
||||
/// `None` for the free public tier.
|
||||
pub fn with_timeout_and_bearer(
|
||||
base_url: impl Into<String>,
|
||||
timeout: Duration,
|
||||
bearer: Option<&str>,
|
||||
) -> Self {
|
||||
let mut builder = Client::builder().timeout(timeout);
|
||||
if let Some(token) = bearer {
|
||||
// Default header is applied to every request the client
|
||||
// emits — request-level overrides still possible but no
|
||||
// builder code path needs to remember to set it.
|
||||
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||
let value = format!("Bearer {token}");
|
||||
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||
hv.set_sensitive(true);
|
||||
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||
builder = builder.default_headers(hdrs);
|
||||
}
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
http: Client::builder()
|
||||
.timeout(timeout)
|
||||
http: builder
|
||||
.build()
|
||||
.expect("reqwest client builds with rustls + json features"),
|
||||
}
|
||||
|
|
@ -243,7 +269,9 @@ impl ChainBackend for KoiosClient {
|
|||
}
|
||||
|
||||
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError> {
|
||||
let body = AddressesBody { addresses: vec![address] };
|
||||
let body = AddressesBody {
|
||||
addresses: vec![address],
|
||||
};
|
||||
let raw: Vec<KoiosAddressInfo> = self.post_json("address_info", &body).await?;
|
||||
|
||||
// Empty array = address has no on-chain history yet — treat
|
||||
|
|
@ -312,11 +340,15 @@ impl ChainBackend for KoiosClient {
|
|||
}
|
||||
|
||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||
let body = TxHashesBody {
|
||||
tx_hashes: vec![tx_hash],
|
||||
};
|
||||
let raw: Vec<KoiosTxStatusResp> = self.post_json("tx_status", &body).await?;
|
||||
match raw.into_iter().next() {
|
||||
Some(info) => match info.num_confirmations {
|
||||
Some(n) if n > 0 => Ok(TxStatus::Confirmed { num_confirmations: n }),
|
||||
Some(n) if n > 0 => Ok(TxStatus::Confirmed {
|
||||
num_confirmations: n,
|
||||
}),
|
||||
Some(_) | None => Ok(TxStatus::Pending),
|
||||
},
|
||||
None => Ok(TxStatus::NotFound),
|
||||
|
|
@ -408,7 +440,11 @@ mod tests {
|
|||
#[test]
|
||||
fn deserializes_utxo_response() {
|
||||
let raw: Vec<KoiosUtxo> = serde_json::from_str(SAMPLE_UTXOS).unwrap();
|
||||
let utxos: Vec<Utxo> = raw.into_iter().map(convert_utxo).collect::<Result<_, _>>().unwrap();
|
||||
let utxos: Vec<Utxo> = raw
|
||||
.into_iter()
|
||||
.map(convert_utxo)
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(utxos.len(), 2);
|
||||
assert_eq!(utxos[0].lovelace, 1_500_000);
|
||||
assert!(utxos[0].assets.is_empty());
|
||||
|
|
@ -504,7 +540,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn tx_status_serializes_with_tag() {
|
||||
let confirmed = TxStatus::Confirmed { num_confirmations: 17 };
|
||||
let confirmed = TxStatus::Confirmed {
|
||||
num_confirmations: 17,
|
||||
};
|
||||
let json = serde_json::to_string(&confirmed).unwrap();
|
||||
assert!(json.contains("\"status\":\"confirmed\""));
|
||||
assert!(json.contains("\"num_confirmations\":17"));
|
||||
|
|
@ -556,6 +594,10 @@ mod tests {
|
|||
let result = client.get_balance(known_addr).await;
|
||||
// We don't assert a specific balance — just that the
|
||||
// request shape is valid and the response decodes.
|
||||
assert!(result.is_ok(), "live balance call failed: {:?}", result.err());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"live balance call failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,8 +136,7 @@ fn json_to_plutus_data(v: &Value) -> Result<PlutusData, WalletError> {
|
|||
Value::Object(map) => {
|
||||
let mut pairs: Vec<(PlutusData, PlutusData)> = Vec::with_capacity(map.len());
|
||||
for (k, vv) in map {
|
||||
let key =
|
||||
PlutusData::BoundedBytes(BoundedBytes::from(k.as_bytes().to_vec()));
|
||||
let key = PlutusData::BoundedBytes(BoundedBytes::from(k.as_bytes().to_vec()));
|
||||
let value = json_to_plutus_data(vv)?;
|
||||
pairs.push((key, value));
|
||||
}
|
||||
|
|
@ -165,9 +164,8 @@ pub fn build_cip68_datum_cbor(metadata: &Value) -> Result<Vec<u8>, WalletError>
|
|||
}
|
||||
let metadata_pd = json_to_plutus_data(metadata)?;
|
||||
|
||||
let version_pd = PlutusData::BigInt(BigInt::Int(
|
||||
pallas_codec::utils::Int::from(CIP68_VERSION_2),
|
||||
));
|
||||
let version_pd =
|
||||
PlutusData::BigInt(BigInt::Int(pallas_codec::utils::Int::from(CIP68_VERSION_2)));
|
||||
|
||||
// "extra" — Constr 0 with no fields (Plutus unit).
|
||||
let extra_pd = PlutusData::Constr(Constr {
|
||||
|
|
|
|||
|
|
@ -99,10 +99,7 @@ impl StakeKey {
|
|||
/// Reward / stake address (`stake1...` or `stake_test1...`)
|
||||
/// bech32-encoded. This is the address you point at a stake pool
|
||||
/// when delegating.
|
||||
pub fn stake_address(
|
||||
&self,
|
||||
network: crate::Network,
|
||||
) -> Result<String, crate::WalletError> {
|
||||
pub fn stake_address(&self, network: crate::Network) -> Result<String, crate::WalletError> {
|
||||
use pallas_addresses::{StakeAddress, StakePayload};
|
||||
let payload = StakePayload::Stake(self.public_key_hash());
|
||||
let addr = StakeAddress::new(network.to_pallas(), payload);
|
||||
|
|
|
|||
773
crates/aldabra-core/src/governance.rs
Normal file
773
crates/aldabra-core/src/governance.rs
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
//! Conway-era governance flows.
|
||||
//!
|
||||
//! Phase 5 of the aldabra roadmap. Surfaces:
|
||||
//!
|
||||
//! - [`build_signed_vote_delegation`] — delegate the wallet's stake
|
||||
//! credential's voting power to a DRep (key, script, abstain, or
|
||||
//! no-confidence).
|
||||
//! - [`build_signed_drep_registration`] — register the wallet's stake
|
||||
//! credential as a key-based DRep (deposit-bearing).
|
||||
//! - [`build_signed_drep_deregistration`] — return the deposit, retire
|
||||
//! the DRep.
|
||||
//!
|
||||
//! These mirror [`crate::stake::build_signed_stake_delegation`]'s shape:
|
||||
//! two-pass fee, dual-witness (payment + stake) signing, change with
|
||||
//! input-asset preservation. Difference from pool delegation is just
|
||||
//! which `Certificate` enum variant we encode.
|
||||
//!
|
||||
//! ## What's NOT here
|
||||
//!
|
||||
//! - **DRep update** (`UpdateDRepCert`) — anchor-only; refresh metadata.
|
||||
//! Easy to add when needed; same shape as registration without deposit.
|
||||
//! - **Vote casting** (`VotingProcedure` + `voting_procedures` field on
|
||||
//! the tx body) — Phase 6. Requires extending the Sulkta-Coop/pallas
|
||||
//! fork to thread `voting_procedures` through `StagingTransaction`
|
||||
//! (currently TODO at `pallas-txbuilder/src/conway.rs:254`).
|
||||
//! - **Committee certs** (`AuthCommitteeHot`, `ResignCommitteeCold`) —
|
||||
//! not needed for any current Sulkta use case.
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_primitives::conway::{Certificate, DRep, StakeCredential};
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction};
|
||||
|
||||
use crate::sign::add_witness;
|
||||
use crate::tx::InputUtxo;
|
||||
use crate::{Network, PaymentKey, ProtocolParams, StakeKey, WalletError};
|
||||
|
||||
/// Conway DRep registration deposit. Mainnet protocol parameter
|
||||
/// `drep_deposit` is currently 500 ADA. **Use `params.drep_deposit_lovelace`
|
||||
/// instead of this constant** — it's kept here for backward-compat callers
|
||||
/// only. AUDIT-2026-05-06 M-2: hardcoding the deposit means a protocol
|
||||
/// change (or an old DRep registered at a different deposit) will silently
|
||||
/// fail ledger validation. Always pull from current chain params.
|
||||
pub const DREP_REGISTRATION_DEPOSIT_LOVELACE: u64 = 500_000_000;
|
||||
|
||||
/// Two witnesses (payment + stake) — same overhead as
|
||||
/// `stake::build_signed_stake_delegation`.
|
||||
const TWO_WITNESS_OVERHEAD_BYTES: u64 = 256;
|
||||
|
||||
/// Where to delegate voting power. Mirrors `pallas_primitives::conway::DRep`
|
||||
/// but parses friendlier inputs (bech32 drep_id, "abstain", "no_confidence")
|
||||
/// at the call site.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DRepTarget {
|
||||
/// 28-byte key hash (DRep is registered against an AddrKeyhash).
|
||||
Key(Hash<28>),
|
||||
/// 28-byte script hash (DRep is registered against a ScriptHash).
|
||||
Script(Hash<28>),
|
||||
/// Predefined "always abstain" DRep — passes through to `DRep::Abstain`.
|
||||
Abstain,
|
||||
/// Predefined "no confidence" DRep — passes through to `DRep::NoConfidence`.
|
||||
NoConfidence,
|
||||
}
|
||||
|
||||
impl DRepTarget {
|
||||
fn into_pallas(self) -> DRep {
|
||||
match self {
|
||||
DRepTarget::Key(h) => DRep::Key(h),
|
||||
DRepTarget::Script(h) => DRep::Script(h),
|
||||
DRepTarget::Abstain => DRep::Abstain,
|
||||
DRepTarget::NoConfidence => DRep::NoConfidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `drep1...` bech32 DRep ID into a [`DRepTarget`].
|
||||
///
|
||||
/// `drep1...` IDs are CIP-129 conway-era. The hrp + first byte signals
|
||||
/// key-vs-script. See https://cips.cardano.org/cip/CIP-0129/.
|
||||
///
|
||||
/// Special strings:
|
||||
/// - `"abstain"` → `DRepTarget::Abstain`
|
||||
/// - `"no_confidence"` → `DRepTarget::NoConfidence`
|
||||
pub fn parse_drep_target(s: &str) -> Result<DRepTarget, WalletError> {
|
||||
use bech32::FromBase32;
|
||||
if s == "abstain" {
|
||||
return Ok(DRepTarget::Abstain);
|
||||
}
|
||||
if s == "no_confidence" {
|
||||
return Ok(DRepTarget::NoConfidence);
|
||||
}
|
||||
let (hrp, data, _) =
|
||||
bech32::decode(s).map_err(|e| WalletError::Address(format!("bad drep bech32: {e}")))?;
|
||||
if hrp != "drep" && hrp != "drep_script" {
|
||||
return Err(WalletError::Address(format!(
|
||||
"expected drep / drep_script hrp, got '{hrp}'"
|
||||
)));
|
||||
}
|
||||
let bytes: Vec<u8> = Vec::<u8>::from_base32(&data)
|
||||
.map_err(|e| WalletError::Address(format!("bad drep base32: {e}")))?;
|
||||
// CIP-129: first byte's low nibble carries the credential type:
|
||||
// 0x22 = key DRep, 0x23 = script DRep. Strip the header byte if present
|
||||
// (CIP-129 IDs are 29 bytes total: 1 header + 28 hash).
|
||||
let hash_bytes = if bytes.len() == 29 {
|
||||
// CIP-129 with header byte.
|
||||
let hdr = bytes[0];
|
||||
let kind = hdr & 0x0F;
|
||||
let mut arr = [0u8; 28];
|
||||
arr.copy_from_slice(&bytes[1..]);
|
||||
let h = Hash::<28>::new(arr);
|
||||
return Ok(match kind {
|
||||
0x2 => DRepTarget::Key(h),
|
||||
0x3 => DRepTarget::Script(h),
|
||||
other => {
|
||||
return Err(WalletError::Address(format!(
|
||||
"unknown DRep credential kind 0x{:x} in CIP-129 header",
|
||||
other
|
||||
)));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
if hash_bytes.len() != 28 {
|
||||
return Err(WalletError::Address(format!(
|
||||
"drep hash must be 28 bytes (or 29 with CIP-129 header), got {}",
|
||||
hash_bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut arr = [0u8; 28];
|
||||
arr.copy_from_slice(&hash_bytes);
|
||||
let h = Hash::<28>::new(arr);
|
||||
// Without CIP-129 header, infer from hrp.
|
||||
Ok(match hrp.as_str() {
|
||||
"drep" => DRepTarget::Key(h),
|
||||
"drep_script" => DRepTarget::Script(h),
|
||||
_ => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
||||
if hex_str.len() != 64 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"expected 64-char hex tx_hash, got {}",
|
||||
hex_str.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?;
|
||||
}
|
||||
Ok(Hash::<32>::new(out))
|
||||
}
|
||||
|
||||
fn network_id_for(network: Network) -> u8 {
|
||||
match network {
|
||||
Network::Mainnet => 1,
|
||||
Network::Preview | Network::Preprod => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build + sign a vote-delegation tx. If `register_first` is true,
|
||||
/// prepends a `StakeRegistration` certificate (one-time, costs 2 ADA
|
||||
/// deposit) — same shape as `build_signed_stake_delegation`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_signed_vote_delegation(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
drep_target: DRepTarget,
|
||||
register_first: bool,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let stake_pkh = stake_key.public_key_hash();
|
||||
let credential = StakeCredential::AddrKeyhash(stake_pkh);
|
||||
|
||||
let mut cert_bytes_list: Vec<Vec<u8>> = Vec::new();
|
||||
if register_first {
|
||||
let reg = Certificate::StakeRegistration(credential.clone());
|
||||
cert_bytes_list.push(
|
||||
minicbor::to_vec(®)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode reg cert: {e}")))?,
|
||||
);
|
||||
}
|
||||
let deleg = Certificate::VoteDeleg(credential, drep_target.into_pallas());
|
||||
cert_bytes_list.push(
|
||||
minicbor::to_vec(&deleg)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode vote-deleg cert: {e}")))?,
|
||||
);
|
||||
|
||||
// Stake-registration deposit only (vote_delegation itself has no deposit).
|
||||
let deposit = if register_first {
|
||||
crate::stake::STAKE_KEY_DEPOSIT_LOVELACE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
sign_cert_tx(
|
||||
payment_key,
|
||||
stake_key,
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
cert_bytes_list,
|
||||
deposit,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build + sign a DRep registration tx. The wallet's stake credential
|
||||
/// becomes a key-based DRep with a 500 ADA deposit (default; pulled
|
||||
/// from `params` if the protocol changes).
|
||||
///
|
||||
/// `anchor_url` + `anchor_data_hash_hex` are optional — if set, attach
|
||||
/// CIP-100/119 metadata to the registration. Pass `None` for both when
|
||||
/// you don't have an off-chain anchor yet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_signed_drep_registration(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
anchor_url: Option<&str>,
|
||||
anchor_data_hash_hex: Option<&str>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
use pallas_codec::utils::Nullable;
|
||||
use pallas_primitives::conway::Anchor;
|
||||
|
||||
let stake_pkh = stake_key.public_key_hash();
|
||||
let drep_credential = StakeCredential::AddrKeyhash(stake_pkh);
|
||||
|
||||
let anchor: Nullable<Anchor> = match (anchor_url, anchor_data_hash_hex) {
|
||||
(Some(url), Some(hash_hex)) => {
|
||||
if hash_hex.len() != 64 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"anchor_data_hash must be 64-char hex, got {}",
|
||||
hash_hex.len()
|
||||
)));
|
||||
}
|
||||
let mut h_arr = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
h_arr[i] = u8::from_str_radix(&hash_hex[i * 2..i * 2 + 2], 16).map_err(|_| {
|
||||
WalletError::Derivation("invalid hex in anchor_data_hash".into())
|
||||
})?;
|
||||
}
|
||||
Nullable::Some(Anchor {
|
||||
url: url.to_string(),
|
||||
content_hash: Hash::<32>::new(h_arr),
|
||||
})
|
||||
}
|
||||
(None, None) => Nullable::Null,
|
||||
_ => {
|
||||
return Err(WalletError::Derivation(
|
||||
"anchor_url and anchor_data_hash must both be set or both omitted".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cert = Certificate::RegDRepCert(drep_credential, params.drep_deposit_lovelace, anchor);
|
||||
let cert_bytes = minicbor::to_vec(&cert)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode RegDRep cert: {e}")))?;
|
||||
|
||||
sign_cert_tx(
|
||||
payment_key,
|
||||
stake_key,
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
vec![cert_bytes],
|
||||
params.drep_deposit_lovelace,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// One Yes/No/Abstain vote on one Conway governance action.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VoteChoice {
|
||||
Yes,
|
||||
No,
|
||||
Abstain,
|
||||
}
|
||||
|
||||
impl VoteChoice {
|
||||
fn into_pallas(self) -> pallas_primitives::conway::Vote {
|
||||
use pallas_primitives::conway::Vote;
|
||||
match self {
|
||||
VoteChoice::Yes => Vote::Yes,
|
||||
VoteChoice::No => Vote::No,
|
||||
VoteChoice::Abstain => Vote::Abstain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build + sign a DRep vote-cast tx. The wallet's stake credential
|
||||
/// signs as the DRep voter — must already be registered as a DRep
|
||||
/// (via `build_signed_drep_registration` or a separate flow) for the
|
||||
/// vote to count on chain.
|
||||
///
|
||||
/// `gov_action_tx_hash_hex` + `gov_action_index` identify the Conway
|
||||
/// governance action to vote on (look these up via Koios / chain
|
||||
/// passthrough tools — `chain_governance_actions` will land alongside
|
||||
/// this when wired). `anchor_url` + `anchor_data_hash_hex` are optional
|
||||
/// per-vote rationale (CIP-100). Pass `None` for both when omitting.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_signed_drep_vote_cast(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
gov_action_tx_hash_hex: &str,
|
||||
gov_action_index: u32,
|
||||
vote: VoteChoice,
|
||||
anchor_url: Option<&str>,
|
||||
anchor_data_hash_hex: Option<&str>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
use pallas_codec::utils::{NonEmptyKeyValuePairs, Nullable};
|
||||
use pallas_primitives::conway::{
|
||||
Anchor, GovActionId, Voter, VotingProcedure, VotingProcedures,
|
||||
};
|
||||
|
||||
let stake_pkh = stake_key.public_key_hash();
|
||||
let voter = Voter::DRepKey(stake_pkh);
|
||||
|
||||
let gov_tx_hash = parse_tx_hash(gov_action_tx_hash_hex)?;
|
||||
let gov_action_id = GovActionId {
|
||||
transaction_id: gov_tx_hash,
|
||||
action_index: gov_action_index,
|
||||
};
|
||||
|
||||
let anchor: Nullable<Anchor> = match (anchor_url, anchor_data_hash_hex) {
|
||||
(Some(url), Some(hash_hex)) => {
|
||||
if hash_hex.len() != 64 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"anchor_data_hash must be 64-char hex, got {}",
|
||||
hash_hex.len()
|
||||
)));
|
||||
}
|
||||
let mut h_arr = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
h_arr[i] = u8::from_str_radix(&hash_hex[i * 2..i * 2 + 2], 16).map_err(|_| {
|
||||
WalletError::Derivation("invalid hex in anchor_data_hash".into())
|
||||
})?;
|
||||
}
|
||||
Nullable::Some(Anchor {
|
||||
url: url.to_string(),
|
||||
content_hash: Hash::<32>::new(h_arr),
|
||||
})
|
||||
}
|
||||
(None, None) => Nullable::Null,
|
||||
_ => {
|
||||
return Err(WalletError::Derivation(
|
||||
"anchor_url and anchor_data_hash must both be set or both omitted".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let procedure = VotingProcedure {
|
||||
vote: vote.into_pallas(),
|
||||
anchor,
|
||||
};
|
||||
|
||||
let inner = NonEmptyKeyValuePairs::Def(vec![(gov_action_id, procedure)]);
|
||||
let outer: VotingProcedures = NonEmptyKeyValuePairs::Def(vec![(voter, inner)]);
|
||||
let vp_bytes = minicbor::to_vec(&outer)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode voting procedures: {e}")))?;
|
||||
|
||||
sign_voting_tx(
|
||||
payment_key,
|
||||
stake_key,
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
vp_bytes,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shared voting-tx signing: builds a tx with `voting_procedures`
|
||||
/// attached, two-pass-fee, dual-witness (payment + stake) signed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sign_voting_tx(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
voting_procedures_cbor: Vec<u8>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let change_addr = parse_address(change_address_bech32)?;
|
||||
let network_id = network_id_for(network);
|
||||
|
||||
let fee_pass1: u64 = 500_000;
|
||||
let need = fee_pass1
|
||||
.checked_add(params.min_utxo_lovelace)
|
||||
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
||||
|
||||
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = Vec::new();
|
||||
for u in sorted {
|
||||
acc = acc.saturating_add(u.lovelace);
|
||||
selected.push(u);
|
||||
if acc >= need {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if acc < need {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds: need {need} (fee+min_change), have {acc}"
|
||||
)));
|
||||
}
|
||||
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
||||
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
let change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
staging = staging.output(change_out);
|
||||
staging = staging.voting_procedures(voting_procedures_cbor.clone());
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
let change_pass1 = total_in
|
||||
.checked_sub(fee_pass1)
|
||||
.ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?;
|
||||
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
||||
let unsigned = staging1
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
||||
.tx_bytes
|
||||
.0;
|
||||
let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES;
|
||||
let real_fee = params.min_fee_for_size(est_signed);
|
||||
|
||||
let final_change = total_in.checked_sub(real_fee).ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} fee={real_fee}"
|
||||
))
|
||||
})?;
|
||||
if final_change < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"change ({final_change}) below min utxo ({})",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
|
||||
let staging2 = build_with_fee(real_fee, final_change)?;
|
||||
let built = staging2
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
||||
|
||||
let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?;
|
||||
let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key);
|
||||
let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?;
|
||||
Ok(fully_signed)
|
||||
}
|
||||
|
||||
/// Build + sign a DRep deregistration tx. Returns the 500 ADA deposit
|
||||
/// to the wallet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_signed_drep_deregistration(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let stake_pkh = stake_key.public_key_hash();
|
||||
let drep_credential = StakeCredential::AddrKeyhash(stake_pkh);
|
||||
let cert = Certificate::UnRegDRepCert(drep_credential, params.drep_deposit_lovelace);
|
||||
let cert_bytes = minicbor::to_vec(&cert)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode UnRegDRep cert: {e}")))?;
|
||||
|
||||
// Refund equals the deposit originally paid. Critical: this MUST match
|
||||
// what the DRep was originally registered with, not "current chain
|
||||
// drep_deposit." If the protocol changed deposit between registration
|
||||
// and deregistration, caller needs to override `params.drep_deposit_lovelace`
|
||||
// to the original-registration value. Otherwise ledger silently fails.
|
||||
sign_cert_tx_with_refund(
|
||||
payment_key,
|
||||
stake_key,
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
vec![cert_bytes],
|
||||
params.drep_deposit_lovelace,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shared cert-tx signing: builds 2-pass-fee, dual-witness, ada-only-funded
|
||||
/// tx with input asset preservation on change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sign_cert_tx(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
cert_bytes_list: Vec<Vec<u8>>,
|
||||
deposit: u64,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let change_addr = parse_address(change_address_bech32)?;
|
||||
let network_id = network_id_for(network);
|
||||
|
||||
let fee_pass1: u64 = 500_000;
|
||||
let need = deposit
|
||||
.checked_add(fee_pass1)
|
||||
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
||||
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
||||
|
||||
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = Vec::new();
|
||||
for u in sorted {
|
||||
acc = acc.saturating_add(u.lovelace);
|
||||
selected.push(u);
|
||||
if acc >= need {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if acc < need {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds: need {need} (deposit+fee+min_change), have {acc}"
|
||||
)));
|
||||
}
|
||||
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
||||
|
||||
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
for u in &selected {
|
||||
for (k, v) in &u.assets {
|
||||
let entry = input_assets.entry(k.clone()).or_insert(0);
|
||||
*entry = entry.saturating_add(*v);
|
||||
}
|
||||
}
|
||||
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &input_assets {
|
||||
if *q == 0 {
|
||||
continue;
|
||||
}
|
||||
if k.len() < 56 {
|
||||
return Err(WalletError::Derivation(
|
||||
"asset key shorter than 56 chars".into(),
|
||||
));
|
||||
}
|
||||
let pol_hex = &k[..56];
|
||||
let name_hex = &k[56..];
|
||||
let mut policy_bytes = [0u8; 28];
|
||||
for i in 0..28 {
|
||||
policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation("invalid policy hex".into()))?;
|
||||
}
|
||||
let policy = Hash::<28>::new(policy_bytes);
|
||||
let mut name_bytes = Vec::with_capacity(name_hex.len() / 2);
|
||||
for i in (0..name_hex.len()).step_by(2) {
|
||||
name_bytes.push(
|
||||
u8::from_str_radix(&name_hex[i..i + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation("invalid name hex".into()))?,
|
||||
);
|
||||
}
|
||||
change_out = change_out
|
||||
.add_asset(policy, name_bytes, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_out);
|
||||
for cb in &cert_bytes_list {
|
||||
staging = staging.add_certificate(cb.clone());
|
||||
}
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
let change_pass1 = total_in
|
||||
.checked_sub(deposit + fee_pass1)
|
||||
.ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?;
|
||||
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
||||
let unsigned = staging1
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
||||
.tx_bytes
|
||||
.0;
|
||||
let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES;
|
||||
let real_fee = params.min_fee_for_size(est_signed);
|
||||
|
||||
let token_change = !input_assets.is_empty();
|
||||
let final_change = total_in.checked_sub(deposit + real_fee).ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}"
|
||||
))
|
||||
})?;
|
||||
if final_change < params.min_utxo_lovelace && token_change {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change: change={final_change}, min={}",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
if final_change < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"change after deposit+fee ({final_change}) below min utxo ({}). top up the wallet.",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
|
||||
let staging2 = build_with_fee(real_fee, final_change)?;
|
||||
let built = staging2
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
||||
|
||||
let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?;
|
||||
let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key);
|
||||
let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?;
|
||||
Ok(fully_signed)
|
||||
}
|
||||
|
||||
/// Same as `sign_cert_tx` but for refund-bearing certs (deregistrations).
|
||||
/// The deposit is added back to the change instead of subtracted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sign_cert_tx_with_refund(
|
||||
payment_key: &PaymentKey,
|
||||
stake_key: &StakeKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
cert_bytes_list: Vec<Vec<u8>>,
|
||||
refund: u64,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let change_addr = parse_address(change_address_bech32)?;
|
||||
let network_id = network_id_for(network);
|
||||
|
||||
let fee_pass1: u64 = 500_000;
|
||||
// We need just fee + min_change; refund covers the rest.
|
||||
let need = fee_pass1
|
||||
.checked_add(params.min_utxo_lovelace)
|
||||
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
||||
|
||||
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
||||
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let mut acc: u64 = 0;
|
||||
let mut selected: Vec<InputUtxo> = Vec::new();
|
||||
for u in sorted {
|
||||
acc = acc.saturating_add(u.lovelace);
|
||||
selected.push(u);
|
||||
if acc >= need {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if acc < need {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds: need {need} (fee+min_change), have {acc}"
|
||||
)));
|
||||
}
|
||||
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
||||
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
let change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
staging = staging.output(change_out);
|
||||
for cb in &cert_bytes_list {
|
||||
staging = staging.add_certificate(cb.clone());
|
||||
}
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
// Change includes the refund: change = total_in + refund - fee.
|
||||
let change_pass1 = total_in
|
||||
.checked_add(refund)
|
||||
.and_then(|x| x.checked_sub(fee_pass1))
|
||||
.ok_or_else(|| WalletError::Derivation("pass1: amount overflow".into()))?;
|
||||
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
||||
let unsigned = staging1
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
||||
.tx_bytes
|
||||
.0;
|
||||
let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES;
|
||||
let real_fee = params.min_fee_for_size(est_signed);
|
||||
|
||||
let final_change = total_in
|
||||
.checked_add(refund)
|
||||
.and_then(|x| x.checked_sub(real_fee))
|
||||
.ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} refund={refund} fee={real_fee}"
|
||||
))
|
||||
})?;
|
||||
if final_change < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"change ({final_change}) below min utxo ({})",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
|
||||
let staging2 = build_with_fee(real_fee, final_change)?;
|
||||
let built = staging2
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
||||
|
||||
let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?;
|
||||
let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key);
|
||||
let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?;
|
||||
Ok(fully_signed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_drep_target_handles_named_specials() {
|
||||
assert_eq!(parse_drep_target("abstain").unwrap(), DRepTarget::Abstain);
|
||||
assert_eq!(
|
||||
parse_drep_target("no_confidence").unwrap(),
|
||||
DRepTarget::NoConfidence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_drep_target_rejects_garbage() {
|
||||
assert!(parse_drep_target("not-a-drep").is_err());
|
||||
assert!(parse_drep_target("pool1abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drep_target_into_pallas_round_trip() {
|
||||
let h = Hash::<28>::new([0u8; 28]);
|
||||
assert!(matches!(DRepTarget::Key(h).into_pallas(), DRep::Key(_)));
|
||||
assert!(matches!(
|
||||
DRepTarget::Script(h).into_pallas(),
|
||||
DRep::Script(_)
|
||||
));
|
||||
assert!(matches!(DRepTarget::Abstain.into_pallas(), DRep::Abstain));
|
||||
assert!(matches!(
|
||||
DRepTarget::NoConfidence.into_pallas(),
|
||||
DRep::NoConfidence
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -243,10 +243,8 @@ pub fn summarize_tx(cbor_bytes: &[u8]) -> Result<TxSummary, WalletError> {
|
|||
.unwrap_or(0);
|
||||
|
||||
let auxiliary_data_hash_set = body.auxiliary_data_hash.is_some();
|
||||
let auxiliary_data_present = matches!(
|
||||
tx.auxiliary_data,
|
||||
pallas_codec::utils::Nullable::Some(_)
|
||||
);
|
||||
let auxiliary_data_present =
|
||||
matches!(tx.auxiliary_data, pallas_codec::utils::Nullable::Some(_));
|
||||
|
||||
Ok(TxSummary {
|
||||
tx_hash: hex(body_hash.as_ref()),
|
||||
|
|
|
|||
|
|
@ -37,35 +37,48 @@ use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
|||
|
||||
pub mod cip68;
|
||||
pub mod derive;
|
||||
pub mod governance;
|
||||
pub mod inspect;
|
||||
pub mod metadata;
|
||||
pub mod mint;
|
||||
pub mod plutus;
|
||||
pub mod plutus_cost_models;
|
||||
pub mod plutus_mint;
|
||||
pub mod sign;
|
||||
pub mod stake;
|
||||
pub mod tx;
|
||||
pub use cip68::{
|
||||
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
|
||||
};
|
||||
pub use cip68::{build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name};
|
||||
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
|
||||
pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary};
|
||||
pub use inspect::{
|
||||
summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary,
|
||||
};
|
||||
// Stake address derivation lives directly on StakeKey — exported above.
|
||||
pub use governance::{
|
||||
build_signed_drep_deregistration, build_signed_drep_registration, build_signed_drep_vote_cast,
|
||||
build_signed_vote_delegation, parse_drep_target, DRepTarget, VoteChoice,
|
||||
DREP_REGISTRATION_DEPOSIT_LOVELACE,
|
||||
};
|
||||
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
||||
pub use mint::{
|
||||
build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata,
|
||||
build_unsigned_mint, PolicySpec,
|
||||
};
|
||||
pub use sign::add_witness;
|
||||
pub use pallas_txbuilder::ScriptKind;
|
||||
pub use plutus::{
|
||||
build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput,
|
||||
PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE,
|
||||
};
|
||||
pub use plutus_mint::{
|
||||
build_signed_plutus_mint, build_unsigned_plutus_mint, ExtraDestAsset, PlutusMintArgs,
|
||||
PlutusMintAsset,
|
||||
};
|
||||
pub use sign::add_witness;
|
||||
pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE};
|
||||
pub use tx::{
|
||||
build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment,
|
||||
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary,
|
||||
ProtocolParams, UnsignedPayment,
|
||||
build_signed_payment, build_signed_payment_extras, build_signed_payment_with_assets,
|
||||
build_unsigned_payment, build_unsigned_payment_extras, build_unsigned_payment_with_assets,
|
||||
hex_decode, AssetSpec, InputUtxo, PaymentSummary, ProtocolParams, ReferenceScriptSpec,
|
||||
UnsignedPayment,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
|
@ -158,10 +171,7 @@ impl Mnemonic {
|
|||
/// 2. Bit-clamp the first 32 bytes so the result is a valid extended
|
||||
/// Ed25519 scalar with the 3rd-highest bit cleared
|
||||
/// (`normalize_bytes_force3rd`).
|
||||
pub fn into_root_key_with_passphrase(
|
||||
self,
|
||||
passphrase: &str,
|
||||
) -> Result<RootKey, WalletError> {
|
||||
pub fn into_root_key_with_passphrase(self, passphrase: &str) -> Result<RootKey, WalletError> {
|
||||
let mut xprv_bytes = [0u8; XPRV_SIZE];
|
||||
let mut hmac = Hmac::new(Sha512::new(), passphrase.as_bytes());
|
||||
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
||||
|
|
@ -407,8 +417,8 @@ mod tests {
|
|||
assert!(addr.starts_with("addr1"), "got: {addr}");
|
||||
// Round-trip — pallas should parse what we just emitted and
|
||||
// give back a Shelley mainnet address.
|
||||
let parsed = pallas_addresses::Address::from_bech32(&addr)
|
||||
.expect("our own bech32 output parses");
|
||||
let parsed =
|
||||
pallas_addresses::Address::from_bech32(&addr).expect("our own bech32 output parses");
|
||||
match parsed {
|
||||
pallas_addresses::Address::Shelley(s) => {
|
||||
assert_eq!(s.network(), pallas_addresses::Network::Mainnet);
|
||||
|
|
|
|||
|
|
@ -74,7 +74,11 @@ fn json_to_metadatum(v: &Value) -> Result<Metadatum, WalletError> {
|
|||
Value::Null => Err(WalletError::Derivation(
|
||||
"null is not representable in Cardano metadata".into(),
|
||||
)),
|
||||
Value::Bool(b) => Ok(Metadatum::Int(Int(CborInt::from(if *b { 1i64 } else { 0 })))),
|
||||
Value::Bool(b) => Ok(Metadatum::Int(Int(CborInt::from(if *b {
|
||||
1i64
|
||||
} else {
|
||||
0
|
||||
})))),
|
||||
Value::Number(n) => {
|
||||
let i = n.as_i64().ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
|
|
@ -166,8 +170,7 @@ pub fn build_cip25_aux_data(
|
|||
plutus_scripts: None,
|
||||
});
|
||||
|
||||
minicbor::to_vec(&aux)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode CIP-25 aux: {e}")))
|
||||
minicbor::to_vec(&aux).map_err(|e| WalletError::Derivation(format!("encode CIP-25 aux: {e}")))
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>, WalletError> {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ use pallas_codec::minicbor;
|
|||
use pallas_crypto::hash::Hash;
|
||||
use pallas_primitives::alonzo::NativeScript;
|
||||
use pallas_traverse::ComputeHash;
|
||||
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction};
|
||||
use pallas_txbuilder::{
|
||||
BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction,
|
||||
};
|
||||
use pallas_wallet::PrivateKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -161,8 +163,7 @@ fn hash_to_hex(h: &Hash<28>) -> String {
|
|||
}
|
||||
|
||||
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32)
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
||||
|
|
@ -353,11 +354,9 @@ fn prepare_mint(
|
|||
(real_fee, c)
|
||||
}
|
||||
Some(c) => (real_fee + c, 0),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}"
|
||||
)))
|
||||
}
|
||||
None => return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} dest={dest_lovelace} fee={real_fee}"
|
||||
))),
|
||||
};
|
||||
|
||||
let staging2 = build_mint_staging(
|
||||
|
|
@ -729,63 +728,62 @@ pub fn build_signed_cip68_nft_mint(
|
|||
}
|
||||
}
|
||||
|
||||
let build_with_fee = |fee: u64,
|
||||
change_lovelace: u64|
|
||||
-> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
|
||||
// Output 1: ref NFT @ ref_address, with inline datum.
|
||||
let ref_out = Output::new(ref_addr.clone(), ref_lovelace)
|
||||
.add_asset(policy_id, ref_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))?
|
||||
.set_inline_datum(datum_cbor.clone());
|
||||
staging = staging.output(ref_out);
|
||||
|
||||
// Output 2: user NFT @ user_address.
|
||||
let user_out = Output::new(user_addr.clone(), user_lovelace)
|
||||
.add_asset(policy_id, user_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?;
|
||||
staging = staging.output(user_out);
|
||||
|
||||
// Output 3 (optional): change @ wallet, with leftover input assets.
|
||||
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = input_assets
|
||||
.iter()
|
||||
.filter(|(_, q)| **q > 0)
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &nonzero_change_assets {
|
||||
if k.len() < 56 {
|
||||
return Err(WalletError::Derivation(
|
||||
"change asset key shorter than 56 chars".into(),
|
||||
));
|
||||
}
|
||||
let p = parse_pkh(&k[..56])?;
|
||||
let n = parse_asset_name(&k[56..])?;
|
||||
change_out = change_out
|
||||
.add_asset(p, n, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
staging = staging.output(change_out);
|
||||
}
|
||||
|
||||
staging = staging
|
||||
.mint_asset(policy_id, ref_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))?
|
||||
.mint_asset(policy_id, user_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("mint user: {e}")))?
|
||||
.script(ScriptKind::Native, script_cbor.clone())
|
||||
.disclosed_signer(payment_pkh)
|
||||
.fee(fee)
|
||||
.network_id(network_id);
|
||||
// Output 1: ref NFT @ ref_address, with inline datum.
|
||||
let ref_out = Output::new(ref_addr.clone(), ref_lovelace)
|
||||
.add_asset(policy_id, ref_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("ref add_asset: {e}")))?
|
||||
.set_inline_datum(datum_cbor.clone());
|
||||
staging = staging.output(ref_out);
|
||||
|
||||
Ok(staging)
|
||||
};
|
||||
// Output 2: user NFT @ user_address.
|
||||
let user_out = Output::new(user_addr.clone(), user_lovelace)
|
||||
.add_asset(policy_id, user_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("user add_asset: {e}")))?;
|
||||
staging = staging.output(user_out);
|
||||
|
||||
// Output 3 (optional): change @ wallet, with leftover input assets.
|
||||
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = input_assets
|
||||
.iter()
|
||||
.filter(|(_, q)| **q > 0)
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &nonzero_change_assets {
|
||||
if k.len() < 56 {
|
||||
return Err(WalletError::Derivation(
|
||||
"change asset key shorter than 56 chars".into(),
|
||||
));
|
||||
}
|
||||
let p = parse_pkh(&k[..56])?;
|
||||
let n = parse_asset_name(&k[56..])?;
|
||||
change_out = change_out
|
||||
.add_asset(p, n, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_out);
|
||||
}
|
||||
|
||||
staging = staging
|
||||
.mint_asset(policy_id, ref_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("mint ref: {e}")))?
|
||||
.mint_asset(policy_id, user_name.clone(), 1)
|
||||
.map_err(|e| WalletError::Derivation(format!("mint user: {e}")))?
|
||||
.script(ScriptKind::Native, script_cbor.clone())
|
||||
.disclosed_signer(payment_pkh)
|
||||
.fee(fee)
|
||||
.network_id(network_id);
|
||||
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
// Pass 1 — placeholder fee, measure unsigned size, recompute.
|
||||
let change_pass1 = total_in
|
||||
|
|
@ -803,25 +801,24 @@ pub fn build_signed_cip68_nft_mint(
|
|||
let real_fee = params.min_fee_for_size(est_signed);
|
||||
|
||||
let token_change = !input_assets.is_empty();
|
||||
let (final_fee, final_change) = match total_in
|
||||
.checked_sub(user_lovelace + ref_lovelace + real_fee)
|
||||
{
|
||||
Some(c) if c >= params.min_utxo_lovelace || token_change => {
|
||||
if token_change && c < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change: change={c}, min={}",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
let (final_fee, final_change) =
|
||||
match total_in.checked_sub(user_lovelace + ref_lovelace + real_fee) {
|
||||
Some(c) if c >= params.min_utxo_lovelace || token_change => {
|
||||
if token_change && c < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change: change={c}, min={}",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
(real_fee, c)
|
||||
}
|
||||
(real_fee, c)
|
||||
}
|
||||
Some(c) => (real_fee + c, 0),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} fee={real_fee}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Some(c) => (real_fee + c, 0),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} fee={real_fee}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let staging2 = build_with_fee(final_fee, final_change)?;
|
||||
let built = staging2
|
||||
|
|
@ -949,7 +946,11 @@ mod tests {
|
|||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect("mint builds + signs");
|
||||
assert!(cbor.len() > 200, "mint cbor too short: {} bytes", cbor.len());
|
||||
assert!(
|
||||
cbor.len() > 200,
|
||||
"mint cbor too short: {} bytes",
|
||||
cbor.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1047,7 +1048,10 @@ mod tests {
|
|||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_inline_datum, "ref NFT output must carry an inline datum");
|
||||
assert!(
|
||||
found_inline_datum,
|
||||
"ref NFT output must carry an inline datum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1085,8 +1089,8 @@ mod tests {
|
|||
// Decode the resulting tx and confirm:
|
||||
// 1. aux_data is present
|
||||
// 2. body.auxiliary_data_hash is populated
|
||||
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
||||
.expect("decode signed mint cbor");
|
||||
let tx =
|
||||
pallas_primitives::conway::Tx::decode_fragment(&cbor).expect("decode signed mint cbor");
|
||||
assert!(
|
||||
tx.transaction_body.auxiliary_data_hash.is_some(),
|
||||
"aux_data_hash must be set when metadata is attached"
|
||||
|
|
|
|||
|
|
@ -75,8 +75,7 @@ impl PlutusVersion {
|
|||
pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000;
|
||||
|
||||
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32)
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
||||
|
|
@ -182,8 +181,7 @@ pub fn build_signed_plutus_spend(
|
|||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex
|
||||
&& u.output_index == collateral.output_index)
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
|
|
@ -225,46 +223,43 @@ pub fn build_signed_plutus_spend(
|
|||
collateral.output_index as u64,
|
||||
);
|
||||
|
||||
let build_with_fee = |fee: u64,
|
||||
change_lovelace: u64|
|
||||
-> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
// PLUTUS-1: locked + funding as regular inputs (both consumed
|
||||
// on happy path); collateral as collateral_input only.
|
||||
staging = staging.input(locked_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input.clone());
|
||||
staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace));
|
||||
if change_lovelace > 0 {
|
||||
staging = staging.output(Output::new(change_addr.clone(), change_lovelace));
|
||||
}
|
||||
staging = staging
|
||||
.script(script_version.to_script_kind(), script_cbor.to_vec())
|
||||
.add_spend_redeemer(
|
||||
locked_input.clone(),
|
||||
redeemer_cbor.to_vec(),
|
||||
Some(ex_units.into()),
|
||||
)
|
||||
.fee(fee)
|
||||
.network_id(network_id);
|
||||
if let Some(d) = witness_datum_cbor {
|
||||
staging = staging.datum(d.to_vec());
|
||||
}
|
||||
// PLUTUS-4 audit fix: pallas-txbuilder only computes
|
||||
// script_data_hash if language_view is set. Without it, the
|
||||
// body's hash is None and the chain rejects with
|
||||
// PPViewHashesDontMatch. PlutusV3 path requires a V3 cost
|
||||
// model — caller-supplied via ProtocolParams.
|
||||
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
|
||||
if matches!(script_version, PlutusVersion::V3) {
|
||||
staging = staging.language_view(
|
||||
script_version.to_script_kind(),
|
||||
cost_model.to_vec(),
|
||||
);
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
// PLUTUS-1: locked + funding as regular inputs (both consumed
|
||||
// on happy path); collateral as collateral_input only.
|
||||
staging = staging.input(locked_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input.clone());
|
||||
staging = staging.output(Output::new(payout_addr.clone(), payout_lovelace));
|
||||
if change_lovelace > 0 {
|
||||
staging = staging.output(Output::new(change_addr.clone(), change_lovelace));
|
||||
}
|
||||
}
|
||||
Ok(staging)
|
||||
};
|
||||
staging = staging
|
||||
.script(script_version.to_script_kind(), script_cbor.to_vec())
|
||||
.add_spend_redeemer(
|
||||
locked_input.clone(),
|
||||
redeemer_cbor.to_vec(),
|
||||
Some(ex_units.into()),
|
||||
)
|
||||
.fee(fee)
|
||||
.network_id(network_id);
|
||||
if let Some(d) = witness_datum_cbor {
|
||||
staging = staging.datum(d.to_vec());
|
||||
}
|
||||
// PLUTUS-4 audit fix: pallas-txbuilder only computes
|
||||
// script_data_hash if language_view is set. Without it, the
|
||||
// body's hash is None and the chain rejects with
|
||||
// PPViewHashesDontMatch. PlutusV3 path requires a V3 cost
|
||||
// model — caller-supplied via ProtocolParams.
|
||||
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
|
||||
if matches!(script_version, PlutusVersion::V3) {
|
||||
staging =
|
||||
staging.language_view(script_version.to_script_kind(), cost_model.to_vec());
|
||||
}
|
||||
}
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
// Pass 1 — placeholder fee, measure unsigned size.
|
||||
let change_pass1 = total_in
|
||||
|
|
@ -316,7 +311,10 @@ pub fn looks_like_script_address(addr_bech32: &str) -> bool {
|
|||
// is a script-payment + key-delegation address; types 2,
|
||||
// 3, 5, 7 also have script payment parts. Matches header
|
||||
// bits where bit 4 = 1 for script payment.
|
||||
bytes.first().map(|b| (b >> 4) & 0b0001 != 0).unwrap_or(false)
|
||||
bytes
|
||||
.first()
|
||||
.map(|b| (b >> 4) & 0b0001 != 0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,42 +13,34 @@
|
|||
// Naming kept as `_PREPROD` for git churn reasons; treat as
|
||||
// "current Plutus V3 protocol parameters."
|
||||
pub const PLUTUS_V3_COST_MODEL_PREPROD: [i64; 297] = [
|
||||
100788, 420, 1, 1, 1000, 173, 0, 1,
|
||||
1000, 59957, 4, 1, 11183, 32, 201305, 8356,
|
||||
4, 16000, 100, 16000, 100, 16000, 100, 16000,
|
||||
100, 16000, 100, 16000, 100, 100, 100, 16000,
|
||||
100, 94375, 32, 132994, 32, 61462, 4, 72010,
|
||||
178, 0, 1, 22151, 32, 91189, 769, 4,
|
||||
2, 85848, 123203, 7305, -900, 1716, 549, 57,
|
||||
85848, 0, 1, 1, 1000, 42921, 4, 2,
|
||||
24548, 29498, 38, 1, 898148, 27279, 1, 51775,
|
||||
558, 1, 39184, 1000, 60594, 1, 141895, 32,
|
||||
83150, 32, 15299, 32, 76049, 1, 13169, 4,
|
||||
22100, 10, 28999, 74, 1, 28999, 74, 1,
|
||||
43285, 552, 1, 44749, 541, 1, 33852, 32,
|
||||
68246, 32, 72362, 32, 7243, 32, 7391, 32,
|
||||
11546, 32, 85848, 123203, 7305, -900, 1716, 549,
|
||||
57, 85848, 0, 1, 90434, 519, 0, 1,
|
||||
74433, 32, 85848, 123203, 7305, -900, 1716, 549,
|
||||
57, 85848, 0, 1, 1, 85848, 123203, 7305,
|
||||
-900, 1716, 549, 57, 85848, 0, 1, 955506,
|
||||
213312, 0, 2, 270652, 22588, 4, 1457325, 64566,
|
||||
4, 20467, 1, 4, 0, 141992, 32, 100788,
|
||||
420, 1, 1, 81663, 32, 59498, 32, 20142,
|
||||
32, 24588, 32, 20744, 32, 25933, 32, 24623,
|
||||
32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308,
|
||||
10, 16000, 100, 16000, 100, 962335, 18, 2780678,
|
||||
6, 442008, 1, 52538055, 3756, 18, 267929, 18,
|
||||
76433006, 8868, 18, 52948122, 18, 1995836, 36, 3227919,
|
||||
12, 901022, 1, 166917843, 4307, 36, 284546, 36,
|
||||
158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273,
|
||||
72, 2174038, 72, 2261318, 64571, 4, 207616, 8310,
|
||||
4, 1293828, 28716, 63, 0, 1, 1006041, 43623,
|
||||
251, 0, 1, 100181, 726, 719, 0, 1,
|
||||
100181, 726, 719, 0, 1, 100181, 726, 719,
|
||||
0, 1, 107878, 680, 0, 1, 95336, 1,
|
||||
281145, 18848, 0, 1, 180194, 159, 1, 1,
|
||||
158519, 8942, 0, 1, 159378, 8813, 0, 1,
|
||||
107490, 3298, 1, 106057, 655, 1, 1964219, 24520,
|
||||
3,
|
||||
100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100,
|
||||
16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, 16000, 100, 94375, 32,
|
||||
132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, 4, 2, 85848, 123203, 7305, -900,
|
||||
1716, 549, 57, 85848, 0, 1, 1, 1000, 42921, 4, 2, 24548, 29498, 38, 1, 898148, 27279, 1, 51775,
|
||||
558, 1, 39184, 1000, 60594, 1, 141895, 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10,
|
||||
28999, 74, 1, 28999, 74, 1, 43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32,
|
||||
7243, 32, 7391, 32, 11546, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 90434,
|
||||
519, 0, 1, 74433, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 1, 85848, 123203,
|
||||
7305, -900, 1716, 549, 57, 85848, 0, 1, 955506, 213312, 0, 2, 270652, 22588, 4, 1457325, 64566,
|
||||
4, 20467, 1, 4, 0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, 20142, 32, 24588, 32,
|
||||
20744, 32, 25933, 32, 24623, 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, 10, 16000,
|
||||
100, 16000, 100, 962335, 18, 2780678, 6, 442008, 1, 52538055, 3756, 18, 267929, 18, 76433006,
|
||||
8868, 18, 52948122, 18, 1995836, 36, 3227919, 12, 901022, 1, 166917843, 4307, 36, 284546, 36,
|
||||
158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273, 72, 2174038, 72, 2261318, 64571,
|
||||
4, 207616, 8310, 4, 1293828, 28716, 63, 0, 1, 1006041, 43623, 251, 0, 1, 100181, 726, 719, 0,
|
||||
1, 100181, 726, 719, 0, 1, 100181, 726, 719, 0, 1, 107878, 680, 0, 1, 95336, 1, 281145, 18848,
|
||||
0, 1, 180194, 159, 1, 1, 158519, 8942, 0, 1, 159378, 8813, 0, 1, 107490, 3298, 1, 106057, 655,
|
||||
1, 1964219, 24520, 3,
|
||||
];
|
||||
pub const PLUTUS_V2_COST_MODEL_PREPROD: [i64; 175] = [
|
||||
100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100,
|
||||
16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, 16000, 100, 94375, 32,
|
||||
132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, 4, 2, 85848, 228465, 122, 0, 1,
|
||||
1, 1000, 42921, 4, 2, 24548, 29498, 38, 1, 898148, 27279, 1, 51775, 558, 1, 39184, 1000, 60594,
|
||||
1, 141895, 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10, 28999, 74, 1, 28999, 74, 1,
|
||||
43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32, 7243, 32, 7391, 32, 11546, 32,
|
||||
85848, 228465, 122, 0, 1, 1, 90434, 519, 0, 1, 74433, 32, 85848, 228465, 122, 0, 1, 1, 85848,
|
||||
228465, 122, 0, 1, 1, 955506, 213312, 0, 2, 270652, 22588, 4, 1457325, 64566, 4, 20467, 1, 4,
|
||||
0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, 20142, 32, 24588, 32, 20744, 32, 25933,
|
||||
32, 24623, 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, 10,
|
||||
];
|
||||
|
|
|
|||
950
crates/aldabra-core/src/plutus_mint.rs
Normal file
950
crates/aldabra-core/src/plutus_mint.rs
Normal file
|
|
@ -0,0 +1,950 @@
|
|||
//! Plutus-policy mint with custom output construction.
|
||||
//!
|
||||
//! Distinct from `mint.rs` which only handles native-script policies.
|
||||
//! Used for any DApp that ships Plutus-compiled minting policies —
|
||||
//! Agora's GST/StakeST/ProposalST/GAT, Liqwid's lqADA/iAsset, etc.
|
||||
//!
|
||||
//! ## Tx shape
|
||||
//!
|
||||
//! - **Required inputs**: caller-supplied list of UTxOs that MUST be
|
||||
//! spent (e.g. Agora's GST policy is parameterized on a specific
|
||||
//! UTxO ref; the policy only authorizes a mint when that UTxO is
|
||||
//! consumed in the same tx).
|
||||
//! - **Funding inputs**: chosen automatically from `available_utxos`.
|
||||
//! At least one ADA-only UTxO ≥ 5 ADA must remain available for
|
||||
//! collateral — same constraint as `plutus.rs::build_signed_plutus_spend`.
|
||||
//! - **Collateral input**: smallest ADA-only UTxO ≥ 5 ADA, distinct
|
||||
//! from required + funding inputs. Only consumed if the script
|
||||
//! fails on-chain.
|
||||
//! - **Mint**: caller-supplied `(asset_name_hex, quantity)` list under
|
||||
//! the supplied policy (Plutus V1/V2/V3).
|
||||
//! - **Recipient output**: address + lovelace + minted assets +
|
||||
//! any caller-supplied extra assets to forward (e.g. tTRP gov tokens
|
||||
//! on a stake bootstrap) + optional inline datum.
|
||||
//! - **Change output**: leftover ADA + leftover input assets (other
|
||||
//! than what was forwarded to the recipient).
|
||||
//!
|
||||
//! ## Why a single tool covers governor + stake bootstrap
|
||||
//!
|
||||
//! Agora's deployment pattern is the same shape for every "first-time
|
||||
//! mint of a single ST token under a Plutus policy" tx:
|
||||
//! - Governor bootstrap: mint 1 GST → governor_addr + GovernorDatum
|
||||
//! - Stake bootstrap: mint 1 StakeST → stakes_addr + tTRP + StakeDatum
|
||||
//! - Proposal create: mint 1 ProposalST → proposal_addr + ProposalDatum
|
||||
//!
|
||||
//! All three share the structure; the only differences are the
|
||||
//! particular policy CBOR + redeemer + datum + extra assets to forward.
|
||||
|
||||
use pallas_addresses::Address as PallasAddress;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_crypto::key::ed25519::SecretKeyExtended;
|
||||
use pallas_txbuilder::{
|
||||
BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction,
|
||||
};
|
||||
use pallas_wallet::PrivateKey;
|
||||
|
||||
use crate::plutus::{PlutusVersion, MIN_COLLATERAL_LOVELACE};
|
||||
use crate::tx::{hex_decode, AssetSpec, InputUtxo, PaymentSummary, ProtocolParams};
|
||||
use crate::{Network, PaymentKey, WalletError};
|
||||
|
||||
/// One asset to mint under the Plutus policy. Quantity > 0 mints,
|
||||
/// < 0 burns. Burning requires the wallet to hold the asset already
|
||||
/// (it'll be drawn from input assets).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlutusMintAsset {
|
||||
pub asset_name_hex: String,
|
||||
pub quantity: i64,
|
||||
}
|
||||
|
||||
/// Optional non-mint asset to attach to the recipient output.
|
||||
/// Used for e.g. "send tTRP alongside the freshly-minted StakeST"
|
||||
/// on a stake bootstrap. Sourced from wallet input UTxOs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtraDestAsset {
|
||||
pub policy_id_hex: String,
|
||||
pub asset_name_hex: String,
|
||||
pub quantity: u64,
|
||||
}
|
||||
|
||||
/// Full input spec for a Plutus mint. Caller fills out everything;
|
||||
/// the builder chooses funding + collateral and computes fees.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlutusMintArgs<'a> {
|
||||
/// Specific UTxOs that MUST appear as regular inputs. e.g. for
|
||||
/// Agora's GST policy, this is the gstOutRef the policy was
|
||||
/// parameterized on. May be empty.
|
||||
pub required_inputs: &'a [InputUtxo],
|
||||
/// Plutus minting policy script CBOR (the raw script, NOT a
|
||||
/// `cborHex` wrapper — caller hex-decoded it).
|
||||
pub policy_cbor: &'a [u8],
|
||||
pub policy_version: PlutusVersion,
|
||||
/// PlutusData CBOR redeemer for the mint redeemer entry.
|
||||
pub redeemer_cbor: &'a [u8],
|
||||
/// Generous default if `None`. Tune for known validators.
|
||||
pub ex_units: crate::plutus::PlutusExUnits,
|
||||
/// Assets to mint under this policy.
|
||||
pub mint_assets: &'a [PlutusMintAsset],
|
||||
/// Recipient address (script or wallet — both work; for DAO
|
||||
/// flows this is governor_addr / stakes_addr / etc).
|
||||
pub dest_address_bech32: &'a str,
|
||||
pub dest_lovelace: u64,
|
||||
/// Non-mint assets to include on the recipient output. Sourced
|
||||
/// from wallet inputs. Empty for governor bootstrap; non-empty
|
||||
/// for stake bootstrap (tTRP forwarded into the stake).
|
||||
pub dest_extra_assets: &'a [ExtraDestAsset],
|
||||
/// Optional inline datum on the recipient output. Required for
|
||||
/// any send to a Plutus script address.
|
||||
pub dest_inline_datum_cbor: Option<&'a [u8]>,
|
||||
/// PKH hashes to add to the tx body's `required_signers` field.
|
||||
/// On Babbage+, Plutus's `TxInfo.signatories` is populated ONLY
|
||||
/// from `required_signers` — VKey witnesses alone are not enough
|
||||
/// for any `pauthorizedBy` / `txSignedBy` check inside a script.
|
||||
/// MCP layer always passes this wallet's payment-key pkh; pass
|
||||
/// extra entries for cosigners. Empty slice = scripts that don't
|
||||
/// check signatories (e.g. Agora's GST policy).
|
||||
/// Caught 2026-05-07 on Agora's stake-policy bootstrap on preprod
|
||||
/// — script erred because owner pkh was absent from signatories.
|
||||
pub additional_signers: &'a [Hash<28>],
|
||||
}
|
||||
|
||||
fn parse_address(bech32: &str) -> Result<PallasAddress, WalletError> {
|
||||
PallasAddress::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
||||
if hex_str.len() != 64 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"expected 64-char hex tx_hash, got {}",
|
||||
hex_str.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?;
|
||||
}
|
||||
Ok(Hash::<32>::new(out))
|
||||
}
|
||||
|
||||
fn parse_policy_id(hex_str: &str) -> Result<Hash<28>, WalletError> {
|
||||
if hex_str.len() != 56 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"expected 56-hex policy_id, got {}",
|
||||
hex_str.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; 28];
|
||||
for i in 0..28 {
|
||||
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation(format!("invalid hex in policy_id: {hex_str}")))?;
|
||||
}
|
||||
Ok(Hash::<28>::new(out))
|
||||
}
|
||||
|
||||
fn parse_asset_name(hex_str: &str) -> Result<Vec<u8>, WalletError> {
|
||||
if !hex_str.len().is_multiple_of(2) {
|
||||
return Err(WalletError::Derivation(
|
||||
"asset_name hex must have even length".into(),
|
||||
));
|
||||
}
|
||||
if hex_str.len() > 64 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"asset_name too long: {} hex chars (>64)",
|
||||
hex_str.len()
|
||||
)));
|
||||
}
|
||||
hex_decode(hex_str)
|
||||
}
|
||||
|
||||
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
|
||||
let extended: [u8; 64] = payment.xprv().extended_secret_key();
|
||||
let secret = SecretKeyExtended::from_bytes(extended)
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
|
||||
Ok(PrivateKey::Extended(secret))
|
||||
}
|
||||
|
||||
fn network_id_for(network: Network) -> u8 {
|
||||
match network {
|
||||
Network::Mainnet => 1,
|
||||
Network::Preview | Network::Preprod => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_eq(a: &InputUtxo, b: &InputUtxo) -> bool {
|
||||
a.tx_hash_hex == b.tx_hash_hex && a.output_index == b.output_index
|
||||
}
|
||||
|
||||
fn hash_to_hex(h: &Hash<28>) -> String {
|
||||
let bytes: &[u8] = h.as_ref();
|
||||
let mut s = String::with_capacity(56);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn hash_to_hex_32(h: &[u8; 32]) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in h {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// Generous overhead for the vkey witness + redeemer ex_units inflation +
|
||||
// CBOR length-prefix flips between unsigned (def-length) and signed
|
||||
// (indef-length) array tags. Original 128 underbid by 144 bytes on a
|
||||
// 3-input + inline-V2-policy mint (preprod_test2 governor bootstrap
|
||||
// 2026-05-08, FeeTooSmallUTxO @ 6353 lovelace short). Bumping to 256
|
||||
// got within 16 bytes on retry — still rejected. 512 is generous head-
|
||||
// room for any single-vkey case (~+22k lovelace overestimate worst-case,
|
||||
// trivial); reconsider for multi-sig where many vkey witnesses are added.
|
||||
const WITNESS_OVERHEAD_BYTES: u64 = 512;
|
||||
|
||||
/// Build + sign a Plutus-policy mint with a fully-specified output.
|
||||
///
|
||||
/// Selects collateral (smallest ADA-only ≥ 5 ADA) + funding (largest
|
||||
/// remaining UTxOs sufficient to cover dest + fee + min_change) from
|
||||
/// `available_utxos`. Required inputs are added as regular inputs.
|
||||
/// The policy script witnesses inline; redeemer + ExUnits attached
|
||||
/// via `add_mint_redeemer`. Mint assets land on the dest output;
|
||||
/// extra assets are sourced from inputs and forwarded; leftover
|
||||
/// assets go to change.
|
||||
pub fn build_signed_plutus_mint(
|
||||
payment_key: &PaymentKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
args: &PlutusMintArgs,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let private = payment_key_to_private(payment_key)?;
|
||||
let (built, _summary) = prepare_plutus_mint(
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
args,
|
||||
params,
|
||||
)?;
|
||||
let signed = built
|
||||
.sign(private)
|
||||
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
||||
Ok(signed.tx_bytes.0)
|
||||
}
|
||||
|
||||
/// Build (no sign) a Plutus-policy mint. Returns the unsigned CBOR
|
||||
/// + summary for review before pushing through `wallet_sign_partial`
|
||||
/// + `wallet_submit_signed_tx`.
|
||||
pub fn build_unsigned_plutus_mint(
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
args: &PlutusMintArgs,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<crate::tx::UnsignedPayment, WalletError> {
|
||||
let (built, summary) = prepare_plutus_mint(
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
args,
|
||||
params,
|
||||
)?;
|
||||
Ok(crate::tx::UnsignedPayment {
|
||||
cbor_hex: built.tx_bytes.0.iter().fold(
|
||||
String::with_capacity(built.tx_bytes.0.len() * 2),
|
||||
|mut s, b| {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
s
|
||||
},
|
||||
),
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_plutus_mint(
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
args: &PlutusMintArgs,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
||||
let dest_addr = parse_address(args.dest_address_bech32)?;
|
||||
let change_addr = parse_address(change_address_bech32)?;
|
||||
let network_id = network_id_for(network);
|
||||
|
||||
if args.policy_cbor.is_empty() {
|
||||
return Err(WalletError::Derivation(
|
||||
"policy_cbor must be non-empty".into(),
|
||||
));
|
||||
}
|
||||
if args.mint_assets.is_empty() {
|
||||
return Err(WalletError::Derivation(
|
||||
"mint_assets must be non-empty (caller must supply at least one asset to mint or burn)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Required inputs MUST exist in available_utxos so we know their
|
||||
// ADA value for fee math. Caller-supplied required_inputs entries
|
||||
// already carry lovelace/assets, but we double-check existence.
|
||||
for req in args.required_inputs {
|
||||
if !available_utxos.iter().any(|u| input_eq(u, req)) {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"required_input {}#{} is not in available_utxos — caller must include it",
|
||||
req.tx_hash_hex, req.output_index
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the policy hash for naming the mint asset.
|
||||
let policy_hash: Hash<28> = {
|
||||
// Pallas computes script hash as blake2b-224 of (tag || cbor).
|
||||
// Tags: Native=0, PlutusV1=1, PlutusV2=2, PlutusV3=3.
|
||||
let tag: u8 = match args.policy_version {
|
||||
PlutusVersion::V1 => 1,
|
||||
PlutusVersion::V2 => 2,
|
||||
PlutusVersion::V3 => 3,
|
||||
};
|
||||
use pallas_crypto::hash::Hasher;
|
||||
Hasher::<224>::hash_tagged(args.policy_cbor, tag)
|
||||
};
|
||||
let policy_id_hex = hash_to_hex(&policy_hash);
|
||||
|
||||
// Collateral: smallest ADA-only ≥ 5 ADA, NOT one of required_inputs.
|
||||
let mut ada_only: Vec<&InputUtxo> = available_utxos
|
||||
.iter()
|
||||
.filter(|u| u.assets.is_empty())
|
||||
.filter(|u| !args.required_inputs.iter().any(|r| input_eq(u, r)))
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
"no ADA-only wallet UTXO ≥ {} lovelace available for collateral \
|
||||
(excluding required_inputs)",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.to_owned()
|
||||
.clone();
|
||||
|
||||
// Pre-compute the parsed mint-asset-name bytes for staging.
|
||||
let parsed_mint_assets: Vec<(Vec<u8>, i64)> = args
|
||||
.mint_assets
|
||||
.iter()
|
||||
.map(|a| -> Result<_, WalletError> {
|
||||
Ok((parse_asset_name(&a.asset_name_hex)?, a.quantity))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
// Aggregate input assets (required + chosen funding) into one map.
|
||||
// dest_extra_assets must be coverable from these. Mint contributes
|
||||
// additional assets on top.
|
||||
|
||||
// Build canonical asset key: policy_id_hex || asset_name_hex.
|
||||
let asset_key = |pol: &str, name: &str| format!("{pol}{name}");
|
||||
|
||||
// Required-by-extras assets — caller asked us to forward these to dest.
|
||||
let mut needed_extras: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
for e in args.dest_extra_assets {
|
||||
if e.policy_id_hex.len() != 56 {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"dest_extra_asset policy_id_hex must be 56 hex chars, got {}",
|
||||
e.policy_id_hex.len()
|
||||
)));
|
||||
}
|
||||
// Round-trip parse for hex sanity.
|
||||
let _ = parse_policy_id(&e.policy_id_hex)?;
|
||||
let _ = parse_asset_name(&e.asset_name_hex)?;
|
||||
let key = asset_key(&e.policy_id_hex, &e.asset_name_hex);
|
||||
*needed_extras.entry(key).or_insert(0) = needed_extras
|
||||
.get(&asset_key(&e.policy_id_hex, &e.asset_name_hex))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(e.quantity);
|
||||
}
|
||||
|
||||
// Funding selection: include all required_inputs first, then add
|
||||
// ADA-only candidates (excluding collateral) until we cover
|
||||
// (dest_lovelace + estimated fee + min_utxo_change). Also include
|
||||
// any UTxOs we need to drain to cover needed_extras.
|
||||
let mut funding: Vec<InputUtxo> = args.required_inputs.to_vec();
|
||||
|
||||
// First, scan for UTxOs that hold needed_extras assets and add
|
||||
// them to funding. Track running totals of held assets.
|
||||
let mut held: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
for u in &funding {
|
||||
for (k, v) in &u.assets {
|
||||
*held.entry(k.clone()).or_insert(0) =
|
||||
held.get(k).copied().unwrap_or(0).saturating_add(*v);
|
||||
}
|
||||
}
|
||||
// Pull in UTxOs that contribute the needed assets.
|
||||
for u in available_utxos {
|
||||
if input_eq(u, &collateral) {
|
||||
continue;
|
||||
}
|
||||
if funding.iter().any(|f| input_eq(f, u)) {
|
||||
continue;
|
||||
}
|
||||
// Does this UTxO contribute to a still-deficit extra asset?
|
||||
let mut helps = false;
|
||||
for (k, need) in &needed_extras {
|
||||
let have = held.get(k).copied().unwrap_or(0);
|
||||
if have < *need && u.assets.contains_key(k) {
|
||||
helps = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if helps {
|
||||
for (k, v) in &u.assets {
|
||||
*held.entry(k.clone()).or_insert(0) =
|
||||
held.get(k).copied().unwrap_or(0).saturating_add(*v);
|
||||
}
|
||||
funding.push(u.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all needed_extras are covered.
|
||||
for (k, need) in &needed_extras {
|
||||
let have = held.get(k).copied().unwrap_or(0);
|
||||
if have < *need {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"wallet doesn't hold enough of {k} to forward to dest: need {need}, have {have}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// ExUnits fee estimate.
|
||||
let ex_fee = params.ex_units_fee(args.ex_units.mem, args.ex_units.steps);
|
||||
let fee_pass1: u64 = 1_000_000u64.saturating_add(ex_fee);
|
||||
|
||||
// Add ADA-only funding UTxOs until we have enough for dest + fee
|
||||
// + min_change.
|
||||
let need_total = args
|
||||
.dest_lovelace
|
||||
.checked_add(fee_pass1)
|
||||
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
||||
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
||||
let total_in_so_far: u64 = funding.iter().map(|u| u.lovelace).sum();
|
||||
if total_in_so_far < need_total {
|
||||
// Need more ADA — pull in additional ADA-only UTxOs.
|
||||
for u in available_utxos {
|
||||
if input_eq(u, &collateral) {
|
||||
continue;
|
||||
}
|
||||
if funding.iter().any(|f| input_eq(f, u)) {
|
||||
continue;
|
||||
}
|
||||
funding.push(u.clone());
|
||||
let now: u64 = funding.iter().map(|x| x.lovelace).sum();
|
||||
if now >= need_total {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let total_in: u64 = funding.iter().map(|u| u.lovelace).sum();
|
||||
if total_in < need_total {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient lovelace: need {need_total} (dest + est_fee + min_change), have {total_in}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Aggregate input assets (after funding finalized).
|
||||
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
for u in &funding {
|
||||
for (k, v) in &u.assets {
|
||||
*input_assets.entry(k.clone()).or_insert(0) =
|
||||
input_assets.get(k).copied().unwrap_or(0).saturating_add(*v);
|
||||
}
|
||||
}
|
||||
|
||||
// Process burns (negative mint quantities) — subtract from input_assets
|
||||
// so they don't leak to change.
|
||||
for ma in args.mint_assets {
|
||||
if ma.quantity < 0 {
|
||||
let burn_qty = (-ma.quantity) as u64;
|
||||
let key = asset_key(&policy_id_hex, &ma.asset_name_hex);
|
||||
let have = input_assets.get(&key).copied().unwrap_or(0);
|
||||
if have < burn_qty {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient {key} to burn: have {have}, need {burn_qty}"
|
||||
)));
|
||||
}
|
||||
*input_assets.entry(key).or_insert(0) -= burn_qty;
|
||||
}
|
||||
}
|
||||
|
||||
// Build dest assets: minted (positive only) + extras forwarded.
|
||||
let mut dest_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
||||
for ma in args.mint_assets {
|
||||
if ma.quantity > 0 {
|
||||
let key = asset_key(&policy_id_hex, &ma.asset_name_hex);
|
||||
*dest_assets.entry(key).or_insert(0) = dest_assets
|
||||
.get(&asset_key(&policy_id_hex, &ma.asset_name_hex))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(ma.quantity as u64);
|
||||
}
|
||||
}
|
||||
for (k, q) in &needed_extras {
|
||||
*dest_assets.entry(k.clone()).or_insert(0) =
|
||||
dest_assets.get(k).copied().unwrap_or(0).saturating_add(*q);
|
||||
}
|
||||
|
||||
// Change assets = input_assets minus dest extras (mint doesn't
|
||||
// come from inputs).
|
||||
let mut change_assets: std::collections::BTreeMap<String, u64> = input_assets.clone();
|
||||
for (k, q) in &needed_extras {
|
||||
let cur = change_assets.get(k).copied().unwrap_or(0);
|
||||
if cur < *q {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"internal: dest extra exceeds available input asset for {k}"
|
||||
)));
|
||||
}
|
||||
*change_assets.entry(k.clone()).or_insert(0) = cur - *q;
|
||||
}
|
||||
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let funding_inputs: Vec<Input> = funding
|
||||
.iter()
|
||||
.map(|u| -> Result<_, WalletError> {
|
||||
Ok(Input::new(
|
||||
parse_tx_hash(&u.tx_hash_hex)?,
|
||||
u.output_index as u64,
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for inp in &funding_inputs {
|
||||
staging = staging.input(inp.clone());
|
||||
}
|
||||
staging = staging.collateral_input(collateral_input.clone());
|
||||
|
||||
// Dest output.
|
||||
let mut dest_out = Output::new(dest_addr.clone(), args.dest_lovelace);
|
||||
for (k, q) in &dest_assets {
|
||||
if *q == 0 {
|
||||
continue;
|
||||
}
|
||||
let pol_hex = &k[..56];
|
||||
let name_hex = &k[56..];
|
||||
let p = parse_policy_id(pol_hex)?;
|
||||
let n = parse_asset_name(name_hex)?;
|
||||
dest_out = dest_out
|
||||
.add_asset(p, n, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("dest add_asset: {e}")))?;
|
||||
}
|
||||
if let Some(d) = args.dest_inline_datum_cbor {
|
||||
dest_out = dest_out.set_inline_datum(d.to_vec());
|
||||
}
|
||||
staging = staging.output(dest_out);
|
||||
|
||||
// Change output (only if needed).
|
||||
let nonzero_change: std::collections::BTreeMap<String, u64> = change_assets
|
||||
.iter()
|
||||
.filter(|(_, q)| **q > 0)
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
if change_lovelace > 0 || !nonzero_change.is_empty() {
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &nonzero_change {
|
||||
let pol_hex = &k[..56];
|
||||
let name_hex = &k[56..];
|
||||
let p = parse_policy_id(pol_hex)?;
|
||||
let n = parse_asset_name(name_hex)?;
|
||||
change_out = change_out
|
||||
.add_asset(p, n, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_out);
|
||||
}
|
||||
|
||||
// Mint each asset.
|
||||
for (name_bytes, qty) in &parsed_mint_assets {
|
||||
staging = staging
|
||||
.mint_asset(policy_hash, name_bytes.clone(), *qty)
|
||||
.map_err(|e| WalletError::Derivation(format!("mint_asset: {e}")))?;
|
||||
}
|
||||
|
||||
// Inline policy script witness + redeemer.
|
||||
let kind: ScriptKind = match args.policy_version {
|
||||
PlutusVersion::V1 => ScriptKind::PlutusV1,
|
||||
PlutusVersion::V2 => ScriptKind::PlutusV2,
|
||||
PlutusVersion::V3 => ScriptKind::PlutusV3,
|
||||
};
|
||||
staging = staging
|
||||
.script(kind, args.policy_cbor.to_vec())
|
||||
.add_mint_redeemer(
|
||||
policy_hash,
|
||||
args.redeemer_cbor.to_vec(),
|
||||
Some(args.ex_units.into()),
|
||||
)
|
||||
.fee(fee)
|
||||
.network_id(network_id);
|
||||
|
||||
for pkh in args.additional_signers {
|
||||
staging = staging.disclosed_signer(*pkh);
|
||||
}
|
||||
|
||||
// Plutus V1/V2/V3 each need their cost-model wired via
|
||||
// language_view so pallas computes script_data_hash on the tx
|
||||
// body. Without it, chain rejects with PPViewHashesDontMatch.
|
||||
// Caught 2026-05-07 attempting Agora's V2 GST-policy bootstrap
|
||||
// mint on preprod — earlier code only set language_view for
|
||||
// V3 and every V2 mint hit the chain rejection.
|
||||
match args.policy_version {
|
||||
PlutusVersion::V2 => {
|
||||
staging = staging.language_view(
|
||||
kind,
|
||||
crate::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
}
|
||||
PlutusVersion::V3 => {
|
||||
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
|
||||
staging = staging.language_view(kind, cost_model.to_vec());
|
||||
}
|
||||
}
|
||||
PlutusVersion::V1 => {
|
||||
// V1 cost model not yet provided in aldabra-core. If a
|
||||
// V1 mint is ever needed, append PLUTUS_V1_COST_MODEL_PREPROD
|
||||
// to plutus_cost_models.rs and add the matching arm here.
|
||||
}
|
||||
}
|
||||
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
// Pass 1.
|
||||
let token_change = !change_assets.values().all(|v| *v == 0);
|
||||
let need_change_min = if token_change {
|
||||
params.min_utxo_lovelace
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let change_pass1 = total_in
|
||||
.checked_sub(args.dest_lovelace.saturating_add(fee_pass1))
|
||||
.filter(|c| *c >= need_change_min)
|
||||
.unwrap_or(need_change_min);
|
||||
|
||||
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
||||
let unsigned = staging1
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
||||
.tx_bytes
|
||||
.0;
|
||||
let est_signed = (unsigned.len() as u64) + WITNESS_OVERHEAD_BYTES;
|
||||
let size_fee = params.min_fee_for_size(est_signed);
|
||||
let real_fee = size_fee.saturating_add(ex_fee);
|
||||
|
||||
let outflow = args
|
||||
.dest_lovelace
|
||||
.checked_add(real_fee)
|
||||
.ok_or_else(|| WalletError::Derivation("dest_lovelace + fee overflow".into()))?;
|
||||
let (final_fee, final_change) = match total_in.checked_sub(outflow) {
|
||||
Some(c) if c >= params.min_utxo_lovelace || token_change => {
|
||||
if token_change && c < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change: change={c} lovelace, min={}",
|
||||
params.min_utxo_lovelace
|
||||
)));
|
||||
}
|
||||
(real_fee, c)
|
||||
}
|
||||
Some(c) => (
|
||||
real_fee
|
||||
.checked_add(c)
|
||||
.ok_or_else(|| WalletError::Derivation("fee + change overflow".into()))?,
|
||||
0,
|
||||
),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} dest={} fee={real_fee} (size={size_fee} + ex={ex_fee})",
|
||||
args.dest_lovelace
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let staging2 = build_with_fee(final_fee, final_change)?;
|
||||
let built = staging2
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
||||
|
||||
let summary = PaymentSummary {
|
||||
tx_hash: hash_to_hex_32(&built.tx_hash.0),
|
||||
network,
|
||||
from_address: change_address_bech32.to_string(),
|
||||
to_address: args.dest_address_bech32.to_string(),
|
||||
send_lovelace: args.dest_lovelace,
|
||||
fee_lovelace: final_fee,
|
||||
change_lovelace: final_change,
|
||||
num_inputs: funding.len(),
|
||||
send_assets: dest_assets
|
||||
.iter()
|
||||
.map(|(k, v)| AssetSpec {
|
||||
policy_id_hex: k[..56].to_string(),
|
||||
asset_name_hex: k[56..].to_string(),
|
||||
quantity: *v,
|
||||
})
|
||||
.collect(),
|
||||
change_assets: change_assets
|
||||
.iter()
|
||||
.filter(|(_, v)| **v > 0)
|
||||
.map(|(k, v)| AssetSpec {
|
||||
policy_id_hex: k[..56].to_string(),
|
||||
asset_name_hex: k[56..].to_string(),
|
||||
quantity: *v,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
Ok((built, summary))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plutus::{PlutusExUnits, DEFAULT_EX_UNITS};
|
||||
use crate::Mnemonic;
|
||||
|
||||
const ABANDON_ART: &str = concat!(
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon art",
|
||||
);
|
||||
|
||||
fn payment_from_canonical() -> PaymentKey {
|
||||
let root = Mnemonic::from_phrase(ABANDON_ART)
|
||||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap();
|
||||
crate::derive::derive_payment_key(&root, 0, 0)
|
||||
}
|
||||
|
||||
fn change_address(network: Network) -> String {
|
||||
let root = Mnemonic::from_phrase(ABANDON_ART)
|
||||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap();
|
||||
crate::derive_base_address(&root, network, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Sample preprod governor address (the one Plutarch linker
|
||||
/// produced for our preprod tTRP DAO). Used as the dest.
|
||||
const SAMPLE_GOVERNOR_ADDR: &str =
|
||||
"addr_test1wqlzsnytzs4qv0trmhvw5cuyxnxk0qjq68crn85jj4lhv7qn4wym4";
|
||||
|
||||
/// Trivial Plutus V3 minting policy (always succeeds). For tests
|
||||
/// we don't need it to actually validate; we just need the
|
||||
/// staging tx to accept it. 6 bytes minimal CBOR.
|
||||
const ALWAYS_TRUE_PLUTUS_V3_CBOR: [u8; 6] = [0x46, 0x01, 0x00, 0x00, 0x32, 0x22];
|
||||
const UNIT_REDEEMER_CBOR: [u8; 3] = [0xd8, 0x79, 0x80];
|
||||
|
||||
/// PlutusData CBOR for a minimal `Constr 0 []` (used as a stand-in
|
||||
/// for any datum in tests).
|
||||
const UNIT_DATUM_CBOR: [u8; 3] = [0xd8, 0x79, 0x80];
|
||||
|
||||
fn baseline_utxos() -> Vec<InputUtxo> {
|
||||
vec![
|
||||
// gstOutRef stand-in — small ADA-only UTxO.
|
||||
InputUtxo {
|
||||
tx_hash_hex: "deadbeef".repeat(8),
|
||||
output_index: 0,
|
||||
lovelace: 1_500_000,
|
||||
assets: Default::default(),
|
||||
},
|
||||
// Funding utxo.
|
||||
InputUtxo {
|
||||
tx_hash_hex: "cafebabe".repeat(8),
|
||||
output_index: 0,
|
||||
lovelace: 100_000_000,
|
||||
assets: Default::default(),
|
||||
},
|
||||
// Collateral candidate.
|
||||
InputUtxo {
|
||||
tx_hash_hex: "f00dface".repeat(8),
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: Default::default(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_policy() {
|
||||
let payment = payment_from_canonical();
|
||||
let utxos = baseline_utxos();
|
||||
let mint = vec![PlutusMintAsset {
|
||||
asset_name_hex: "".into(),
|
||||
quantity: 1,
|
||||
}];
|
||||
let args = PlutusMintArgs {
|
||||
required_inputs: &[utxos[0].clone()],
|
||||
policy_cbor: &[],
|
||||
policy_version: PlutusVersion::V3,
|
||||
redeemer_cbor: &UNIT_REDEEMER_CBOR,
|
||||
ex_units: DEFAULT_EX_UNITS,
|
||||
mint_assets: &mint,
|
||||
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
|
||||
dest_lovelace: 3_000_000,
|
||||
dest_extra_assets: &[],
|
||||
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
|
||||
additional_signers: &[],
|
||||
};
|
||||
let err = build_signed_plutus_mint(
|
||||
&payment,
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change_address(Network::Preprod),
|
||||
&args,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect_err("expected empty-policy rejection");
|
||||
match err {
|
||||
WalletError::Derivation(m) => assert!(m.contains("policy_cbor")),
|
||||
other => panic!("expected Derivation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_mint_assets() {
|
||||
let payment = payment_from_canonical();
|
||||
let utxos = baseline_utxos();
|
||||
let args = PlutusMintArgs {
|
||||
required_inputs: &[utxos[0].clone()],
|
||||
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
|
||||
policy_version: PlutusVersion::V3,
|
||||
redeemer_cbor: &UNIT_REDEEMER_CBOR,
|
||||
ex_units: DEFAULT_EX_UNITS,
|
||||
mint_assets: &[],
|
||||
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
|
||||
dest_lovelace: 3_000_000,
|
||||
dest_extra_assets: &[],
|
||||
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
|
||||
additional_signers: &[],
|
||||
};
|
||||
let err = build_signed_plutus_mint(
|
||||
&payment,
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change_address(Network::Preprod),
|
||||
&args,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect_err("expected empty-mint rejection");
|
||||
match err {
|
||||
WalletError::Derivation(m) => assert!(m.contains("mint_assets")),
|
||||
other => panic!("expected Derivation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_required_input_not_in_available() {
|
||||
let payment = payment_from_canonical();
|
||||
let utxos = baseline_utxos();
|
||||
let bogus_required = InputUtxo {
|
||||
tx_hash_hex: "ababab".repeat(10) + "abab",
|
||||
output_index: 7,
|
||||
lovelace: 1_500_000,
|
||||
assets: Default::default(),
|
||||
};
|
||||
let mint = vec![PlutusMintAsset {
|
||||
asset_name_hex: "".into(),
|
||||
quantity: 1,
|
||||
}];
|
||||
let args = PlutusMintArgs {
|
||||
required_inputs: std::slice::from_ref(&bogus_required),
|
||||
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
|
||||
policy_version: PlutusVersion::V3,
|
||||
redeemer_cbor: &UNIT_REDEEMER_CBOR,
|
||||
ex_units: DEFAULT_EX_UNITS,
|
||||
mint_assets: &mint,
|
||||
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
|
||||
dest_lovelace: 3_000_000,
|
||||
dest_extra_assets: &[],
|
||||
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
|
||||
additional_signers: &[],
|
||||
};
|
||||
let err = build_signed_plutus_mint(
|
||||
&payment,
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change_address(Network::Preprod),
|
||||
&args,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect_err("expected required-input-missing rejection");
|
||||
match err {
|
||||
WalletError::Derivation(m) => assert!(m.contains("required_input")),
|
||||
other => panic!("expected Derivation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn governor_bootstrap_shape_produces_cbor() {
|
||||
let payment = payment_from_canonical();
|
||||
let utxos = baseline_utxos();
|
||||
let mint = vec![PlutusMintAsset {
|
||||
asset_name_hex: "".into(), // GST asset name = empty
|
||||
quantity: 1,
|
||||
}];
|
||||
let args = PlutusMintArgs {
|
||||
required_inputs: &[utxos[0].clone()],
|
||||
policy_cbor: &ALWAYS_TRUE_PLUTUS_V3_CBOR,
|
||||
policy_version: PlutusVersion::V3,
|
||||
redeemer_cbor: &UNIT_REDEEMER_CBOR,
|
||||
ex_units: PlutusExUnits {
|
||||
mem: 5_000_000,
|
||||
steps: 5_000_000_000,
|
||||
},
|
||||
mint_assets: &mint,
|
||||
dest_address_bech32: SAMPLE_GOVERNOR_ADDR,
|
||||
dest_lovelace: 3_000_000,
|
||||
dest_extra_assets: &[],
|
||||
dest_inline_datum_cbor: Some(&UNIT_DATUM_CBOR),
|
||||
additional_signers: &[],
|
||||
};
|
||||
// V3 cost model is required for staging language_view; test
|
||||
// ProtocolParams::default() should provide one for preprod.
|
||||
// If it doesn't, we accept an early build error here (caller
|
||||
// would supply via params on a real call).
|
||||
let res = build_signed_plutus_mint(
|
||||
&payment,
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change_address(Network::Preprod),
|
||||
&args,
|
||||
&ProtocolParams::default(),
|
||||
);
|
||||
match res {
|
||||
Ok(cbor) => {
|
||||
assert!(!cbor.is_empty());
|
||||
// Conway tx CBOR starts with major-array tag 0x84 (4 elements).
|
||||
assert_eq!(cbor[0], 0x84, "expected conway tx CBOR tag prefix");
|
||||
}
|
||||
Err(WalletError::Derivation(m)) => {
|
||||
// Acceptable: cost-model-related error if default params
|
||||
// don't include V3 cost model. Just confirm we got past
|
||||
// arg validation.
|
||||
assert!(
|
||||
!m.contains("policy_cbor")
|
||||
&& !m.contains("mint_assets")
|
||||
&& !m.contains("required_input"),
|
||||
"expected args to validate clean; got {m}"
|
||||
);
|
||||
}
|
||||
Err(other) => panic!("unexpected error type: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,10 +34,7 @@ use crate::{PaymentKey, WalletError};
|
|||
|
||||
/// Append a vkeywitness from the given payment key to an existing
|
||||
/// (unsigned or partially-signed) Conway tx. Returns the new CBOR.
|
||||
pub fn add_witness(
|
||||
payment_key: &PaymentKey,
|
||||
cbor_bytes: &[u8],
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
pub fn add_witness(payment_key: &PaymentKey, cbor_bytes: &[u8]) -> Result<Vec<u8>, WalletError> {
|
||||
let mut tx = Tx::decode_fragment(cbor_bytes)
|
||||
.map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?;
|
||||
|
||||
|
|
@ -79,8 +76,8 @@ pub fn add_witness(
|
|||
witnesses.push(new_witness);
|
||||
tx.transaction_witness_set.vkeywitness = NonEmptySet::from_vec(witnesses);
|
||||
|
||||
let encoded = minicbor::to_vec(&tx)
|
||||
.map_err(|e| WalletError::Derivation(format!("encode tx: {e}")))?;
|
||||
let encoded =
|
||||
minicbor::to_vec(&tx).map_err(|e| WalletError::Derivation(format!("encode tx: {e}")))?;
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ pub fn parse_pool_id(bech32_str: &str) -> Result<Hash<28>, WalletError> {
|
|||
}
|
||||
|
||||
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
||||
pallas_addresses::Address::from_bech32(bech32)
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
pallas_addresses::Address::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
||||
|
|
@ -156,50 +155,51 @@ pub fn build_signed_stake_delegation(
|
|||
}
|
||||
}
|
||||
|
||||
let build_with_fee = |fee: u64,
|
||||
change_lovelace: u64|
|
||||
-> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &input_assets {
|
||||
if *q == 0 {
|
||||
continue;
|
||||
let build_with_fee =
|
||||
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
|
||||
let mut staging = StagingTransaction::new();
|
||||
for u in &selected {
|
||||
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
||||
}
|
||||
if k.len() < 56 {
|
||||
return Err(WalletError::Derivation(
|
||||
"asset key shorter than 56 chars".into(),
|
||||
));
|
||||
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
||||
for (k, q) in &input_assets {
|
||||
if *q == 0 {
|
||||
continue;
|
||||
}
|
||||
if k.len() < 56 {
|
||||
return Err(WalletError::Derivation(
|
||||
"asset key shorter than 56 chars".into(),
|
||||
));
|
||||
}
|
||||
let pol_hex = &k[..56];
|
||||
let name_hex = &k[56..];
|
||||
let mut policy_bytes = [0u8; 28];
|
||||
for i in 0..28 {
|
||||
policy_bytes[i] =
|
||||
u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16).map_err(|_| {
|
||||
WalletError::Derivation("invalid policy hex in asset key".into())
|
||||
})?;
|
||||
}
|
||||
let policy = Hash::<28>::new(policy_bytes);
|
||||
let mut name_bytes = Vec::with_capacity(name_hex.len() / 2);
|
||||
for i in (0..name_hex.len()).step_by(2) {
|
||||
name_bytes.push(
|
||||
u8::from_str_radix(&name_hex[i..i + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation("invalid name hex".into()))?,
|
||||
);
|
||||
}
|
||||
change_out = change_out
|
||||
.add_asset(policy, name_bytes, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
}
|
||||
let pol_hex = &k[..56];
|
||||
let name_hex = &k[56..];
|
||||
let mut policy_bytes = [0u8; 28];
|
||||
for i in 0..28 {
|
||||
policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation("invalid policy hex in asset key".into()))?;
|
||||
staging = staging.output(change_out);
|
||||
for cb in &cert_bytes_list {
|
||||
staging = staging.add_certificate(cb.clone());
|
||||
}
|
||||
let policy = Hash::<28>::new(policy_bytes);
|
||||
let mut name_bytes = Vec::with_capacity(name_hex.len() / 2);
|
||||
for i in (0..name_hex.len()).step_by(2) {
|
||||
name_bytes.push(
|
||||
u8::from_str_radix(&name_hex[i..i + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation("invalid name hex".into()))?,
|
||||
);
|
||||
}
|
||||
change_out = change_out
|
||||
.add_asset(policy, name_bytes, *q)
|
||||
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_out);
|
||||
for cb in &cert_bytes_list {
|
||||
staging = staging.add_certificate(cb.clone());
|
||||
}
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
Ok(staging)
|
||||
};
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
Ok(staging)
|
||||
};
|
||||
|
||||
// Pass 1 — measure unsigned size.
|
||||
let change_pass1 = total_in
|
||||
|
|
@ -217,11 +217,11 @@ pub fn build_signed_stake_delegation(
|
|||
let real_fee = params.min_fee_for_size(est_signed);
|
||||
|
||||
let token_change = !input_assets.is_empty();
|
||||
let final_change = total_in
|
||||
.checked_sub(deposit + real_fee)
|
||||
.ok_or_else(|| WalletError::Derivation(format!(
|
||||
let final_change = total_in.checked_sub(deposit + real_fee).ok_or_else(|| {
|
||||
WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}"
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
if final_change < params.min_utxo_lovelace && token_change {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change: change={final_change}, min={}",
|
||||
|
|
@ -258,7 +258,7 @@ pub fn build_signed_stake_delegation(
|
|||
/// body-hash signing logic is identical regardless of which key
|
||||
/// "role" the wallet considers the XPrv. Crate-internal helper —
|
||||
/// callers use `build_signed_stake_delegation` end-to-end.
|
||||
fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey {
|
||||
pub(crate) fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey {
|
||||
crate::derive::PaymentKey::from_xprv(stake_key.xprv().clone())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,21 @@
|
|||
use ed25519_bip32::XPrv;
|
||||
use pallas_addresses::Address as PallasAddress;
|
||||
use pallas_crypto::key::ed25519::SecretKeyExtended;
|
||||
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
|
||||
use pallas_txbuilder::{
|
||||
BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction,
|
||||
};
|
||||
|
||||
/// Reference-script attached to a tx output. Used to deploy Plutus
|
||||
/// validators / minting policies as reusable on-chain references so
|
||||
/// downstream txs can spend from / mint under those scripts via
|
||||
/// `--tx-in-script-file ref` semantics instead of inline-witnessing
|
||||
/// the entire CBOR every time. Each ref-script carries its language
|
||||
/// (PlutusV1/V2/V3 — Native is also valid but rare).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ReferenceScriptSpec<'a> {
|
||||
pub kind: ScriptKind,
|
||||
pub cbor: &'a [u8],
|
||||
}
|
||||
use pallas_wallet::PrivateKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
|
@ -79,6 +93,11 @@ pub struct ProtocolParams {
|
|||
/// `None`, Plutus paths skip script_data_hash and the chain will
|
||||
/// reject with `PPViewHashesDontMatch`.
|
||||
pub plutus_v3_cost_model: Option<Vec<i64>>,
|
||||
/// Conway DRep registration deposit (ledger param `drep_deposit`).
|
||||
/// Mainnet default: 500 ADA. Used by `governance::build_signed_drep_*`.
|
||||
/// Pass the chain's current value when constructing — registering
|
||||
/// with the wrong amount fails ledger validation silently.
|
||||
pub drep_deposit_lovelace: u64,
|
||||
}
|
||||
|
||||
impl Default for ProtocolParams {
|
||||
|
|
@ -95,6 +114,7 @@ impl Default for ProtocolParams {
|
|||
// or fetched from `epoch_params`. None by default keeps
|
||||
// the ada-only / mint paths zero-cost.
|
||||
plutus_v3_cost_model: None,
|
||||
drep_deposit_lovelace: 500_000_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -349,6 +369,7 @@ fn output_with_assets(
|
|||
lovelace: u64,
|
||||
assets: &std::collections::BTreeMap<String, u64>,
|
||||
inline_datum_cbor: Option<&[u8]>,
|
||||
reference_script: Option<ReferenceScriptSpec<'_>>,
|
||||
) -> Result<Output, WalletError> {
|
||||
let mut out = Output::new(addr.clone(), lovelace);
|
||||
for (key, qty) in assets {
|
||||
|
|
@ -370,6 +391,15 @@ fn output_with_assets(
|
|||
if let Some(datum) = inline_datum_cbor {
|
||||
out = out.set_inline_datum(datum.to_vec());
|
||||
}
|
||||
// 2026-05-07: optional reference-script attached to the output.
|
||||
// This is the on-chain equivalent of `cardano-cli ... --tx-out
|
||||
// --tx-out-reference-script-file ...`. Once deployed, downstream
|
||||
// txs can witness the script via `read_only_input` instead of
|
||||
// inline-witnessing the full CBOR. Required for any DAO/dApp that
|
||||
// wants to keep witness sizes manageable when validators are large.
|
||||
if let Some(rs) = reference_script {
|
||||
out = out.set_inline_script(rs.kind, rs.cbor.to_vec());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -380,6 +410,7 @@ fn build_staging_with_fee(
|
|||
to_lovelace: u64,
|
||||
to_assets: &std::collections::BTreeMap<String, u64>,
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
||||
change_addr: &PallasAddress,
|
||||
change_lovelace: u64,
|
||||
change_assets: &std::collections::BTreeMap<String, u64>,
|
||||
|
|
@ -396,6 +427,7 @@ fn build_staging_with_fee(
|
|||
to_lovelace,
|
||||
to_assets,
|
||||
to_inline_datum_cbor,
|
||||
to_reference_script,
|
||||
)?);
|
||||
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = change_assets
|
||||
.iter()
|
||||
|
|
@ -403,13 +435,15 @@ fn build_staging_with_fee(
|
|||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
||||
// Change output never carries an inline datum — it goes back to
|
||||
// the wallet, which has no validator to satisfy.
|
||||
// Change output never carries an inline datum or reference
|
||||
// script — it goes back to the wallet, which has no validator
|
||||
// to satisfy and no reason to publish a script there.
|
||||
staging = staging.output(output_with_assets(
|
||||
change_addr,
|
||||
change_lovelace,
|
||||
&nonzero_change_assets,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
}
|
||||
staging = staging.fee(fee).network_id(network_id);
|
||||
|
|
@ -456,16 +490,13 @@ pub struct UnsignedPayment {
|
|||
pub summary: PaymentSummary,
|
||||
}
|
||||
|
||||
fn build_unsigned_bytes(
|
||||
staging: StagingTransaction,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
fn build_unsigned_bytes(staging: StagingTransaction) -> Result<Vec<u8>, WalletError> {
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
||||
Ok(built.tx_bytes.0)
|
||||
}
|
||||
|
||||
|
||||
/// Internal helper — runs the two-pass fee refinement and returns
|
||||
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
||||
/// the body. Handles both ADA-only and multi-asset payments; pass
|
||||
|
|
@ -476,6 +507,13 @@ fn build_unsigned_bytes(
|
|||
/// script address with a datum the validator can read (AUDIT4-3
|
||||
/// fix). Change output never gets a datum — it goes back to the
|
||||
/// wallet which has no validator to satisfy.
|
||||
///
|
||||
/// `to_reference_script`, when `Some`, attaches the script bytes as
|
||||
/// a reference-script on the recipient output (Babbage/Conway era
|
||||
/// `--tx-out-reference-script-file`). Used to deploy a Plutus
|
||||
/// validator/policy as a reusable on-chain reference. Pairs naturally
|
||||
/// with sending to the wallet's own address — the wallet then "owns"
|
||||
/// (spends from) the ref-script UTxO any time it wants to retire it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_payment(
|
||||
network: Network,
|
||||
|
|
@ -485,6 +523,7 @@ fn prepare_payment(
|
|||
lovelace: u64,
|
||||
assets_to_send: &[AssetSpec],
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
||||
let to_addr = parse_address(to_address_bech32)?;
|
||||
|
|
@ -573,6 +612,7 @@ fn prepare_payment(
|
|||
lovelace,
|
||||
&target_assets,
|
||||
to_inline_datum_cbor,
|
||||
to_reference_script,
|
||||
&change_addr,
|
||||
change_pass1,
|
||||
&change_assets,
|
||||
|
|
@ -624,6 +664,7 @@ fn prepare_payment(
|
|||
lovelace,
|
||||
&target_assets,
|
||||
to_inline_datum_cbor,
|
||||
to_reference_script,
|
||||
&change_addr,
|
||||
final_change,
|
||||
&change_assets,
|
||||
|
|
@ -745,6 +786,42 @@ pub fn build_signed_payment_with_assets(
|
|||
assets_to_send: &[AssetSpec],
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
build_signed_payment_extras(
|
||||
payment_key,
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
to_address_bech32,
|
||||
lovelace,
|
||||
assets_to_send,
|
||||
to_inline_datum_cbor,
|
||||
None,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build + sign a Conway-era payment with the full output extras —
|
||||
/// ADA + native assets + optional inline datum + optional reference
|
||||
/// script. Public superset of `build_signed_payment_with_assets`.
|
||||
///
|
||||
/// `to_reference_script`: when `Some`, attaches the script CBOR as a
|
||||
/// reference-script on the recipient output (Babbage/Conway era).
|
||||
/// Used to deploy a Plutus validator/policy as a reusable on-chain
|
||||
/// reference. Pair with sending to the wallet's own address so the
|
||||
/// wallet retains the ability to retire the deployment later.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_signed_payment_extras(
|
||||
payment_key: &PaymentKey,
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
to_address_bech32: &str,
|
||||
lovelace: u64,
|
||||
assets_to_send: &[AssetSpec],
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let private = payment_key_to_private(payment_key)?;
|
||||
let (built, _summary) = prepare_payment(
|
||||
|
|
@ -755,6 +832,7 @@ pub fn build_signed_payment_with_assets(
|
|||
lovelace,
|
||||
assets_to_send,
|
||||
to_inline_datum_cbor,
|
||||
to_reference_script,
|
||||
params,
|
||||
)?;
|
||||
let signed = built
|
||||
|
|
@ -801,6 +879,37 @@ pub fn build_unsigned_payment_with_assets(
|
|||
assets_to_send: &[AssetSpec],
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<UnsignedPayment, WalletError> {
|
||||
build_unsigned_payment_extras(
|
||||
network,
|
||||
available_utxos,
|
||||
change_address_bech32,
|
||||
to_address_bech32,
|
||||
lovelace,
|
||||
assets_to_send,
|
||||
to_inline_datum_cbor,
|
||||
None,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a Conway-era payment with the full output extras (ADA +
|
||||
/// native assets + optional inline datum + optional reference script)
|
||||
/// without signing. Returns unsigned CBOR + `PaymentSummary`. Caller
|
||||
/// signs + submits via the cold-sign path.
|
||||
///
|
||||
/// See [`build_signed_payment_extras`] for the signed variant.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_unsigned_payment_extras(
|
||||
network: Network,
|
||||
available_utxos: &[InputUtxo],
|
||||
change_address_bech32: &str,
|
||||
to_address_bech32: &str,
|
||||
lovelace: u64,
|
||||
assets_to_send: &[AssetSpec],
|
||||
to_inline_datum_cbor: Option<&[u8]>,
|
||||
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
||||
params: &ProtocolParams,
|
||||
) -> Result<UnsignedPayment, WalletError> {
|
||||
let (built, summary) = prepare_payment(
|
||||
network,
|
||||
|
|
@ -810,6 +919,7 @@ pub fn build_unsigned_payment_with_assets(
|
|||
lovelace,
|
||||
assets_to_send,
|
||||
to_inline_datum_cbor,
|
||||
to_reference_script,
|
||||
params,
|
||||
)?;
|
||||
Ok(UnsignedPayment {
|
||||
|
|
@ -1136,7 +1246,11 @@ mod tests {
|
|||
)
|
||||
.expect("multi-asset payment builds + signs");
|
||||
// Multi-asset tx is meaningfully larger than ADA-only.
|
||||
assert!(cbor.len() > 200, "cbor unexpectedly short: {} bytes", cbor.len());
|
||||
assert!(
|
||||
cbor.len() > 200,
|
||||
"cbor unexpectedly short: {} bytes",
|
||||
cbor.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
79
crates/aldabra-dao/Cargo.toml
Normal file
79
crates/aldabra-dao/Cargo.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# aldabra-dao — Agora-on-Cardano DAO interaction.
|
||||
#
|
||||
# This crate is a community-publishable, multi-DAO client for any
|
||||
# Agora deployment. Bob's DAO and Alice's DAO are both first-class —
|
||||
# nothing is hardcoded to any single DAO.
|
||||
#
|
||||
# Layout:
|
||||
# config — per-DAO config files at $ALDABRA_DATA/daos/<name>.json
|
||||
# + .active selector. Loaded fresh on every tool call so
|
||||
# add/remove/switch take effect without daemon restart.
|
||||
# agora — Plutarch type ports (StakeDatum, ProposalDatum, etc) with
|
||||
# PlutusData encode/decode. One module per Agora module.
|
||||
# reader — Read-only Koios-backed state queries for governor /
|
||||
# stakes / proposals UTxOs. Decodes datums into typed Rust.
|
||||
# builder — Plutus tx assembly per operation (stake_create,
|
||||
# proposal_vote, etc). Each operation is its own file
|
||||
# for readability.
|
||||
# error — Crate-internal error type.
|
||||
#
|
||||
# Boundary rules:
|
||||
# - We depend on aldabra-core for crypto / signing / address ops only.
|
||||
# - We depend on aldabra-chain for raw Koios queries.
|
||||
# - We do NOT touch keys directly; signing is delegated to aldabra-core.
|
||||
# - We do NOT do MCP. The dao_* MCP tools live in aldabra-mcp.
|
||||
#
|
||||
# Why a separate crate (not just a module under aldabra-core):
|
||||
# - DAO ops are a separate auditable surface from the core wallet.
|
||||
# - Community users can depend on aldabra-dao without pulling in the
|
||||
# full MCP binary.
|
||||
# - Plutus DAO tx assembly is enough code that mixing it with raw
|
||||
# wallet sends would bloat aldabra-core past the auditability threshold.
|
||||
|
||||
[package]
|
||||
name = "aldabra-dao"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aldabra-core = { path = "../aldabra-core" }
|
||||
aldabra-chain = { path = "../aldabra-chain" }
|
||||
|
||||
# Pallas — PlutusData encode/decode + tx building + addresses.
|
||||
pallas-primitives = { workspace = true }
|
||||
pallas-codec = { workspace = true }
|
||||
pallas-crypto = { workspace = true }
|
||||
pallas-addresses = { workspace = true }
|
||||
pallas-txbuilder = { workspace = true }
|
||||
pallas-traverse = { workspace = true }
|
||||
|
||||
# Async + I/O for chain reads.
|
||||
tokio = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Serde for DaoConfig persistence + Koios JSON.
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Bech32 for parsing addresses we don't get pre-decoded.
|
||||
bech32 = "0.9"
|
||||
|
||||
# Hex for handling token names + script hashes.
|
||||
hex = "0.4"
|
||||
|
||||
# Errors.
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Logging.
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# DaoStore tests use a temp dir as the data root.
|
||||
tempfile = "3"
|
||||
# `from_slice` for round-trip CBOR tests in agora module.
|
||||
pallas-codec = { workspace = true }
|
||||
|
||||
46
crates/aldabra-dao/examples/dump_governor.rs
Normal file
46
crates/aldabra-dao/examples/dump_governor.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Dump a sample GovernorDatum as PlutusData CBOR hex.
|
||||
//!
|
||||
//! Used during preprod DAO bringup (2026-05-07) to construct the
|
||||
//! inline datum for the governor bootstrap tx. Edit the values in
|
||||
//! `main()` to your DAO's parameters and run:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --example dump_governor -p aldabra-dao --release
|
||||
//! ```
|
||||
//!
|
||||
//! Pipe the output hex into `wallet_plutus_mint_unsigned`'s
|
||||
//! `dest_inline_datum_cbor_hex` arg.
|
||||
|
||||
use aldabra_dao::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use aldabra_dao::agora::GovernorDatum;
|
||||
use pallas_codec::minicbor;
|
||||
|
||||
fn main() {
|
||||
let g = GovernorDatum {
|
||||
proposal_thresholds: ProposalThresholds {
|
||||
execute: 50,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
next_proposal_id: 0,
|
||||
proposal_timings: ProposalTimingConfig {
|
||||
// Short timings for preprod testing — flip these up for
|
||||
// any real DAO on mainnet (Sulkta uses 7d/7d/48h/24h/1h/30min).
|
||||
draft_time: 60_000,
|
||||
voting_time: 60_000,
|
||||
locking_time: 30_000,
|
||||
executing_time: 30_000,
|
||||
min_stake_voting_time: 60_000,
|
||||
voting_time_range_max_width: 30_000,
|
||||
},
|
||||
create_proposal_time_range_max_width: 30_000,
|
||||
maximum_created_proposals_per_stake: 20,
|
||||
};
|
||||
let pd = g.to_plutus_data().expect("encode GovernorDatum");
|
||||
let mut buf = Vec::new();
|
||||
minicbor::encode(&pd, &mut buf).expect("encode CBOR");
|
||||
let hex: String = buf.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
println!("{}", hex);
|
||||
}
|
||||
33
crates/aldabra-dao/examples/dump_stake.rs
Normal file
33
crates/aldabra-dao/examples/dump_stake.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! Print StakeDatum CBOR hex for a parametrized stake bootstrap.
|
||||
//!
|
||||
//! Usage: edit the values below, then
|
||||
//! cargo run --example dump_stake -p aldabra-dao --release
|
||||
//!
|
||||
//! Used during preprod DAO bringup (Track B-fast) to construct the
|
||||
//! inline datum for `wallet_plutus_mint_unsigned` calls that mint
|
||||
//! StakeST and deposit at the stakes address.
|
||||
|
||||
use aldabra_dao::agora::stake::{Credential, ProposalLock, StakeDatum};
|
||||
use pallas_codec::minicbor;
|
||||
|
||||
fn main() {
|
||||
// Edit per stake.
|
||||
let owner_pkh_hex = "4cd61bd67ed72c1cec160bf7de6103c6bddb397da6a500dc4ff805f8";
|
||||
let staked_amount: i64 = 250;
|
||||
|
||||
let owner_bytes = hex::decode(owner_pkh_hex).expect("decode pkh hex");
|
||||
assert_eq!(owner_bytes.len(), 28, "pkh must be 28 bytes");
|
||||
|
||||
let datum = StakeDatum {
|
||||
staked_amount,
|
||||
owner: Credential::PubKey(owner_bytes),
|
||||
delegated_to: None,
|
||||
locked_by: Vec::<ProposalLock>::new(),
|
||||
};
|
||||
|
||||
let pd = datum.to_plutus_data().expect("encode");
|
||||
let mut buf = Vec::new();
|
||||
minicbor::encode(&pd, &mut buf).expect("cbor");
|
||||
let hex: String = buf.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
println!("{}", hex);
|
||||
}
|
||||
272
crates/aldabra-dao/examples/repro_script_corruption.rs
Normal file
272
crates/aldabra-dao/examples/repro_script_corruption.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
//! Standalone reproducer for the large-bytestring reference-script
|
||||
//! corruption observed in pallas-txbuilder.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ALDABRA_REPRO_HEX=/path/to/governorValidator.rawhex \
|
||||
//! cargo run --example repro_script_corruption -p aldabra-dao --release
|
||||
//!
|
||||
//! The reproducer:
|
||||
//! 1. Reads a hex-encoded Plutus V2 script (rawHex) from a file.
|
||||
//! 2. Builds a minimal Conway tx with one output that carries the
|
||||
//! script as an inline reference script.
|
||||
//! 3. Calls `build_conway_raw()` to produce the tx body bytes.
|
||||
//! 4. Searches the tx body for the input script bytes verbatim. If
|
||||
//! it finds them: pallas's encode is byte-clean (bug is downstream
|
||||
//! — chain transport, Koios, MCP transport, etc). If it doesn't:
|
||||
//! pallas mutated the bytes during encoding, prints the diff.
|
||||
//!
|
||||
//! No chain query, no MCP, no JSON-RPC. Pure local serialization.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
use aldabra_core::hex_decode as aldabra_hex_decode;
|
||||
use aldabra_core::tx::build_unsigned_payment_extras;
|
||||
use aldabra_core::{InputUtxo, Network, ProtocolParams, ReferenceScriptSpec};
|
||||
use pallas_addresses::Address;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output as TxOutput, ScriptKind, StagingTransaction};
|
||||
|
||||
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
||||
let s = s.trim();
|
||||
let mut v = Vec::with_capacity(s.len() / 2);
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
let hi = (bytes[i] as char).to_digit(16).expect("invalid hex hi") as u8;
|
||||
let lo = (bytes[i + 1] as char).to_digit(16).expect("invalid hex lo") as u8;
|
||||
v.push((hi << 4) | lo);
|
||||
i += 2;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn find_subseq(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if needle.is_empty() || needle.len() > haystack.len() {
|
||||
return None;
|
||||
}
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = env::var("ALDABRA_REPRO_HEX")
|
||||
.expect("set ALDABRA_REPRO_HEX to a file containing the script hex");
|
||||
let hex = fs::read_to_string(&path).expect("read hex file");
|
||||
let trimmed = hex.trim();
|
||||
let script_bytes_local = hex_to_bytes(trimmed);
|
||||
let script_bytes_aldabra = aldabra_hex_decode(trimmed).expect("aldabra hex_decode");
|
||||
println!(
|
||||
"input hex chars: {} | local hex_to_bytes: {} bytes | aldabra hex_decode: {} bytes",
|
||||
trimmed.len(),
|
||||
script_bytes_local.len(),
|
||||
script_bytes_aldabra.len()
|
||||
);
|
||||
assert_eq!(
|
||||
script_bytes_local, script_bytes_aldabra,
|
||||
"local and aldabra decoders must agree"
|
||||
);
|
||||
let script_bytes = script_bytes_aldabra;
|
||||
println!(
|
||||
"input script: {} bytes ({} hex chars)",
|
||||
script_bytes.len(),
|
||||
trimmed.len()
|
||||
);
|
||||
|
||||
let dummy_tx_hash: Hash<32> = Hash::new([0u8; 32]);
|
||||
let input = Input::new(dummy_tx_hash, 0);
|
||||
|
||||
// A throwaway preprod testnet enterprise script address (just for
|
||||
// shape — no funds, no real chain interaction).
|
||||
let dest_addr =
|
||||
Address::from_bech32("addr_test1wptadvtl64h74jmhwuda595j40ss3rgh0p9jam0ejwgz6mcnzvusa")
|
||||
.expect("decode addr");
|
||||
|
||||
let mut output = TxOutput::new(dest_addr, 5_000_000);
|
||||
output = output.set_inline_script(ScriptKind::PlutusV2, script_bytes.clone());
|
||||
|
||||
let staging = StagingTransaction::new()
|
||||
.input(input)
|
||||
.output(output)
|
||||
.fee(2_000_000)
|
||||
.network_id(0);
|
||||
|
||||
let built = staging.build_conway_raw().expect("build_conway_raw failed");
|
||||
|
||||
let tx_bytes = built.tx_bytes.0;
|
||||
println!("built tx body: {} bytes", tx_bytes.len());
|
||||
|
||||
// Sanity: the script bytes should appear somewhere inside the tx
|
||||
// body. The output's script_ref encodes as `tag(24) bytes(...)`
|
||||
// wrapping the inner array `[2, bytes]`. The actual script bytes
|
||||
// are then nested inside that. Search for them verbatim.
|
||||
if let Some(pos) = find_subseq(&tx_bytes, &script_bytes) {
|
||||
println!(
|
||||
"✅ FOUND input script bytes verbatim at tx-body offset {}",
|
||||
pos
|
||||
);
|
||||
println!(" pallas-txbuilder serialized them clean.");
|
||||
|
||||
// BUT: check the bytes-header that precedes them. In CBOR, a
|
||||
// bytestring of length N has a leader byte of 0x40+N for N<24,
|
||||
// 0x58 + 1 length byte for N<=255, 0x59 + 2 length bytes for
|
||||
// N<=65535. For 7213, header = 0x59 0x1c 0x2d. If the header
|
||||
// claims a different length, encoding is inconsistent.
|
||||
if pos >= 3 {
|
||||
let h = &tx_bytes[pos - 3..pos];
|
||||
println!(
|
||||
" bytes-header preceding script: {:02x} {:02x} {:02x}",
|
||||
h[0], h[1], h[2]
|
||||
);
|
||||
if h[0] == 0x59 {
|
||||
let claimed_len = u16::from_be_bytes([h[1], h[2]]) as usize;
|
||||
if claimed_len == script_bytes.len() {
|
||||
println!(
|
||||
" ✅ header length {} == input length {} — consistent.",
|
||||
claimed_len,
|
||||
script_bytes.len()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" ❌ header length {} != input length {} — encoder is OFF BY {}.",
|
||||
claimed_len,
|
||||
script_bytes.len(),
|
||||
script_bytes.len() as i64 - claimed_len as i64
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" ⚠️ preceding byte not 0x59 (uint16 bytes header) — different size class?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Print the very first 100 bytes of tx body for inspection
|
||||
let preview_len = 100.min(tx_bytes.len());
|
||||
let preview: String = tx_bytes[..preview_len]
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
println!(" tx body first {} bytes: {}", preview_len, preview);
|
||||
} else {
|
||||
println!("❌ DID NOT find input script bytes verbatim in tx body.");
|
||||
println!(" pallas-txbuilder mutated the bytes during encoding.");
|
||||
// Try to locate the ApproxRegion that contains them. Search
|
||||
// for the first 64 bytes of input — if THAT prefix is found,
|
||||
// the bytes start there but corrupt later. If not, the start
|
||||
// is also mutated.
|
||||
let prefix = &script_bytes[..64.min(script_bytes.len())];
|
||||
match find_subseq(&tx_bytes, prefix) {
|
||||
Some(start) => {
|
||||
println!(
|
||||
" Found {} -byte prefix at tx-body offset {} — mutation is later in the bytestring",
|
||||
prefix.len(),
|
||||
start
|
||||
);
|
||||
let region = &tx_bytes[start..(start + script_bytes.len()).min(tx_bytes.len())];
|
||||
let mut diffs = 0usize;
|
||||
let mut first_diff = None;
|
||||
for (i, (a, b)) in script_bytes.iter().zip(region.iter()).enumerate() {
|
||||
if a != b {
|
||||
diffs += 1;
|
||||
if first_diff.is_none() {
|
||||
first_diff = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" {} byte-positions differ; first diff at byte {} of script",
|
||||
diffs,
|
||||
first_diff.map(|x| x as i32).unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
None => {
|
||||
println!(" Even the first 64 bytes don't match — corruption starts at offset 0.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ALSO try the FULL aldabra path (build_unsigned_payment_extras) ----
|
||||
println!();
|
||||
println!("=== Now testing full aldabra build_unsigned_payment_extras path ===");
|
||||
// Need a fake wallet UTxO + fake change address. Use a preprod-style
|
||||
// bech32 address for both.
|
||||
let fake_wallet_addr =
|
||||
"addr_test1qpxdvx7k0mtjc88vzc9l0hnpq0rtmkee0kn22qxufluqt793h8qx99hfs34pm5lwmkv4kga4d7zxm3gflqm8x2l6wvgs7x7wax";
|
||||
let fake_to_addr = fake_wallet_addr;
|
||||
let fake_utxo = InputUtxo {
|
||||
tx_hash_hex: "0".repeat(64),
|
||||
output_index: 0,
|
||||
lovelace: 100_000_000_000,
|
||||
assets: Default::default(),
|
||||
};
|
||||
let ref_spec = ReferenceScriptSpec {
|
||||
kind: ScriptKind::PlutusV2,
|
||||
cbor: &script_bytes,
|
||||
};
|
||||
let unsigned = build_unsigned_payment_extras(
|
||||
Network::Preprod,
|
||||
std::slice::from_ref(&fake_utxo),
|
||||
fake_wallet_addr,
|
||||
fake_to_addr,
|
||||
5_000_000,
|
||||
&[],
|
||||
None,
|
||||
Some(ref_spec),
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.expect("build_unsigned_payment_extras failed");
|
||||
let aldabra_tx_hex = unsigned.cbor_hex;
|
||||
let aldabra_tx_bytes: Vec<u8> = (0..aldabra_tx_hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&aldabra_tx_hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
println!(
|
||||
"aldabra build produced cbor of {} hex chars ({} bytes)",
|
||||
aldabra_tx_hex.len(),
|
||||
aldabra_tx_bytes.len()
|
||||
);
|
||||
if let Some(pos) = find_subseq(&aldabra_tx_bytes, &script_bytes) {
|
||||
println!("✅ aldabra path: found script bytes at offset {}", pos);
|
||||
if pos >= 3 {
|
||||
let h = &aldabra_tx_bytes[pos - 3..pos];
|
||||
println!(
|
||||
" bytes-header preceding: {:02x} {:02x} {:02x}",
|
||||
h[0], h[1], h[2]
|
||||
);
|
||||
if h[0] == 0x59 {
|
||||
let claimed = u16::from_be_bytes([h[1], h[2]]) as usize;
|
||||
println!(
|
||||
" claimed={} input_len={} {}",
|
||||
claimed,
|
||||
script_bytes.len(),
|
||||
if claimed == script_bytes.len() {
|
||||
"✅ consistent"
|
||||
} else {
|
||||
"❌ MISMATCH — encoder bug"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("❌ aldabra path: input bytes NOT verbatim in tx body");
|
||||
}
|
||||
|
||||
// Also search for the known on-chain corrupt fingerprint: at
|
||||
// bytes 2390..=2424 the on-chain version has the two 9-byte
|
||||
// blocks SWAPPED relative to input. Build the swapped version
|
||||
// and check if THAT appears in the tx body.
|
||||
if script_bytes.len() >= 2425 {
|
||||
let mut corrupted = script_bytes.clone();
|
||||
let block_a = corrupted[2390..2399].to_vec();
|
||||
let block_b = corrupted[2416..2425].to_vec();
|
||||
corrupted[2390..2399].copy_from_slice(&block_b);
|
||||
corrupted[2416..2425].copy_from_slice(&block_a);
|
||||
|
||||
if find_subseq(&tx_bytes, &corrupted).is_some() {
|
||||
println!("⚠️ found CORRUPTED variant (block-swap @ 2390↔2416) in tx body.");
|
||||
println!(" pallas-txbuilder is producing the same corruption we see on chain.");
|
||||
} else {
|
||||
println!(" block-swap variant NOT found in tx body either.");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/aldabra-dao/src/agora/authority_token.rs
Normal file
20
crates/aldabra-dao/src/agora/authority_token.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Authority Token (GAT) helpers.
|
||||
//!
|
||||
//! Mirrors [`Agora.AuthorityToken`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/AuthorityToken.hs).
|
||||
//!
|
||||
//! GATs are one-shot capability tokens minted by the governor when a
|
||||
//! proposal succeeds, and sent to that proposal's effect script(s).
|
||||
//! Each effect script consumes + burns its GAT to authorize its effect
|
||||
//! (typically: spend the treasury for a treasury-withdraw effect, or
|
||||
//! mutate the governor for a parameter-change effect).
|
||||
//!
|
||||
//! ## Phase scope
|
||||
//!
|
||||
//! - **Phase 3 (vote)**: GATs are not minted yet — proposals are still
|
||||
//! in Draft / VotingReady / Locked states. We don't touch this module.
|
||||
//! - **Phase 4 (advance + execute)**: When `ProposalRedeemer::AdvanceProposal`
|
||||
//! transitions a Locked proposal to Finished, the same tx must mint
|
||||
//! one GAT per effect script. This module will provide
|
||||
//! `build_gat_mint(...)` to assemble that.
|
||||
//!
|
||||
//! Empty for Phase 1; documentation anchor for the upcoming code.
|
||||
159
crates/aldabra-dao/src/agora/governor.rs
Normal file
159
crates/aldabra-dao/src/agora/governor.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Governor datum + redeemer.
|
||||
//!
|
||||
//! Mirrors [`Agora.Governor`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Governor.hs).
|
||||
//!
|
||||
//! Each DAO has exactly one governor UTxO at its `governor_addr`. The
|
||||
//! GovernorDatum carries the DAO's parameters (thresholds, timings) and
|
||||
//! the proposal-id counter.
|
||||
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
use crate::agora::plutus_data::{as_int, as_product, int, product};
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// `GovernorDatum` — ProductIsData → `Constr 0 [...]`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GovernorDatum {
|
||||
pub proposal_thresholds: ProposalThresholds,
|
||||
pub next_proposal_id: i64,
|
||||
pub proposal_timings: ProposalTimingConfig,
|
||||
/// `MaxTimeRangeWidth` newtype over POSIXTime — ms.
|
||||
pub create_proposal_time_range_max_width: i64,
|
||||
pub maximum_created_proposals_per_stake: i64,
|
||||
}
|
||||
|
||||
impl GovernorDatum {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
// ProductIsData → Array, NOT Constr 0.
|
||||
// Verified against Sulkta's live governor UTxO 2026-05-05.
|
||||
Ok(product(vec![
|
||||
self.proposal_thresholds.to_plutus_data()?,
|
||||
int(self.next_proposal_id as i128)?,
|
||||
self.proposal_timings.to_plutus_data()?,
|
||||
int(self.create_proposal_time_range_max_width as i128)?,
|
||||
int(self.maximum_created_proposals_per_stake as i128)?,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 5 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"GovernorDatum expects Array with 5 fields, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
Ok(GovernorDatum {
|
||||
proposal_thresholds: ProposalThresholds::from_plutus_data(&fields[0])?,
|
||||
next_proposal_id: as_int(&fields[1])? as i64,
|
||||
proposal_timings: ProposalTimingConfig::from_plutus_data(&fields[2])?,
|
||||
create_proposal_time_range_max_width: as_int(&fields[3])? as i64,
|
||||
maximum_created_proposals_per_stake: as_int(&fields[4])? as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `GovernorRedeemer` — EnumIsData (declaration order):
|
||||
/// 0=CreateProposal, 1=MintGATs, 2=MutateGovernor.
|
||||
///
|
||||
/// **Encoded as plain Integer**, not `Constr i []` — same Plutarch
|
||||
/// `EnumIsData` pattern as `ProposalStatus` (verified against Sulkta's
|
||||
/// on-chain Proposal #0 status field). No on-chain governor-redeemer
|
||||
/// has been used yet to corroborate, but consistency with ProposalStatus
|
||||
/// is overwhelming evidence. If Phase 4a's first dao_proposal_create
|
||||
/// surfaces a mismatch, swap back to constr(self as u64, vec![]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GovernorRedeemer {
|
||||
CreateProposal = 0,
|
||||
MintGATs = 1,
|
||||
MutateGovernor = 2,
|
||||
}
|
||||
|
||||
impl GovernorRedeemer {
|
||||
pub fn to_plutus_data(self) -> DaoResult<PlutusData> {
|
||||
int(self as i128)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn governor_datum_round_trip() {
|
||||
let g = GovernorDatum {
|
||||
proposal_thresholds: ProposalThresholds {
|
||||
execute: 50,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 10,
|
||||
},
|
||||
next_proposal_id: 2,
|
||||
proposal_timings: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60_000,
|
||||
voting_time_range_max_width: 60_000,
|
||||
},
|
||||
create_proposal_time_range_max_width: 60_000,
|
||||
maximum_created_proposals_per_stake: 3,
|
||||
};
|
||||
let pd = g.to_plutus_data().unwrap();
|
||||
assert_eq!(GovernorDatum::from_plutus_data(&pd).unwrap(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn governor_redeemer_encodes_as_integer() {
|
||||
// Per Plutarch EnumIsData (matches ProposalStatus shape verified
|
||||
// on chain). Plain Integer, not Constr i [].
|
||||
for (r, expected) in [
|
||||
(GovernorRedeemer::CreateProposal, 0i128),
|
||||
(GovernorRedeemer::MintGATs, 1),
|
||||
(GovernorRedeemer::MutateGovernor, 2),
|
||||
] {
|
||||
let pd = r.to_plutus_data().unwrap();
|
||||
assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode Sulkta's live governor datum from on-chain CBOR bytes and assert
|
||||
/// the resulting struct matches the README parameters.
|
||||
///
|
||||
/// This is the end-to-end Phase 0 validation: our type port matches what
|
||||
/// Plutarch actually emits.
|
||||
///
|
||||
/// Source: Koios `address_info` for `addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy`
|
||||
/// at the only governor UTxO `7c8db1432a07143eaf7755257baf8691a8e24ee8b2f3a139fa1ce222f2821c47#1`.
|
||||
/// Captured 2026-05-05.
|
||||
#[test]
|
||||
fn decodes_sulkta_live_governor_datum() {
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
let cbor_hex = "9f9f14186418640101ff019f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1a001b774014ff";
|
||||
let bytes = hex::decode(cbor_hex).unwrap();
|
||||
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
|
||||
let gov = GovernorDatum::from_plutus_data(&pd).expect("decode Sulkta governor");
|
||||
|
||||
assert_eq!(gov.proposal_thresholds.execute, 20);
|
||||
assert_eq!(gov.proposal_thresholds.create, 100);
|
||||
assert_eq!(gov.proposal_thresholds.to_voting, 100);
|
||||
assert_eq!(gov.proposal_thresholds.vote, 1);
|
||||
assert_eq!(gov.proposal_thresholds.cosign, 1);
|
||||
assert_eq!(gov.next_proposal_id, 1);
|
||||
assert_eq!(gov.proposal_timings.draft_time, 7 * 86_400 * 1000);
|
||||
assert_eq!(gov.proposal_timings.voting_time, 7 * 86_400 * 1000);
|
||||
assert_eq!(gov.proposal_timings.locking_time, 48 * 3600 * 1000);
|
||||
assert_eq!(gov.proposal_timings.executing_time, 24 * 3600 * 1000);
|
||||
assert_eq!(gov.proposal_timings.min_stake_voting_time, 60 * 60 * 1000);
|
||||
assert_eq!(
|
||||
gov.proposal_timings.voting_time_range_max_width,
|
||||
30 * 60 * 1000
|
||||
);
|
||||
assert_eq!(gov.create_proposal_time_range_max_width, 30 * 60 * 1000);
|
||||
assert_eq!(gov.maximum_created_proposals_per_stake, 20);
|
||||
}
|
||||
}
|
||||
51
crates/aldabra-dao/src/agora/mod.rs
Normal file
51
crates/aldabra-dao/src/agora/mod.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//! Agora type ports.
|
||||
//!
|
||||
//! Each submodule mirrors one Agora Haskell module
|
||||
//! ([`Agora.Stake`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Stake.hs),
|
||||
//! [`Agora.Proposal`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Proposal.hs),
|
||||
//! etc) and exposes:
|
||||
//!
|
||||
//! - Plain Rust types matching the Haskell ADTs.
|
||||
//! - `From<&T> for pallas_primitives::PlutusData` for encoding (writing
|
||||
//! datums + redeemers into transactions we build).
|
||||
//! - `TryFrom<&PlutusData> for T` for decoding (reading datums from
|
||||
//! on-chain UTxOs).
|
||||
//!
|
||||
//! ## Encoding rules
|
||||
//!
|
||||
//! Agora's Plutarch types use one of three encoding shapes:
|
||||
//!
|
||||
//! | Shape | Plutus encoding | Used for |
|
||||
//! |---------------------------|-----------------------------------|----------|
|
||||
//! | `ProductIsData T` | `Constr 0 [field0, field1, ...]` | Records (StakeDatum, GovernorDatum, ProposalDatum, ProposalThresholds, ProposalTimingConfig, ProposalLock) |
|
||||
//! | `EnumIsData T` | `Constr i []` | Plain enums (ProposalStatus) |
|
||||
//! | `makeIsDataIndexed` | `Constr i [field0, ...]` | Sum types with payload (StakeRedeemer, ProposalRedeemer, ProposalAction, GovernorRedeemer) |
|
||||
//! | newtype over `Integer` | `BigInt` | ProposalId, ResultTag, ProposalStartingTime, MaxTimeRangeWidth |
|
||||
//! | newtype over `Map k v` | `Map [(k,v)]` | ProposalVotes, ProposalEffectGroup |
|
||||
//!
|
||||
//! The Constr indices for sum-type-with-payload variants come from
|
||||
//! explicit `makeIsDataIndexed [('Foo, 0), ('Bar, 1), ...]` calls in
|
||||
//! Agora's source. We MUST match those exactly — the validator
|
||||
//! pattern-matches on Constr index and any mismatch is a flat
|
||||
//! script failure.
|
||||
//!
|
||||
//! ## Common scaffolding
|
||||
//!
|
||||
//! Each submodule has its own types and impls; this module exposes
|
||||
//! a few shared helpers ([`plutus_data`]) used across all of them.
|
||||
|
||||
pub mod governor;
|
||||
pub mod proposal;
|
||||
pub mod stake;
|
||||
pub mod treasury;
|
||||
|
||||
pub mod authority_token;
|
||||
pub mod plutus_data;
|
||||
pub mod reference_scripts;
|
||||
|
||||
pub use governor::{GovernorDatum, GovernorRedeemer};
|
||||
pub use proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig,
|
||||
ProposalVotes,
|
||||
};
|
||||
pub use stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer};
|
||||
233
crates/aldabra-dao/src/agora/plutus_data.rs
Normal file
233
crates/aldabra-dao/src/agora/plutus_data.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//! Shared PlutusData helpers for Agora type ports.
|
||||
//!
|
||||
//! Plutus's `Constr` tag encoding rule (CDDL):
|
||||
//!
|
||||
//! - For constructor index `i` ∈ `[0, 6]`: tag = `121 + i`, `any_constructor = None`.
|
||||
//! - For `i` ∈ `[7, 127]`: tag = `1280 + (i - 7)`, `any_constructor = None`.
|
||||
//! - For `i` >= 128: tag = `102`, `any_constructor = Some(i)`.
|
||||
//!
|
||||
//! All Agora sum types we care about have ≤ 4 variants, so we always
|
||||
//! land in the first branch. We still implement the general form for
|
||||
//! correctness — the cost is zero (one match arm).
|
||||
|
||||
use pallas_codec::utils::MaybeIndefArray;
|
||||
use pallas_primitives::{BigInt, BoundedBytes, Constr, PlutusData};
|
||||
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Build a `Constr i [fields...]` PlutusData with the right tag rule.
|
||||
///
|
||||
/// Returns the PlutusData ready to drop into a parent structure.
|
||||
pub fn constr(index: u64, fields: Vec<PlutusData>) -> PlutusData {
|
||||
let (tag, any_constructor) = if index <= 6 {
|
||||
(121 + index, None)
|
||||
} else if index <= 127 {
|
||||
(1280 + (index - 7), None)
|
||||
} else {
|
||||
(102, Some(index))
|
||||
};
|
||||
PlutusData::Constr(Constr {
|
||||
tag,
|
||||
any_constructor,
|
||||
fields: MaybeIndefArray::Def(fields),
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a Plutarch `ProductIsData` record — a CBOR Array, NOT a `Constr 0`.
|
||||
///
|
||||
/// **Important:** Plutarch optimizes record encodings via the `ProductIsData`
|
||||
/// pattern, which serializes as a plain CBOR list of fields rather than the
|
||||
/// generic-derived `Constr 0 [...]`. Verified against the live Sulkta
|
||||
/// GovernorDatum UTxO 2026-05-05: outer wire bytes start `9f9f...` (indefinite
|
||||
/// array of indefinite arrays) — i.e. arrays, not the `d8 79` Constr-121 tag.
|
||||
///
|
||||
/// We emit indefinite-length arrays to match Plutarch's wire output. Both
|
||||
/// definite and indefinite are accepted on decode (see [`as_array`]).
|
||||
///
|
||||
/// All Agora product datums use this encoding:
|
||||
/// - StakeDatum, ProposalLock
|
||||
/// - ProposalDatum, ProposalThresholds, ProposalTimingConfig
|
||||
/// - GovernorDatum
|
||||
///
|
||||
/// Sum-type variants (Credential, ProposalAction, redeemers, ProposalStatus)
|
||||
/// keep [`constr`] encoding — their derives are `makeIsDataIndexed` or
|
||||
/// `EnumIsData`, both of which produce `Constr i [...]`.
|
||||
pub fn product(fields: Vec<PlutusData>) -> PlutusData {
|
||||
PlutusData::Array(MaybeIndefArray::Indef(fields))
|
||||
}
|
||||
|
||||
/// Decode a `ProductIsData` record back into its field list. Alias for
|
||||
/// [`as_array`]; the separate name documents intent at call sites.
|
||||
pub fn as_product(pd: &PlutusData) -> DaoResult<&Vec<PlutusData>> {
|
||||
as_array(pd)
|
||||
}
|
||||
|
||||
/// Encode a non-negative integer as PlutusData::BigInt.
|
||||
///
|
||||
/// Agora uses `Integer` everywhere, but in practice all our numbers
|
||||
/// (lovelace, token quantity, POSIX millis) fit well inside i128. If
|
||||
/// a future field exceeds that we can swap to `BigInt::BigUInt` —
|
||||
/// none currently do.
|
||||
pub fn int(n: i128) -> DaoResult<PlutusData> {
|
||||
let i = i64::try_from(n).map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"integer {n} exceeds i64 — needs BigInt::Big{{U,N}}Int impl"
|
||||
))
|
||||
})?;
|
||||
Ok(PlutusData::BigInt(BigInt::Int(i.into())))
|
||||
}
|
||||
|
||||
/// Encode a byte string as PlutusData::BoundedBytes.
|
||||
pub fn bytes(bs: &[u8]) -> PlutusData {
|
||||
PlutusData::BoundedBytes(BoundedBytes::from(bs.to_vec()))
|
||||
}
|
||||
|
||||
// ---------- decode helpers --------------------------------------------------
|
||||
|
||||
/// Inspect a PlutusData::Constr. Returns (constructor_index, fields).
|
||||
pub fn as_constr(pd: &PlutusData) -> DaoResult<(u64, &Vec<PlutusData>)> {
|
||||
match pd {
|
||||
PlutusData::Constr(c) => {
|
||||
let idx = if let Some(i) = c.any_constructor {
|
||||
i
|
||||
} else if (121..=127).contains(&c.tag) {
|
||||
c.tag - 121
|
||||
} else if (1280..=1400).contains(&c.tag) {
|
||||
c.tag - 1280 + 7
|
||||
} else {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"unknown Constr tag {} (no any_constructor)",
|
||||
c.tag
|
||||
)));
|
||||
};
|
||||
let (MaybeIndefArray::Def(ref fields) | MaybeIndefArray::Indef(ref fields)) = c.fields;
|
||||
Ok((idx, fields))
|
||||
}
|
||||
other => Err(DaoError::Datum(format!("expected Constr, got {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a PlutusData integer into i128.
|
||||
pub fn as_int(pd: &PlutusData) -> DaoResult<i128> {
|
||||
match pd {
|
||||
PlutusData::BigInt(BigInt::Int(i)) => {
|
||||
// pallas's BigInt::Int wraps a `minicbor::data::Int`. Convert via
|
||||
// i128 (Int → i128 is total since CBOR spec encodes [-2^64, 2^64-1]
|
||||
// — minicbor's Int can't exceed i128's range).
|
||||
Ok(i128::from(*i))
|
||||
}
|
||||
PlutusData::BigInt(BigInt::BigUInt(b)) => {
|
||||
let bs = b.as_slice();
|
||||
if bs.len() > 16 {
|
||||
return Err(DaoError::Datum("BigUInt exceeds 128 bits".into()));
|
||||
}
|
||||
let mut buf = [0u8; 16];
|
||||
buf[16 - bs.len()..].copy_from_slice(bs);
|
||||
Ok(i128::from_be_bytes(buf).max(0))
|
||||
}
|
||||
PlutusData::BigInt(BigInt::BigNInt(b)) => {
|
||||
let bs = b.as_slice();
|
||||
if bs.len() > 16 {
|
||||
return Err(DaoError::Datum("BigNInt exceeds 128 bits".into()));
|
||||
}
|
||||
let mut buf = [0u8; 16];
|
||||
buf[16 - bs.len()..].copy_from_slice(bs);
|
||||
// Plutus BigNInt is "negative of (n+1)" per CBOR convention.
|
||||
let n = i128::from_be_bytes(buf);
|
||||
Ok(-n - 1)
|
||||
}
|
||||
other => Err(DaoError::Datum(format!("expected BigInt, got {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a PlutusData::BoundedBytes into a Vec<u8>.
|
||||
pub fn as_bytes(pd: &PlutusData) -> DaoResult<Vec<u8>> {
|
||||
match pd {
|
||||
PlutusData::BoundedBytes(b) => {
|
||||
// `BoundedBytes` impls `AsRef<Vec<u8>>` AND `AsRef<[u8]>`, so we
|
||||
// pin the slice variant explicitly to disambiguate.
|
||||
let bs: &[u8] = b.as_ref();
|
||||
Ok(bs.to_vec())
|
||||
}
|
||||
other => Err(DaoError::Datum(format!(
|
||||
"expected BoundedBytes, got {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a PlutusData::Array (works for both Def and Indef encodings).
|
||||
pub fn as_array(pd: &PlutusData) -> DaoResult<&Vec<PlutusData>> {
|
||||
match pd {
|
||||
PlutusData::Array(MaybeIndefArray::Def(v))
|
||||
| PlutusData::Array(MaybeIndefArray::Indef(v)) => Ok(v),
|
||||
other => Err(DaoError::Datum(format!("expected Array, got {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a PlutusData::Map into a vector of (key, value) refs, preserving order.
|
||||
pub fn as_map(pd: &PlutusData) -> DaoResult<Vec<(&PlutusData, &PlutusData)>> {
|
||||
match pd {
|
||||
PlutusData::Map(kvp) => Ok(kvp.iter().map(|(k, v)| (k, v)).collect()),
|
||||
other => Err(DaoError::Datum(format!("expected Map, got {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constr_low_index_uses_121_plus_i() {
|
||||
let pd = constr(0, vec![]);
|
||||
if let PlutusData::Constr(c) = pd {
|
||||
assert_eq!(c.tag, 121);
|
||||
assert!(c.any_constructor.is_none());
|
||||
} else {
|
||||
panic!("expected Constr");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constr_index_6_is_127() {
|
||||
let pd = constr(6, vec![]);
|
||||
if let PlutusData::Constr(c) = pd {
|
||||
assert_eq!(c.tag, 127);
|
||||
} else {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constr_index_7_uses_1280_path() {
|
||||
let pd = constr(7, vec![]);
|
||||
if let PlutusData::Constr(c) = pd {
|
||||
assert_eq!(c.tag, 1280);
|
||||
} else {
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_constr_index() {
|
||||
for i in [0u64, 1, 5, 6, 7, 8, 100, 127] {
|
||||
let pd = constr(i, vec![]);
|
||||
let (decoded, _fields) = as_constr(&pd).unwrap();
|
||||
assert_eq!(decoded, i, "round-trip Constr index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_int() {
|
||||
for n in [0i128, 1, -1, 100, -100, i64::MAX as i128, i64::MIN as i128] {
|
||||
let pd = int(n).unwrap();
|
||||
assert_eq!(as_int(&pd).unwrap(), n, "round-trip int {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_bytes() {
|
||||
let bs: &[u8] = &[0xde, 0xad, 0xbe, 0xef];
|
||||
let pd = bytes(bs);
|
||||
assert_eq!(as_bytes(&pd).unwrap(), bs);
|
||||
}
|
||||
}
|
||||
391
crates/aldabra-dao/src/agora/proposal.rs
Normal file
391
crates/aldabra-dao/src/agora/proposal.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! Proposal datum + redeemer.
|
||||
//!
|
||||
//! Mirrors [`Agora.Proposal`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Proposal.hs).
|
||||
//!
|
||||
//! ## Effects map (deferred)
|
||||
//!
|
||||
//! `ProposalDatum.effects` is a `Map ResultTag (Map ScriptHash ProposalEffectMetadata)`.
|
||||
//! Effect metadata isn't needed for **voting** (Phase 3) — only for
|
||||
//! **proposal creation** (Phase 4) and **proposal advance** (when minting GATs).
|
||||
//! For Phase 1/3 we decode it into a typed shape but treat it as opaque
|
||||
//! when re-encoding (round-trip preserves bytes via PlutusData).
|
||||
//!
|
||||
//! Concretely we expose [`ProposalDatum::effects_raw`] as the raw
|
||||
//! PlutusData. Phase 4 will replace it with a typed `EffectsMap`.
|
||||
|
||||
use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray};
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
use crate::agora::plutus_data::{as_array, as_int, as_map, as_product, constr, int, product};
|
||||
use crate::agora::stake::Credential;
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// `data ProposalStatus = Draft | VotingReady | Locked | Finished`
|
||||
/// via `EnumIsData` → **plain `Integer`** (NOT `Constr i []`).
|
||||
///
|
||||
/// **Encoding correction 2026-05-05:** initial Phase 0 spec assumed
|
||||
/// `EnumIsData` produces `Constr i []`. Real on-chain proposal #0 has
|
||||
/// status field encoded as bare `BigInt(3)` (CBOR `03`). Plutarch's
|
||||
/// `EnumIsData` actually emits Integer-as-index in this Agora version.
|
||||
/// Correction verified by audit-sulkta-agora-2026-05-05.md.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProposalStatus {
|
||||
Draft = 0,
|
||||
VotingReady = 1,
|
||||
Locked = 2,
|
||||
Finished = 3,
|
||||
}
|
||||
|
||||
impl ProposalStatus {
|
||||
pub fn to_plutus_data(self) -> DaoResult<PlutusData> {
|
||||
int(self as i128)
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let n = as_int(pd)?;
|
||||
Ok(match n {
|
||||
0 => ProposalStatus::Draft,
|
||||
1 => ProposalStatus::VotingReady,
|
||||
2 => ProposalStatus::Locked,
|
||||
3 => ProposalStatus::Finished,
|
||||
other => {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalStatus expects integer 0..=3, got {other}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProposalThresholds` — ProductIsData → `Constr 0 [execute, create, toVoting, vote, cosign]`.
|
||||
///
|
||||
/// All fields are `Tagged GTTag Integer` upstream but on the wire they're plain Integer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposalThresholds {
|
||||
pub execute: i64,
|
||||
pub create: i64,
|
||||
pub to_voting: i64,
|
||||
pub vote: i64,
|
||||
pub cosign: i64,
|
||||
}
|
||||
|
||||
impl ProposalThresholds {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
// ProductIsData → Array.
|
||||
Ok(product(vec![
|
||||
int(self.execute as i128)?,
|
||||
int(self.create as i128)?,
|
||||
int(self.to_voting as i128)?,
|
||||
int(self.vote as i128)?,
|
||||
int(self.cosign as i128)?,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 5 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalThresholds expects Array with 5 fields, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
Ok(ProposalThresholds {
|
||||
execute: as_int(&fields[0])? as i64,
|
||||
create: as_int(&fields[1])? as i64,
|
||||
to_voting: as_int(&fields[2])? as i64,
|
||||
vote: as_int(&fields[3])? as i64,
|
||||
cosign: as_int(&fields[4])? as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProposalTimingConfig` — ProductIsData → `Constr 0 [...]`.
|
||||
///
|
||||
/// All fields are `POSIXTime` (milliseconds) except `votingTimeRangeMaxWidth`
|
||||
/// which is `MaxTimeRangeWidth` (newtype over POSIXTime); on the wire both
|
||||
/// encode as plain Integer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposalTimingConfig {
|
||||
pub draft_time: i64,
|
||||
pub voting_time: i64,
|
||||
pub locking_time: i64,
|
||||
pub executing_time: i64,
|
||||
pub min_stake_voting_time: i64,
|
||||
pub voting_time_range_max_width: i64,
|
||||
}
|
||||
|
||||
impl ProposalTimingConfig {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(product(vec![
|
||||
int(self.draft_time as i128)?,
|
||||
int(self.voting_time as i128)?,
|
||||
int(self.locking_time as i128)?,
|
||||
int(self.executing_time as i128)?,
|
||||
int(self.min_stake_voting_time as i128)?,
|
||||
int(self.voting_time_range_max_width as i128)?,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 6 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalTimingConfig expects Array with 6 fields, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
Ok(ProposalTimingConfig {
|
||||
draft_time: as_int(&fields[0])? as i64,
|
||||
voting_time: as_int(&fields[1])? as i64,
|
||||
locking_time: as_int(&fields[2])? as i64,
|
||||
executing_time: as_int(&fields[3])? as i64,
|
||||
min_stake_voting_time: as_int(&fields[4])? as i64,
|
||||
voting_time_range_max_width: as_int(&fields[5])? as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProposalVotes = Map ResultTag Integer` — newtype over Map; encodes as a Plutus Map.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposalVotes(pub Vec<(i64, i64)>);
|
||||
|
||||
impl ProposalVotes {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
let pairs = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|(k, v)| Ok((int(*k as i128)?, int(*v as i128)?)))
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(PlutusData::Map(KeyValuePairs::from(pairs)))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let entries = as_map(pd)?;
|
||||
let out = entries
|
||||
.into_iter()
|
||||
.map(|(k, v)| Ok((as_int(k)? as i64, as_int(v)? as i64)))
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(ProposalVotes(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProposalDatum` — ProductIsData → `Constr 0 [...]`.
|
||||
///
|
||||
/// `effects` and `cosigners` are kept as raw PlutusData / typed Vec for
|
||||
/// Phase 1 reads. For Phase 4 (proposal create) we'll replace `effects_raw`
|
||||
/// with a typed `EffectsMap`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProposalDatum {
|
||||
pub proposal_id: i64,
|
||||
/// Map ResultTag (Map ScriptHash ProposalEffectMetadata).
|
||||
/// Opaque for Phase 1; kept as PlutusData for round-trip integrity.
|
||||
pub effects_raw: PlutusData,
|
||||
pub status: ProposalStatus,
|
||||
pub cosigners: Vec<Credential>,
|
||||
pub thresholds: ProposalThresholds,
|
||||
pub votes: ProposalVotes,
|
||||
pub timing_config: ProposalTimingConfig,
|
||||
/// `ProposalStartingTime` newtype → encodes as plain Integer (POSIXTime ms).
|
||||
pub starting_time: i64,
|
||||
}
|
||||
|
||||
impl ProposalDatum {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
let cosigners_pd: Vec<PlutusData> =
|
||||
self.cosigners.iter().map(|c| c.to_plutus_data()).collect();
|
||||
Ok(product(vec![
|
||||
int(self.proposal_id as i128)?,
|
||||
self.effects_raw.clone(),
|
||||
self.status.to_plutus_data()?,
|
||||
PlutusData::Array(MaybeIndefArray::Indef(cosigners_pd)),
|
||||
self.thresholds.to_plutus_data()?,
|
||||
self.votes.to_plutus_data()?,
|
||||
self.timing_config.to_plutus_data()?,
|
||||
int(self.starting_time as i128)?,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 8 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalDatum expects Array with 8 fields, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
Ok(ProposalDatum {
|
||||
proposal_id: as_int(&fields[0])? as i64,
|
||||
effects_raw: fields[1].clone(),
|
||||
status: ProposalStatus::from_plutus_data(&fields[2])?,
|
||||
cosigners: as_array(&fields[3])?
|
||||
.iter()
|
||||
.map(Credential::from_plutus_data)
|
||||
.collect::<DaoResult<Vec<_>>>()?,
|
||||
thresholds: ProposalThresholds::from_plutus_data(&fields[4])?,
|
||||
votes: ProposalVotes::from_plutus_data(&fields[5])?,
|
||||
timing_config: ProposalTimingConfig::from_plutus_data(&fields[6])?,
|
||||
starting_time: as_int(&fields[7])? as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Test if `as_constr` is the right call (sum type) vs `as_product` (record).
|
||||
/// Documented for new contributors — Plutarch's two encoding shapes look
|
||||
/// identical from the Rust struct side but produce very different CBOR.
|
||||
#[allow(dead_code)]
|
||||
fn _shape_dispatch_doc() {}
|
||||
|
||||
/// `ProposalRedeemer` — `makeIsDataIndexed`:
|
||||
/// 0=Vote ResultTag, 1=Cosign, 2=UnlockStake, 3=AdvanceProposal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProposalRedeemer {
|
||||
Vote(i64),
|
||||
Cosign,
|
||||
UnlockStake,
|
||||
AdvanceProposal,
|
||||
}
|
||||
|
||||
impl ProposalRedeemer {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(match self {
|
||||
ProposalRedeemer::Vote(tag) => constr(0, vec![int(*tag as i128)?]),
|
||||
ProposalRedeemer::Cosign => constr(1, vec![]),
|
||||
ProposalRedeemer::UnlockStake => constr(2, vec![]),
|
||||
ProposalRedeemer::AdvanceProposal => constr(3, vec![]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proposal_status_encodes_as_integer() {
|
||||
// Per Plutarch EnumIsData in this Agora version (verified against
|
||||
// Sulkta's Proposal #0 on chain): plain Integer, NOT Constr i [].
|
||||
for (s, expected) in [
|
||||
(ProposalStatus::Draft, 0i128),
|
||||
(ProposalStatus::VotingReady, 1),
|
||||
(ProposalStatus::Locked, 2),
|
||||
(ProposalStatus::Finished, 3),
|
||||
] {
|
||||
let pd = s.to_plutus_data().unwrap();
|
||||
assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", s);
|
||||
assert_eq!(ProposalStatus::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_round_trip() {
|
||||
let t = ProposalThresholds {
|
||||
execute: 50,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 10,
|
||||
};
|
||||
let pd = t.to_plutus_data().unwrap();
|
||||
assert_eq!(ProposalThresholds::from_plutus_data(&pd).unwrap(), t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_round_trip() {
|
||||
let tc = ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60_000,
|
||||
voting_time_range_max_width: 60_000,
|
||||
};
|
||||
let pd = tc.to_plutus_data().unwrap();
|
||||
assert_eq!(ProposalTimingConfig::from_plutus_data(&pd).unwrap(), tc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn votes_round_trip() {
|
||||
let v = ProposalVotes(vec![(0, 50), (1, 0)]);
|
||||
let pd = v.to_plutus_data().unwrap();
|
||||
assert_eq!(ProposalVotes::from_plutus_data(&pd).unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_redeemer_indices() {
|
||||
use crate::agora::plutus_data::as_constr;
|
||||
for (r, i) in [
|
||||
(ProposalRedeemer::Vote(0), 0u64),
|
||||
(ProposalRedeemer::Cosign, 1),
|
||||
(ProposalRedeemer::UnlockStake, 2),
|
||||
(ProposalRedeemer::AdvanceProposal, 3),
|
||||
] {
|
||||
let pd = r.to_plutus_data().unwrap();
|
||||
let (idx, _) = as_constr(&pd).unwrap();
|
||||
assert_eq!(idx, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode Sulkta's Proposal #0 from on-chain bytes. Real-world
|
||||
/// regression for the type port — same role as the GovernorDatum
|
||||
/// live-decode test, but for a proposal.
|
||||
///
|
||||
/// Source: Koios `address_info` for proposal validator address
|
||||
/// `addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40`,
|
||||
/// only UTxO at `0823a9406da1...#0`. Captured 2026-05-05.
|
||||
#[test]
|
||||
fn decodes_sulkta_live_proposal_zero() {
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
let cbor_hex = "9f00a200a001a1581c92b7725bb0c7c06083af729d38f5589c2f85f16c83fe48860399e96f9f5820046dff6c96199a55715c65b9ae62c3be1b6600038cc3116eeb20886a7d66e83cd87a80ff039fd8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffff9f14186418640101ffa2000001009f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1b0000019c7d5c4d17ff";
|
||||
let bytes = hex::decode(cbor_hex).unwrap();
|
||||
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
|
||||
let prop = ProposalDatum::from_plutus_data(&pd).expect("decode Proposal #0");
|
||||
|
||||
assert_eq!(prop.proposal_id, 0);
|
||||
assert_eq!(prop.status, ProposalStatus::Finished);
|
||||
assert_eq!(prop.cosigners.len(), 1);
|
||||
// Cobb's pkh
|
||||
assert!(matches!(
|
||||
&prop.cosigners[0],
|
||||
Credential::PubKey(h) if hex::encode(h) == "c5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfda"
|
||||
));
|
||||
assert_eq!(prop.thresholds.execute, 20);
|
||||
assert_eq!(prop.thresholds.create, 100);
|
||||
assert_eq!(prop.thresholds.vote, 1);
|
||||
assert_eq!(prop.votes.0, vec![(0, 0), (1, 0)]); // zero votes ever cast
|
||||
assert_eq!(prop.timing_config.draft_time, 7 * 86_400 * 1000);
|
||||
assert_eq!(prop.timing_config.voting_time, 7 * 86_400 * 1000);
|
||||
// Decoded from CBOR `1b 0000019c7d5c4d17` = 1771629726999 ms = 2026-04-21 15:42:06 UTC
|
||||
assert_eq!(prop.starting_time, 1_771_629_726_999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_datum_round_trip_minimal() {
|
||||
let pd_unit = constr(0, vec![]); // opaque effects placeholder
|
||||
let datum = ProposalDatum {
|
||||
proposal_id: 1,
|
||||
effects_raw: pd_unit,
|
||||
status: ProposalStatus::Draft,
|
||||
cosigners: vec![Credential::PubKey(vec![0u8; 28])],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 50,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 10,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 1,
|
||||
voting_time: 2,
|
||||
locking_time: 3,
|
||||
executing_time: 4,
|
||||
min_stake_voting_time: 5,
|
||||
voting_time_range_max_width: 6,
|
||||
},
|
||||
starting_time: 1_700_000_000_000,
|
||||
};
|
||||
let pd = datum.to_plutus_data().unwrap();
|
||||
assert_eq!(ProposalDatum::from_plutus_data(&pd).unwrap(), datum);
|
||||
}
|
||||
}
|
||||
37
crates/aldabra-dao/src/agora/reference_scripts.rs
Normal file
37
crates/aldabra-dao/src/agora/reference_scripts.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! Locate Agora reference-script UTxOs on chain.
|
||||
//!
|
||||
//! Agora's compiled validators and minting policies are too big to inline
|
||||
//! in every transaction (~16 KB each). Cardano's solution is **reference
|
||||
//! inputs**: a UTxO can carry a `reference_script` payload, and any other
|
||||
//! tx can cite that UTxO to use its script bytes without paying the size
|
||||
//! cost again.
|
||||
//!
|
||||
//! For Sulkta's DAO (and any other Agora deployment), the reference UTxOs
|
||||
//! sit at the shared stakes script address: 268+ UTxOs each carrying a
|
||||
//! different Agora-related script in `reference_script`. We need to find:
|
||||
//!
|
||||
//! - The stake validator (lives at `stakes_addr`'s payment-credential)
|
||||
//! - The proposal validator (lives at the proposal script address — derived
|
||||
//! from same Agora deployment)
|
||||
//! - The governor validator (lives at `governor_addr`'s payment-cred)
|
||||
//! - The treasury validator (lives at `treasury_addr`'s payment-cred)
|
||||
//! - The stake-state-thread minting policy (parameterized by gov token)
|
||||
//! - The proposal-state-thread minting policy
|
||||
//! - The GAT minting policy
|
||||
//!
|
||||
//! ## Compute-ourselves discovery (Cobb's pick 2026-05-05)
|
||||
//!
|
||||
//! Per the spec, we don't trust MLabs's published registry. Instead:
|
||||
//!
|
||||
//! 1. Decode each contract address (governor / stakes / treasury) to
|
||||
//! extract its payment-credential script hash.
|
||||
//! 2. Query Koios for all UTxOs at the stakes_addr (the shared deploy spot).
|
||||
//! 3. For each UTxO with a `reference_script.hash`, match against:
|
||||
//! - The 4 validator script hashes from step 1
|
||||
//! - The 3 minting-policy script hashes (computed in Phase 4 from
|
||||
//! Agora source CBOR bytes; for Phase 1 reads we don't need them)
|
||||
//! 4. Cache the (script_purpose, ref_utxo) mapping in memory; refresh on
|
||||
//! cache miss.
|
||||
//!
|
||||
//! This module is empty for Phase 1 (read-only doesn't need refs).
|
||||
//! Phase 2 (stake create) and Phase 3 (vote) need it; populated then.
|
||||
434
crates/aldabra-dao/src/agora/stake.rs
Normal file
434
crates/aldabra-dao/src/agora/stake.rs
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
//! Stake datum + redeemer.
|
||||
//!
|
||||
//! Mirrors [`Agora.Stake`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Stake.hs).
|
||||
//!
|
||||
//! On chain, each user's stake is one UTxO at the stakes script address
|
||||
//! holding:
|
||||
//!
|
||||
//! - The locked governance tokens (in the value).
|
||||
//! - A `StakeDatum` (inline datum) carrying owner / delegation / vote-locks.
|
||||
//! - A "stake state thread" token from the Agora stake-policy minting policy
|
||||
//! (proves the UTxO is a real stake, not someone sending TRP to the
|
||||
//! address by accident).
|
||||
//!
|
||||
//! This module is the type port + encode/decode only. Tx assembly lives
|
||||
//! in [`crate::builder`].
|
||||
|
||||
use pallas_codec::utils::MaybeIndefArray;
|
||||
use pallas_primitives::PlutusData;
|
||||
|
||||
use crate::agora::plutus_data::{
|
||||
as_array, as_bytes, as_constr, as_int, as_product, bytes, constr, int, product,
|
||||
};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Cardano credential — either a public-key hash or a script hash.
|
||||
///
|
||||
/// Mirrors `PlutusLedgerApi.V1.Credential.Credential`. Encoding:
|
||||
///
|
||||
/// - `PubKeyCredential pkh` → `Constr 0 [bytes pkh]`
|
||||
/// - `ScriptCredential sh` → `Constr 1 [bytes sh]`
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Credential {
|
||||
PubKey(Vec<u8>),
|
||||
Script(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Credential {
|
||||
pub fn to_plutus_data(&self) -> PlutusData {
|
||||
match self {
|
||||
Credential::PubKey(h) => constr(0, vec![bytes(h)]),
|
||||
Credential::Script(h) => constr(1, vec![bytes(h)]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let (idx, fields) = as_constr(pd)?;
|
||||
if fields.len() != 1 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"Credential expects 1 field, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let h = as_bytes(&fields[0])?;
|
||||
match idx {
|
||||
0 => Ok(Credential::PubKey(h)),
|
||||
1 => Ok(Credential::Script(h)),
|
||||
other => Err(DaoError::Datum(format!(
|
||||
"Credential expects Constr 0 or 1, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the stake was used for.
|
||||
///
|
||||
/// Per `Agora.Stake.ProposalAction` — `makeIsDataIndexed` order:
|
||||
/// - `Created` → `Constr 0 []`
|
||||
/// - `Voted tag time` → `Constr 1 [int tag, int posix_time]`
|
||||
/// - `Cosigned` → `Constr 2 []`
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProposalAction {
|
||||
Created,
|
||||
Voted { result_tag: i64, posix_time: i64 },
|
||||
Cosigned,
|
||||
}
|
||||
|
||||
impl ProposalAction {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(match self {
|
||||
ProposalAction::Created => constr(0, vec![]),
|
||||
ProposalAction::Voted {
|
||||
result_tag,
|
||||
posix_time,
|
||||
} => constr(
|
||||
1,
|
||||
vec![int(*result_tag as i128)?, int(*posix_time as i128)?],
|
||||
),
|
||||
ProposalAction::Cosigned => constr(2, vec![]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let (idx, fields) = as_constr(pd)?;
|
||||
Ok(match idx {
|
||||
0 => {
|
||||
if !fields.is_empty() {
|
||||
return Err(DaoError::Datum(
|
||||
"ProposalAction::Created expects 0 fields".into(),
|
||||
));
|
||||
}
|
||||
ProposalAction::Created
|
||||
}
|
||||
1 => {
|
||||
if fields.len() != 2 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalAction::Voted expects 2 fields, got {}",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let result_tag = as_int(&fields[0])? as i64;
|
||||
let posix_time = as_int(&fields[1])? as i64;
|
||||
ProposalAction::Voted {
|
||||
result_tag,
|
||||
posix_time,
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if !fields.is_empty() {
|
||||
return Err(DaoError::Datum(
|
||||
"ProposalAction::Cosigned expects 0 fields".into(),
|
||||
));
|
||||
}
|
||||
ProposalAction::Cosigned
|
||||
}
|
||||
other => {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalAction expects Constr 0/1/2, got {other}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in `StakeDatum.lockedBy`.
|
||||
///
|
||||
/// `ProposalLock { proposalId :: ProposalId, action :: ProposalAction }`
|
||||
/// → `Constr 0 [int proposal_id, action]` (ProductIsData encoding).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposalLock {
|
||||
pub proposal_id: i64,
|
||||
pub action: ProposalAction,
|
||||
}
|
||||
|
||||
impl ProposalLock {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
// ProductIsData → CBOR Array, NOT Constr 0.
|
||||
Ok(product(vec![
|
||||
int(self.proposal_id as i128)?,
|
||||
self.action.to_plutus_data()?,
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 2 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"ProposalLock expects Array [Int, ProposalAction], got {} fields",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let proposal_id = as_int(&fields[0])? as i64;
|
||||
let action = ProposalAction::from_plutus_data(&fields[1])?;
|
||||
Ok(ProposalLock {
|
||||
proposal_id,
|
||||
action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `StakeDatum` — the inline datum on a stake UTxO.
|
||||
///
|
||||
/// Encoding (ProductIsData → `Constr 0 [...]`):
|
||||
///
|
||||
/// `Constr 0 [int staked_amount, owner_credential, maybe_delegated_to, [proposal_locks...]]`
|
||||
///
|
||||
/// `Maybe Credential` encoding (Plutus `Maybe`):
|
||||
/// - `Just c` → `Constr 0 [c]`
|
||||
/// - `Nothing` → `Constr 1 []`
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StakeDatum {
|
||||
/// Amount of governance token (TRP) locked. Voting weight.
|
||||
pub staked_amount: i64,
|
||||
/// Stake owner; only this credential may move/destroy this stake.
|
||||
pub owner: Credential,
|
||||
/// Optional: another credential allowed to vote with this stake.
|
||||
pub delegated_to: Option<Credential>,
|
||||
/// Active vote / cosign / create locks. Must be empty to deposit/withdraw.
|
||||
pub locked_by: Vec<ProposalLock>,
|
||||
}
|
||||
|
||||
impl StakeDatum {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
// `Maybe Credential` is a sum type → Constr-encoded.
|
||||
// `Credential` itself is a sum type → Constr-encoded.
|
||||
// The outer StakeDatum is a record → ProductIsData (Array).
|
||||
let delegated_pd = match &self.delegated_to {
|
||||
Some(c) => constr(0, vec![c.to_plutus_data()]),
|
||||
None => constr(1, vec![]),
|
||||
};
|
||||
let locks_pd: Vec<PlutusData> = self
|
||||
.locked_by
|
||||
.iter()
|
||||
.map(|l| l.to_plutus_data())
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(product(vec![
|
||||
int(self.staked_amount as i128)?,
|
||||
self.owner.to_plutus_data(),
|
||||
delegated_pd,
|
||||
PlutusData::Array(MaybeIndefArray::Indef(locks_pd)),
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let fields = as_product(pd)?;
|
||||
if fields.len() != 4 {
|
||||
return Err(DaoError::Datum(format!(
|
||||
"StakeDatum expects Array [Int, Credential, Maybe Credential, [ProposalLock]], \
|
||||
got {} fields",
|
||||
fields.len()
|
||||
)));
|
||||
}
|
||||
let staked_amount = as_int(&fields[0])? as i64;
|
||||
let owner = Credential::from_plutus_data(&fields[1])?;
|
||||
let delegated_to = {
|
||||
let (j, f) = as_constr(&fields[2])?;
|
||||
match (j, f.len()) {
|
||||
(0, 1) => Some(Credential::from_plutus_data(&f[0])?),
|
||||
(1, 0) => None,
|
||||
_ => return Err(DaoError::Datum(format!(
|
||||
"Maybe<Credential> expects Constr 0[1] | 1[0], got Constr {j} with {} fields",
|
||||
f.len()
|
||||
))),
|
||||
}
|
||||
};
|
||||
let locked_by = as_array(&fields[3])?
|
||||
.iter()
|
||||
.map(ProposalLock::from_plutus_data)
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(StakeDatum {
|
||||
staked_amount,
|
||||
owner,
|
||||
delegated_to,
|
||||
locked_by,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `StakeRedeemer` — `makeIsDataIndexed` order:
|
||||
/// 0=DepositWithdraw 1=Destroy 2=PermitVote 3=RetractVotes
|
||||
/// 4=DelegateTo 5=ClearDelegate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StakeRedeemer {
|
||||
/// Deposit (positive) or withdraw (negative) GT. Stake must be unlocked.
|
||||
DepositWithdraw(i64),
|
||||
/// Destroy the stake, returning all GT to the wallet. Must be unlocked.
|
||||
Destroy,
|
||||
/// Permit a vote to be added (used in conjunction with the Proposal-Vote
|
||||
/// path; this redeemer goes on the stake input).
|
||||
PermitVote,
|
||||
/// Retract previously-cast votes from finished or in-progress proposals.
|
||||
RetractVotes,
|
||||
/// Delegate this stake's voting power to another credential.
|
||||
DelegateTo(Credential),
|
||||
/// Clear an existing delegation.
|
||||
ClearDelegate,
|
||||
}
|
||||
|
||||
impl StakeRedeemer {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
Ok(match self {
|
||||
StakeRedeemer::DepositWithdraw(n) => constr(0, vec![int(*n as i128)?]),
|
||||
StakeRedeemer::Destroy => constr(1, vec![]),
|
||||
StakeRedeemer::PermitVote => constr(2, vec![]),
|
||||
StakeRedeemer::RetractVotes => constr(3, vec![]),
|
||||
StakeRedeemer::DelegateTo(c) => constr(4, vec![c.to_plutus_data()]),
|
||||
StakeRedeemer::ClearDelegate => constr(5, vec![]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pkh() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_round_trip() {
|
||||
let c = Credential::PubKey(pkh());
|
||||
let pd = c.to_plutus_data();
|
||||
assert_eq!(Credential::from_plutus_data(&pd).unwrap(), c);
|
||||
|
||||
let s = Credential::Script(vec![0u8; 28]);
|
||||
let pd = s.to_plutus_data();
|
||||
assert_eq!(Credential::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_action_round_trip() {
|
||||
for a in [
|
||||
ProposalAction::Created,
|
||||
ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: 1_700_000_000_000,
|
||||
},
|
||||
ProposalAction::Cosigned,
|
||||
] {
|
||||
let pd = a.to_plutus_data().unwrap();
|
||||
assert_eq!(ProposalAction::from_plutus_data(&pd).unwrap(), a);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_datum_round_trip_no_locks() {
|
||||
let s = StakeDatum {
|
||||
staked_amount: 50,
|
||||
owner: Credential::PubKey(pkh()),
|
||||
delegated_to: None,
|
||||
locked_by: vec![],
|
||||
};
|
||||
let pd = s.to_plutus_data().unwrap();
|
||||
assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_datum_round_trip_with_lock_and_delegation() {
|
||||
let s = StakeDatum {
|
||||
staked_amount: 100,
|
||||
owner: Credential::PubKey(pkh()),
|
||||
delegated_to: Some(Credential::PubKey(vec![0xaa; 28])),
|
||||
locked_by: vec![
|
||||
ProposalLock {
|
||||
proposal_id: 1,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 2,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
],
|
||||
};
|
||||
let pd = s.to_plutus_data().unwrap();
|
||||
assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s);
|
||||
}
|
||||
|
||||
/// Decode Kayos's actual on-chain stake from Sulkta's stakes_addr.
|
||||
/// Anchors the StakeDatum type port to a real UTxO so a future
|
||||
/// encoding refactor can't silently break decode of existing stakes.
|
||||
///
|
||||
/// Source: Koios `address_info` for stakes addr
|
||||
/// `addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8`,
|
||||
/// utxo `d5b73a9d1e0fc4cedaf25b1172d379ad36bc39ec8516005cd70b12f9b5bdaa2f#0`.
|
||||
/// Captured 2026-05-06.
|
||||
#[test]
|
||||
fn decodes_sulkta_live_kayos_stake() {
|
||||
use pallas_primitives::PlutusData;
|
||||
let cbor_hex =
|
||||
"9f1832d8799f581c84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3ffd87a8080ff";
|
||||
let bytes = hex::decode(cbor_hex).unwrap();
|
||||
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
|
||||
let stake = StakeDatum::from_plutus_data(&pd).expect("decode Kayos stake");
|
||||
assert_eq!(stake.staked_amount, 50);
|
||||
assert!(matches!(
|
||||
&stake.owner,
|
||||
Credential::PubKey(h) if hex::encode(h) == "84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3"
|
||||
));
|
||||
assert!(stake.delegated_to.is_none());
|
||||
assert!(stake.locked_by.is_empty());
|
||||
|
||||
// Round-trip via StakeDatum. Encode our decoded stake, decode
|
||||
// the result, assert the struct survives unchanged. We CAN'T
|
||||
// assert byte-exact CBOR because pallas-codec emits def-encoded
|
||||
// arrays while chain CBOR uses indef (`9f ... ff`) — both are
|
||||
// Plutus-structurally-equal so the validator's `==` accepts
|
||||
// either. The meaningful invariant is: round-trip preserves
|
||||
// every typed field, no silent drift across encode/decode.
|
||||
let re_encoded = pallas_codec::minicbor::to_vec(stake.to_plutus_data().unwrap()).unwrap();
|
||||
let re_pd: pallas_primitives::PlutusData =
|
||||
pallas_codec::minicbor::decode(&re_encoded).unwrap();
|
||||
let round_tripped = StakeDatum::from_plutus_data(&re_pd).expect("re-decode");
|
||||
assert_eq!(round_tripped, stake, "round-trip lost a field");
|
||||
}
|
||||
|
||||
/// Same shape as `decodes_sulkta_live_kayos_stake` but for Cobb's
|
||||
/// stake (250 Terrapin). Two-witness regression — catches drift
|
||||
/// even if the Kayos test happens to flatten over a bug.
|
||||
#[test]
|
||||
fn decodes_sulkta_live_cobb_stake() {
|
||||
use pallas_primitives::PlutusData;
|
||||
let cbor_hex =
|
||||
"9f18fad8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffd87a8080ff";
|
||||
let bytes = hex::decode(cbor_hex).unwrap();
|
||||
let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap();
|
||||
let stake = StakeDatum::from_plutus_data(&pd).expect("decode Cobb stake");
|
||||
assert_eq!(stake.staked_amount, 250);
|
||||
assert!(matches!(
|
||||
&stake.owner,
|
||||
Credential::PubKey(h) if hex::encode(h) == "c5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfda"
|
||||
));
|
||||
assert!(stake.delegated_to.is_none());
|
||||
assert!(stake.locked_by.is_empty());
|
||||
|
||||
let re_encoded = pallas_codec::minicbor::to_vec(stake.to_plutus_data().unwrap()).unwrap();
|
||||
let re_pd: pallas_primitives::PlutusData =
|
||||
pallas_codec::minicbor::decode(&re_encoded).unwrap();
|
||||
let round_tripped = StakeDatum::from_plutus_data(&re_pd).expect("re-decode");
|
||||
assert_eq!(round_tripped, stake, "round-trip lost a field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stake_redeemer_indices_match_make_is_data_indexed() {
|
||||
let cases = [
|
||||
(StakeRedeemer::DepositWithdraw(0), 0),
|
||||
(StakeRedeemer::Destroy, 1),
|
||||
(StakeRedeemer::PermitVote, 2),
|
||||
(StakeRedeemer::RetractVotes, 3),
|
||||
(
|
||||
StakeRedeemer::DelegateTo(Credential::PubKey(vec![0u8; 28])),
|
||||
4,
|
||||
),
|
||||
(StakeRedeemer::ClearDelegate, 5),
|
||||
];
|
||||
for (r, expected_idx) in cases {
|
||||
let pd = r.to_plutus_data().unwrap();
|
||||
let (idx, _) = as_constr(&pd).unwrap();
|
||||
assert_eq!(idx, expected_idx, "{:?}", r);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/aldabra-dao/src/agora/treasury.rs
Normal file
14
crates/aldabra-dao/src/agora/treasury.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! Treasury — no datum, no redeemer.
|
||||
//!
|
||||
//! Per [`Agora.Treasury`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Treasury.hs):
|
||||
//! the treasury validator's only check is *"a single authority token (GAT)
|
||||
//! has been burned in this transaction"*. No datum is read, no redeemer
|
||||
//! is interpreted — the validator literally accepts any spend that
|
||||
//! satisfies that one condition.
|
||||
//!
|
||||
//! Tx-shape helpers for spending the treasury (i.e. proposal-execution)
|
||||
//! land in [`crate::builder`] when Phase 4 ships. This module is a
|
||||
//! placeholder and a documentation anchor.
|
||||
|
||||
// Intentionally empty for now; populated in Phase 4 with helpers like:
|
||||
// pub fn build_treasury_spend(...) -> DaoResult<StagingTransaction>
|
||||
25
crates/aldabra-dao/src/builder/mod.rs
Normal file
25
crates/aldabra-dao/src/builder/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Plutus tx builders for DAO operations.
|
||||
//!
|
||||
//! Each operation lives in its own submodule so signing paths are
|
||||
//! auditable in isolation. Naming convention matches the MCP tool
|
||||
//! surface: `stake_create`, `proposal_vote`, etc.
|
||||
//!
|
||||
//! Phase scope (mirrors [`crate`] phase plan):
|
||||
//!
|
||||
//! | Phase | Module | What it ships |
|
||||
//! |-------|-----------------------|---------------|
|
||||
//! | 4a | `proposal_create` | Spend governor (CreateProposal), mint ProposalST |
|
||||
//! | 4b | `proposal_cosign` | Add additional cosigner to a Draft proposal |
|
||||
//! | 3 | `proposal_vote` | Spend stake (PermitVote) + proposal (Vote tag) |
|
||||
//! | 4c | `proposal_advance` | State-machine transition redeemer |
|
||||
//! | 4d | `stake_destroy` | Spend stake (Destroy), return TRP to wallet |
|
||||
//! | 4e | `treasury_execute` | Burn GAT + spend treasury per effect datum |
|
||||
//! | def. | `stake_create` | Lock TRP at stakes script (deferred — both |
|
||||
//! | | | live wallets already have stakes) |
|
||||
|
||||
pub mod proposal_advance;
|
||||
pub mod proposal_cosign;
|
||||
pub mod proposal_create;
|
||||
pub mod proposal_retract_votes;
|
||||
pub mod proposal_vote;
|
||||
pub mod stake_destroy;
|
||||
724
crates/aldabra-dao/src/builder/proposal_advance.rs
Normal file
724
crates/aldabra-dao/src/builder/proposal_advance.rs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
//! Build a `dao_proposal_advance` transaction.
|
||||
//!
|
||||
//! State machine that drives a proposal forward through statuses:
|
||||
//!
|
||||
//! ```text
|
||||
//! Draft ─[in drafting period + cosign threshold met]─→ VotingReady
|
||||
//! Draft ─[after drafting period]─────────────────────→ Finished (failed)
|
||||
//! VotingReady ─[in locking period + winner outcome exists]─→ Locked
|
||||
//! VotingReady ─[after locking period]──────────────────────→ Finished (failed)
|
||||
//! Locked ─[after executing period, GST not moved]────→ Finished (effect-less)
|
||||
//! Locked ─[in executing period, GST moved]───────────→ Finished (executed; GAT mint
|
||||
//! happens in a separate
|
||||
//! MintGATs governor tx)
|
||||
//! ```
|
||||
//!
|
||||
//! For v1 we ship every transition EXCEPT the in-executing-period
|
||||
//! Locked→Finished path, which requires the governor MintGATs tx that
|
||||
//! mints + sends GATs to effect script addresses. That's a Phase 4c-bis
|
||||
//! follow-up — Sulkta has never executed a proposal so the GAT minting
|
||||
//! policy isn't even on chain yet.
|
||||
//!
|
||||
//! ## Tx shape (per transition)
|
||||
//!
|
||||
//! All transitions:
|
||||
//! - **Inputs**: proposal UTxO (Plutus spend, redeemer = `AdvanceProposal`)
|
||||
//! + one funding wallet UTxO.
|
||||
//! - **Collateral**: separate ADA-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**: proposal validator script.
|
||||
//! - **Outputs**: new proposal UTxO at `proposal_addr` with status
|
||||
//! mutated; rest of datum bit-exact.
|
||||
//! - **No mints**.
|
||||
//!
|
||||
//! Draft→VotingReady **also** has:
|
||||
//! - Every cosigner's stake UTxO as a **reference** input (not spent).
|
||||
//! This satisfies `witnessStakes` in the validator: it iterates
|
||||
//! `txInfo.referenceInputs`, sums every input that resolves to a
|
||||
//! StakeDatum, and verifies `sortedOwners == cosigners`.
|
||||
//!
|
||||
//! ## What the validator enforces
|
||||
//!
|
||||
//! From `Agora/Proposal/Scripts.hs` `PAdvanceProposal` (~L657):
|
||||
//!
|
||||
//! 1. Output proposal datum equals input with **only** `status` mutated.
|
||||
//! 2. Branch by current status:
|
||||
//! - **Draft**: if within drafting period, sum of cosigner stakes
|
||||
//! (from ref inputs) ≥ thresholds.toVoting AND sorted owners equal
|
||||
//! proposal.cosigners → output status = VotingReady. If after
|
||||
//! drafting period → output status = Finished.
|
||||
//! - **VotingReady**: if within locking period, `pwinner'` returns
|
||||
//! Just (= a winning ResultTag exists with votes ≥ thresholds.execute
|
||||
//! and beats neutralOption) → output status = Locked. If after
|
||||
//! locking period → output status = Finished.
|
||||
//! - **Locked**: output status = Finished. If within executing
|
||||
//! period, GST must have been moved (= governor input present);
|
||||
//! otherwise GST must NOT be moved.
|
||||
//! - **Finished**: rejected.
|
||||
//!
|
||||
//! The drafting/locking/executing period definitions:
|
||||
//!
|
||||
//! - drafting: `[starting_time, starting_time + draft_time]`
|
||||
//! - voting: `[starting_time + draft_time,
|
||||
//! starting_time + draft_time + voting_time]`
|
||||
//! - locking: `[starting_time + draft_time + voting_time,
|
||||
//! starting_time + draft_time + voting_time + locking_time]`
|
||||
//! - executing: `[starting_time + draft_time + voting_time + locking_time,
|
||||
//! starting_time + draft_time + voting_time + locking_time
|
||||
//! + executing_time]`
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig,
|
||||
ProposalVotes,
|
||||
};
|
||||
use crate::agora::stake::Credential;
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_cosign::insert_unique_sorted;
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as ADVANCE_SPEND_EX_UNITS,
|
||||
SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS,
|
||||
};
|
||||
use super::proposal_vote::ProposalUtxoIn;
|
||||
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// Which transition this advance is performing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdvanceTransition {
|
||||
DraftToVotingReady,
|
||||
DraftToFinished,
|
||||
VotingReadyToLocked,
|
||||
VotingReadyToFinished,
|
||||
LockedToFinished,
|
||||
}
|
||||
|
||||
impl AdvanceTransition {
|
||||
pub fn target_status(self) -> ProposalStatus {
|
||||
match self {
|
||||
AdvanceTransition::DraftToVotingReady => ProposalStatus::VotingReady,
|
||||
AdvanceTransition::VotingReadyToLocked => ProposalStatus::Locked,
|
||||
AdvanceTransition::DraftToFinished
|
||||
| AdvanceTransition::VotingReadyToFinished
|
||||
| AdvanceTransition::LockedToFinished => ProposalStatus::Finished,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_status(self) -> ProposalStatus {
|
||||
match self {
|
||||
AdvanceTransition::DraftToVotingReady | AdvanceTransition::DraftToFinished => {
|
||||
ProposalStatus::Draft
|
||||
}
|
||||
AdvanceTransition::VotingReadyToLocked | AdvanceTransition::VotingReadyToFinished => {
|
||||
ProposalStatus::VotingReady
|
||||
}
|
||||
AdvanceTransition::LockedToFinished => ProposalStatus::Locked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosigner stake UTxO that needs to be referenced (not spent) when
|
||||
/// advancing Draft → VotingReady. Built from on-chain `StakeUtxo` data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CosignerStakeRef {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
/// Stake's owner credential — must equal one of `proposal.cosigners`.
|
||||
pub owner: Credential,
|
||||
pub staked_amount: i64,
|
||||
}
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_advance`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalAdvanceArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub proposal: ProposalUtxoIn,
|
||||
/// Which transition to perform. Validator branches on input status,
|
||||
/// so picking the wrong one will fail. Caller computes from current
|
||||
/// status + chain-tip time.
|
||||
pub transition: AdvanceTransition,
|
||||
/// Cosigner stake refs, ONE PER cosigner in `proposal.datum.cosigners`.
|
||||
/// Only used for Draft→VotingReady; ignored for other transitions
|
||||
/// but caller should pass `vec![]` for clarity.
|
||||
pub cosigner_stake_refs: Vec<CosignerStakeRef>,
|
||||
/// Reference UTxO citing the proposal validator script.
|
||||
pub proposal_validator_ref: ReferenceUtxo,
|
||||
/// Wallet change address.
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs (funding + collateral).
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Wallet's payment-credential hash (28 bytes) — needed for the
|
||||
/// disclosed_signer; the funding utxo's vkey witness will sign.
|
||||
pub advancer_pkh: Vec<u8>,
|
||||
/// Current chain tip slot. Used as the floor for `valid_from_slot`
|
||||
/// when no caller override is supplied.
|
||||
pub tip_slot: u64,
|
||||
/// Optional explicit `valid_from_slot` override. When `None`, the
|
||||
/// builder uses `tip_slot`. Set this when the MCP layer (or any
|
||||
/// other caller) needs to clamp the range to fit inside a phase
|
||||
/// window — e.g. early Draft→VotingReady advance whose validity
|
||||
/// range must sit fully inside the drafting period rather than
|
||||
/// straddling drafting_end.
|
||||
pub valid_from_slot_override: Option<u64>,
|
||||
/// Optional explicit `invalid_from_slot` override (= validity
|
||||
/// upper-bound, exclusive). When `None`, the builder uses
|
||||
/// `tip_slot + VALIDITY_RANGE_SLOTS`. Pair with
|
||||
/// `valid_from_slot_override` for phase-clamped advances.
|
||||
pub invalid_from_slot_override: Option<u64>,
|
||||
/// Estimated total fee.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_proposal_advance`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalAdvance {
|
||||
pub tx_cbor_hex: String,
|
||||
pub tx_hash_hex: String,
|
||||
pub proposal_id: i64,
|
||||
pub from_status: ProposalStatus,
|
||||
pub to_status: ProposalStatus,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-advance tx.
|
||||
pub fn build_unsigned_proposal_advance(
|
||||
args: ProposalAdvanceArgs,
|
||||
) -> DaoResult<UnsignedProposalAdvance> {
|
||||
let proposal_id = args.proposal.datum.proposal_id;
|
||||
let from_status = args.proposal.datum.status;
|
||||
let to_status = args.transition.target_status();
|
||||
|
||||
// ---- preflight: from_status matches transition ----------------------
|
||||
|
||||
if from_status != args.transition.from_status() {
|
||||
return Err(DaoError::State(format!(
|
||||
"transition {:?} expects from-status {:?}, but proposal #{} is currently {:?}",
|
||||
args.transition,
|
||||
args.transition.from_status(),
|
||||
proposal_id,
|
||||
from_status
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- preflight: per-transition rules --------------------------------
|
||||
|
||||
match args.transition {
|
||||
AdvanceTransition::DraftToVotingReady => {
|
||||
// (i) cosigner_stake_refs count == proposal.cosigners count.
|
||||
if args.cosigner_stake_refs.len() != args.proposal.datum.cosigners.len() {
|
||||
return Err(DaoError::State(format!(
|
||||
"expected {} cosigner stake refs (one per cosigner), got {}",
|
||||
args.proposal.datum.cosigners.len(),
|
||||
args.cosigner_stake_refs.len()
|
||||
)));
|
||||
}
|
||||
// (ii) sorted owners list from refs equals proposal.cosigners
|
||||
// (already sorted unique per the cosign builder's invariant).
|
||||
// We rebuild via insert_unique_sorted so any caller that passed
|
||||
// refs in arbitrary order still gets the right comparison.
|
||||
let mut sorted_ref_owners: Vec<Credential> = Vec::new();
|
||||
for r in &args.cosigner_stake_refs {
|
||||
sorted_ref_owners = insert_unique_sorted(&sorted_ref_owners, &r.owner)?;
|
||||
}
|
||||
if sorted_ref_owners != args.proposal.datum.cosigners {
|
||||
return Err(DaoError::State("sorted cosigner-stake owners do not match proposal.cosigners exactly — \
|
||||
ref order or membership wrong".to_string()));
|
||||
}
|
||||
// (iii) sum of staked_amounts ≥ thresholds.to_voting.
|
||||
let total: i128 = args
|
||||
.cosigner_stake_refs
|
||||
.iter()
|
||||
.map(|r| r.staked_amount as i128)
|
||||
.sum();
|
||||
let thresh = args.proposal.datum.thresholds.to_voting as i128;
|
||||
if total < thresh {
|
||||
return Err(DaoError::State(format!(
|
||||
"sum of cosigner staked amounts {} < to_voting threshold {}",
|
||||
total, thresh
|
||||
)));
|
||||
}
|
||||
}
|
||||
AdvanceTransition::VotingReadyToLocked => {
|
||||
// pwinner' votes thresholds.execute must return Just.
|
||||
// Implement client-side: find max-vote tag, check votes ≥ execute,
|
||||
// and check it strictly beats every other tag.
|
||||
let votes = &args.proposal.datum.votes.0;
|
||||
let exec_threshold = args.proposal.datum.thresholds.execute;
|
||||
let max = votes.iter().max_by_key(|(_, v)| *v).copied();
|
||||
let Some((_winner_tag, max_votes)) = max else {
|
||||
return Err(DaoError::State(
|
||||
"proposal has no votes map; cannot determine winner".into(),
|
||||
));
|
||||
};
|
||||
if max_votes < exec_threshold {
|
||||
return Err(DaoError::State(format!(
|
||||
"winning votes {} < execute threshold {}",
|
||||
max_votes, exec_threshold
|
||||
)));
|
||||
}
|
||||
// Tie check: more than one tag has max_votes → no winner.
|
||||
let max_count = votes.iter().filter(|(_, v)| *v == max_votes).count();
|
||||
if max_count > 1 {
|
||||
return Err(DaoError::State(format!(
|
||||
"vote tie at {} between {} options; no winning outcome",
|
||||
max_votes, max_count
|
||||
)));
|
||||
}
|
||||
}
|
||||
AdvanceTransition::DraftToFinished
|
||||
| AdvanceTransition::VotingReadyToFinished
|
||||
| AdvanceTransition::LockedToFinished => {
|
||||
// No additional preflight beyond the from-status match. The
|
||||
// validator checks timing on chain — caller must ensure
|
||||
// tx validity range is in the right period (we don't compute
|
||||
// ms-from-slot here for simplicity; caller-or-tool's
|
||||
// responsibility).
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pick funding + collateral --------------------------------------
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| DaoError::State("need a SECOND ada-only wallet UTxO for funding".into()))?;
|
||||
|
||||
// ---- new proposal datum: only status mutated ------------------------
|
||||
|
||||
let new_proposal = ProposalDatum {
|
||||
proposal_id,
|
||||
effects_raw: args.proposal.datum.effects_raw.clone(),
|
||||
status: to_status,
|
||||
cosigners: args.proposal.datum.cosigners.clone(),
|
||||
thresholds: ProposalThresholds {
|
||||
..args.proposal.datum.thresholds.clone()
|
||||
},
|
||||
votes: ProposalVotes(args.proposal.datum.votes.0.clone()),
|
||||
timing_config: ProposalTimingConfig {
|
||||
..args.proposal.datum.timing_config.clone()
|
||||
},
|
||||
starting_time: args.proposal.datum.starting_time,
|
||||
};
|
||||
|
||||
let new_proposal_datum_pd = new_proposal.to_plutus_data()?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
|
||||
let proposal_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&ProposalRedeemer::AdvanceProposal.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?;
|
||||
|
||||
// ---- balance + change ----------------------------------------------
|
||||
|
||||
let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let total_in = args
|
||||
.proposal
|
||||
.lovelace
|
||||
.checked_add(funding.lovelace)
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_proposal_lovelace
|
||||
.checked_add(args.fee_lovelace)
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out}"
|
||||
))
|
||||
})?;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})"
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble StagingTransaction ------------------------------------
|
||||
|
||||
let proposal_addr = parse_address(
|
||||
args.cfg
|
||||
.proposal_addr
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_addr not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let proposal_input = Input::new(
|
||||
parse_tx_hash(&args.proposal.tx_hash_hex)?,
|
||||
args.proposal.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(
|
||||
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||
funding.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let proposal_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?,
|
||||
args.proposal_validator_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let proposal_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.proposal_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
staging = staging.input(proposal_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
staging = staging.reference_input(proposal_validator_ref_input);
|
||||
|
||||
// For Draft→VotingReady, every cosigner's stake utxo goes in as a
|
||||
// reference input. Validator iterates txInfo.referenceInputs and
|
||||
// sums their staked_amount.
|
||||
if args.transition == AdvanceTransition::DraftToVotingReady {
|
||||
for r in &args.cosigner_stake_refs {
|
||||
let r_input = Input::new(parse_tx_hash(&r.tx_hash_hex)?, r.output_index as u64);
|
||||
staging = staging.reference_input(r_input);
|
||||
}
|
||||
}
|
||||
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
staging = staging.add_spend_redeemer(
|
||||
proposal_input,
|
||||
proposal_spend_redeemer_cbor,
|
||||
Some(ADVANCE_SPEND_EX_UNITS),
|
||||
);
|
||||
|
||||
// Validity range. Honor caller-supplied overrides if set — that's
|
||||
// how the MCP layer clamps the range to fit inside a phase window
|
||||
// (e.g. early Draft→VotingReady advance must sit fully inside the
|
||||
// drafting period). Default behavior (None overrides) keeps the
|
||||
// legacy `[tip, tip + 1799]` range that's right for Sulkta-shape
|
||||
// 30-min windows but fails on tighter/expired windows.
|
||||
let valid_from = args.valid_from_slot_override.unwrap_or(args.tip_slot);
|
||||
let invalid_from = args
|
||||
.invalid_from_slot_override
|
||||
.unwrap_or(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
||||
if invalid_from <= valid_from {
|
||||
return Err(DaoError::State(format!(
|
||||
"validity range degenerate: valid_from={valid_from}, invalid_from={invalid_from}; \
|
||||
override values must satisfy invalid_from > valid_from"
|
||||
)));
|
||||
}
|
||||
staging = staging.valid_from_slot(valid_from);
|
||||
staging = staging.invalid_from_slot(invalid_from);
|
||||
|
||||
let advancer_pkh_arr: [u8; 28] = args.advancer_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"advancer_pkh must be 28 bytes, got {}",
|
||||
args.advancer_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(advancer_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// Wire V2 cost model so pallas computes script_data_hash. Without
|
||||
// this the chain rejects with PPViewHashesDontMatch — same trap
|
||||
// proposal_create + plutus_mint hit. All Agora validators we
|
||||
// witness here (proposal_validator, proposalSt policy when burning)
|
||||
// are PlutusV2 on the preprod linker output.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_advance_unsigned: dao={} proposal_id={} {:?} → {:?} fee={}",
|
||||
args.cfg.name, proposal_id, from_status, to_status, args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalAdvance {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
proposal_id,
|
||||
from_status,
|
||||
to_status,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::plutus_data::constr;
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn pkh_a() -> Vec<u8> {
|
||||
vec![0x10; 28]
|
||||
}
|
||||
fn pkh_b() -> Vec<u8> {
|
||||
vec![0x80; 28]
|
||||
}
|
||||
fn advancer_pkh() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn sample_proposal_datum() -> ProposalDatum {
|
||||
ProposalDatum {
|
||||
proposal_id: 1,
|
||||
effects_raw: constr(0, vec![]),
|
||||
status: ProposalStatus::Draft,
|
||||
cosigners: vec![Credential::PubKey(pkh_a()), Credential::PubKey(pkh_b())],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
starting_time: 1_780_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_args(
|
||||
transition: AdvanceTransition,
|
||||
proposal_overrides: impl FnOnce(&mut ProposalDatum),
|
||||
) -> ProposalAdvanceArgs {
|
||||
let mut datum = sample_proposal_datum();
|
||||
datum.status = transition.from_status();
|
||||
proposal_overrides(&mut datum);
|
||||
|
||||
ProposalAdvanceArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(),
|
||||
output_index: 0,
|
||||
lovelace: 2_000_000,
|
||||
proposal_st_asset_name_hex: "".into(),
|
||||
datum,
|
||||
},
|
||||
transition,
|
||||
cosigner_stake_refs: vec![
|
||||
CosignerStakeRef {
|
||||
tx_hash_hex: "22".repeat(32),
|
||||
output_index: 0,
|
||||
owner: Credential::PubKey(pkh_a()),
|
||||
staked_amount: 60,
|
||||
},
|
||||
CosignerStakeRef {
|
||||
tx_hash_hex: "33".repeat(32),
|
||||
output_index: 0,
|
||||
owner: Credential::PubKey(pkh_b()),
|
||||
staked_amount: 60,
|
||||
},
|
||||
],
|
||||
proposal_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 1,
|
||||
},
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
advancer_pkh: advancer_pkh(),
|
||||
tip_slot: 180_062_536,
|
||||
valid_from_slot_override: None,
|
||||
invalid_from_slot_override: None,
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_to_voting_ready_happy_path() {
|
||||
let args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {});
|
||||
let unsigned = build_unsigned_proposal_advance(args).unwrap();
|
||||
assert_eq!(unsigned.from_status, ProposalStatus::Draft);
|
||||
assert_eq!(unsigned.to_status, ProposalStatus::VotingReady);
|
||||
assert_eq!(unsigned.proposal_id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_to_voting_ready_rejects_below_threshold() {
|
||||
let args = sample_args(AdvanceTransition::DraftToVotingReady, |d| {
|
||||
d.thresholds.to_voting = 1000;
|
||||
});
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("to_voting threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_to_voting_ready_rejects_owner_mismatch() {
|
||||
let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {});
|
||||
// Replace cosigner_stake_refs[0] owner with a different pkh.
|
||||
args.cosigner_stake_refs[0].owner = Credential::PubKey(vec![0xff; 28]);
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("do not match proposal.cosigners"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_to_voting_ready_rejects_count_mismatch() {
|
||||
let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {});
|
||||
args.cosigner_stake_refs.pop();
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("cosigner stake refs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_to_finished_ok() {
|
||||
let args = sample_args(AdvanceTransition::DraftToFinished, |_| {});
|
||||
let unsigned = build_unsigned_proposal_advance(args).unwrap();
|
||||
assert_eq!(unsigned.to_status, ProposalStatus::Finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_to_locked_happy() {
|
||||
let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| {
|
||||
// Pre-set winning votes — tag 0 has 50 votes, tag 1 has 0.
|
||||
// execute threshold is 20, so tag 0 wins decisively.
|
||||
d.votes = ProposalVotes(vec![(0, 50), (1, 0)]);
|
||||
});
|
||||
let unsigned = build_unsigned_proposal_advance(args).unwrap();
|
||||
assert_eq!(unsigned.from_status, ProposalStatus::VotingReady);
|
||||
assert_eq!(unsigned.to_status, ProposalStatus::Locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_to_locked_rejects_no_winner() {
|
||||
let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| {
|
||||
// Tied votes → no winner.
|
||||
d.votes = ProposalVotes(vec![(0, 50), (1, 50)]);
|
||||
});
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("tie"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_to_locked_rejects_below_execute() {
|
||||
let args = sample_args(AdvanceTransition::VotingReadyToLocked, |d| {
|
||||
d.votes = ProposalVotes(vec![(0, 5), (1, 0)]);
|
||||
});
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("execute threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_to_finished_ok() {
|
||||
let args = sample_args(AdvanceTransition::VotingReadyToFinished, |_| {});
|
||||
let unsigned = build_unsigned_proposal_advance(args).unwrap();
|
||||
assert_eq!(unsigned.to_status, ProposalStatus::Finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_to_finished_ok() {
|
||||
let args = sample_args(AdvanceTransition::LockedToFinished, |_| {});
|
||||
let unsigned = build_unsigned_proposal_advance(args).unwrap();
|
||||
assert_eq!(unsigned.from_status, ProposalStatus::Locked);
|
||||
assert_eq!(unsigned.to_status, ProposalStatus::Finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_transition_status_mismatch() {
|
||||
let mut args = sample_args(AdvanceTransition::DraftToVotingReady, |_| {});
|
||||
args.proposal.datum.status = ProposalStatus::VotingReady;
|
||||
let err = build_unsigned_proposal_advance(args).unwrap_err();
|
||||
assert!(err.to_string().contains("expects from-status"));
|
||||
}
|
||||
}
|
||||
690
crates/aldabra-dao/src/builder/proposal_cosign.rs
Normal file
690
crates/aldabra-dao/src/builder/proposal_cosign.rs
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
//! Build a `dao_proposal_cosign` transaction.
|
||||
//!
|
||||
//! Adds a cosigner to a Draft proposal. Used to clear the
|
||||
//! `to_voting` threshold when a single stake's amount is below it but
|
||||
//! several stakes summed are above — each cosigner contributes their
|
||||
//! `staked_amount` toward the to-voting count when the proposal advances.
|
||||
//!
|
||||
//! ## Tx shape
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The cosigner's stake UTxO (Plutus spend, redeemer = `PermitVote`
|
||||
//! wrapping a Cosign action — same `ppermitVote` handler as voting,
|
||||
//! different proposal redeemer).
|
||||
//! - The target proposal UTxO (Plutus spend, redeemer = `Cosign`).
|
||||
//! - One funding wallet UTxO.
|
||||
//! - **Collateral input**: separate ada-only ≥5 ADA wallet utxo.
|
||||
//! - **Reference inputs**: stake validator + proposal validator.
|
||||
//! - **No mints**.
|
||||
//! - **Outputs**:
|
||||
//! - New stake UTxO with a `Cosigned` lock prepended.
|
||||
//! - New proposal UTxO with the cosigner inserted into `cosigners`
|
||||
//! (sorted, unique).
|
||||
//! - Wallet change.
|
||||
//!
|
||||
//! ## What the validator enforces
|
||||
//!
|
||||
//! From `Agora/Proposal/Scripts.hs` `PCosign` branch (~L433):
|
||||
//!
|
||||
//! 1. Proposal status == Draft.
|
||||
//! 2. **Exactly one** stake input (`ptryFromSingleton`).
|
||||
//! 3. New cosigner = stake.owner — delegatees CANNOT cosign.
|
||||
//! 4. Updated cosigners list is `pinsertUniqueBy` of the new cosigner
|
||||
//! over the old list (sorted insertion, fails on duplicate).
|
||||
//! 5. `len(updated_cosigners) ≤ maximumCosigners` (script parameter,
|
||||
//! matches `cfg.max_cosigners`).
|
||||
//! 6. `stake.staked_amount ≥ thresholds.cosign`.
|
||||
//! 7. Output proposal datum equals input with **only** `cosigners`
|
||||
//! mutated; everything else bit-exact.
|
||||
//!
|
||||
//! From `Agora/Stake/Redeemers.hs` `ppermitVote` PCosign branch (~L244):
|
||||
//!
|
||||
//! 8. Single stake input (already covered above).
|
||||
//! 9. **Owner signs** the tx — `pisSignedBy False` rejects delegatees.
|
||||
//! 10. New stake datum = old with `Cosigned` ProposalLock prepended.
|
||||
//!
|
||||
//! ## Cosigner ordering
|
||||
//!
|
||||
//! Validator uses `pinsertUniqueBy # (pfromOrdBy # plam pfromData)`. For
|
||||
//! Plutus `Credential` this means lexicographic order on the
|
||||
//! Constr-encoded representation: first by variant index (PubKey=0 <
|
||||
//! Script=1), then by the contained hash bytes. Builder mirrors this in
|
||||
//! [`insert_unique_sorted`].
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{
|
||||
ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig,
|
||||
ProposalVotes,
|
||||
};
|
||||
use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as COSIGN_SPEND_EX_UNITS,
|
||||
SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS,
|
||||
};
|
||||
use super::proposal_vote::ProposalUtxoIn;
|
||||
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_cosign`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalCosignArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub stake_in: StakeUtxoIn,
|
||||
pub proposal: ProposalUtxoIn,
|
||||
/// Cosigner's payment-credential hash (28 bytes). MUST match the
|
||||
/// stake's owner pkh — delegatees cannot cosign per validator.
|
||||
pub cosigner_pkh: Vec<u8>,
|
||||
/// Cosigner wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Current chain tip slot.
|
||||
pub tip_slot: u64,
|
||||
/// Reference UTxO citing the stake validator script.
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO citing the proposal validator script.
|
||||
pub proposal_validator_ref: ReferenceUtxo,
|
||||
/// Estimated total fee. Caller-supplied for v1.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_proposal_cosign`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalCosign {
|
||||
pub tx_cbor_hex: String,
|
||||
pub tx_hash_hex: String,
|
||||
pub proposal_id: i64,
|
||||
/// New cosigners count after insertion.
|
||||
pub cosigners_count: usize,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Insert `new` into the sorted-unique credential list, mirroring
|
||||
/// Plutarch's `pinsertUniqueBy # (pfromOrdBy # plam pfromData)`.
|
||||
///
|
||||
/// Returns `Err` if the credential is already present (validator's
|
||||
/// `pinsertUniqueBy` rejects duplicates and so do we — preflight).
|
||||
///
|
||||
/// Order rule: variant index first (PubKey=0 < Script=1), then by the
|
||||
/// 28-byte hash lex order.
|
||||
pub(super) fn insert_unique_sorted(
|
||||
list: &[Credential],
|
||||
new: &Credential,
|
||||
) -> DaoResult<Vec<Credential>> {
|
||||
let key = |c: &Credential| match c {
|
||||
Credential::PubKey(h) => (0u8, h.clone()),
|
||||
Credential::Script(h) => (1u8, h.clone()),
|
||||
};
|
||||
let new_key = key(new);
|
||||
// Check for duplicate.
|
||||
for c in list {
|
||||
if key(c) == new_key {
|
||||
return Err(DaoError::State("credential already in cosigner list — pinsertUniqueBy would reject".to_string()));
|
||||
}
|
||||
}
|
||||
// Find insertion point.
|
||||
let mut out: Vec<Credential> = Vec::with_capacity(list.len() + 1);
|
||||
let mut inserted = false;
|
||||
for c in list {
|
||||
if !inserted && key(c) > new_key {
|
||||
out.push(new.clone());
|
||||
inserted = true;
|
||||
}
|
||||
out.push(c.clone());
|
||||
}
|
||||
if !inserted {
|
||||
out.push(new.clone());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-cosign tx.
|
||||
pub fn build_unsigned_proposal_cosign(
|
||||
args: ProposalCosignArgs,
|
||||
) -> DaoResult<UnsignedProposalCosign> {
|
||||
let proposal_id = args.proposal.datum.proposal_id;
|
||||
|
||||
// ---- preflight checks ------------------------------------------------
|
||||
|
||||
// (3 + 9) Cosigner pkh must equal stake.owner pkh — delegatees rejected.
|
||||
if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.cosigner_pkh) {
|
||||
return Err(DaoError::State(
|
||||
"cosigner pkh must equal stake's owner pkh — delegatees cannot cosign per validator"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// (1) Proposal must be Draft.
|
||||
if args.proposal.datum.status != ProposalStatus::Draft {
|
||||
return Err(DaoError::State(format!(
|
||||
"proposal #{} status is {:?}, must be Draft to cosign",
|
||||
proposal_id, args.proposal.datum.status
|
||||
)));
|
||||
}
|
||||
|
||||
// (6) Stake amount must clear cosign threshold.
|
||||
let cosign_threshold = args.proposal.datum.thresholds.cosign;
|
||||
if args.stake_in.datum.staked_amount < cosign_threshold {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake amount {} < cosign threshold {} — increase stake first",
|
||||
args.stake_in.datum.staked_amount, cosign_threshold
|
||||
)));
|
||||
}
|
||||
|
||||
// (4) Insert cosigner into sorted-unique list. Errors on duplicate.
|
||||
let cosigner_cred = Credential::PubKey(args.cosigner_pkh.clone());
|
||||
let new_cosigners = insert_unique_sorted(&args.proposal.datum.cosigners, &cosigner_cred)?;
|
||||
|
||||
// (5) Length check.
|
||||
if (new_cosigners.len() as u32) > args.cfg.max_cosigners {
|
||||
return Err(DaoError::State(format!(
|
||||
"cosigners count {} would exceed max_cosigners {}",
|
||||
new_cosigners.len(),
|
||||
args.cfg.max_cosigners
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- pick funding + collateral ---------------------------------------
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DaoError::State("need a SECOND ada-only wallet UTxO to fund the spend".into())
|
||||
})?;
|
||||
|
||||
// ---- compute new datums ---------------------------------------------
|
||||
|
||||
// Stake: prepend Cosigned lock.
|
||||
let mut new_locks = Vec::with_capacity(args.stake_in.datum.locked_by.len() + 1);
|
||||
new_locks.push(ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Cosigned,
|
||||
});
|
||||
new_locks.extend(args.stake_in.datum.locked_by.iter().cloned());
|
||||
let new_stake = StakeDatum {
|
||||
staked_amount: args.stake_in.datum.staked_amount,
|
||||
owner: args.stake_in.datum.owner.clone(),
|
||||
delegated_to: args.stake_in.datum.delegated_to.clone(),
|
||||
locked_by: new_locks,
|
||||
};
|
||||
|
||||
// Proposal: cosigners updated, all else preserved bit-exact.
|
||||
let new_proposal = ProposalDatum {
|
||||
proposal_id,
|
||||
effects_raw: args.proposal.datum.effects_raw.clone(),
|
||||
status: args.proposal.datum.status,
|
||||
cosigners: new_cosigners.clone(),
|
||||
thresholds: ProposalThresholds {
|
||||
..args.proposal.datum.thresholds.clone()
|
||||
},
|
||||
votes: ProposalVotes(args.proposal.datum.votes.0.clone()),
|
||||
timing_config: ProposalTimingConfig {
|
||||
..args.proposal.datum.timing_config.clone()
|
||||
},
|
||||
starting_time: args.proposal.datum.starting_time,
|
||||
};
|
||||
|
||||
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
||||
let new_proposal_datum_pd = new_proposal.to_plutus_data()?;
|
||||
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
|
||||
// ---- redeemers -------------------------------------------------------
|
||||
|
||||
let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?;
|
||||
let proposal_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&ProposalRedeemer::Cosign.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?;
|
||||
|
||||
// ---- balance + change ------------------------------------------------
|
||||
|
||||
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let total_in = args
|
||||
.stake_in
|
||||
.lovelace
|
||||
.checked_add(args.proposal.lovelace)
|
||||
.and_then(|x| x.checked_add(funding.lovelace))
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_stake_lovelace
|
||||
.checked_add(new_proposal_lovelace)
|
||||
.and_then(|x| x.checked_add(args.fee_lovelace))
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out}"
|
||||
))
|
||||
})?;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})"
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble StagingTransaction -------------------------------------
|
||||
|
||||
let stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
||||
let proposal_addr = parse_address(
|
||||
args.cfg
|
||||
.proposal_addr
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_addr not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let proposal_input = Input::new(
|
||||
parse_tx_hash(&args.proposal.tx_hash_hex)?,
|
||||
args.proposal.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(
|
||||
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||
funding.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let proposal_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?,
|
||||
args.proposal_validator_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let stake_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.stake_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("stake_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let proposal_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.proposal_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
let new_stake_output = Output::new(stakes_addr, new_stake_lovelace)
|
||||
.set_inline_datum(new_stake_datum_cbor.clone())
|
||||
.add_asset(stake_st_policy_hash, stake_st_asset_name, 1)
|
||||
.and_then(|o| {
|
||||
o.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes,
|
||||
args.stake_in.gov_token_qty,
|
||||
)
|
||||
})
|
||||
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
||||
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
staging = staging.input(stake_input.clone());
|
||||
staging = staging.input(proposal_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(proposal_validator_ref_input);
|
||||
staging = staging.output(new_stake_output);
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(COSIGN_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_spend_redeemer(
|
||||
proposal_input,
|
||||
proposal_spend_redeemer_cbor,
|
||||
Some(COSIGN_SPEND_EX_UNITS),
|
||||
);
|
||||
|
||||
staging = staging.valid_from_slot(args.tip_slot);
|
||||
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
||||
|
||||
let cosigner_pkh_arr: [u8; 28] = args.cosigner_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"cosigner_pkh must be 28 bytes, got {}",
|
||||
args.cosigner_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(cosigner_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// Wire V2 cost model — same fix as proposal_create / proposal_advance.
|
||||
// Without this the chain rejects with PPViewHashesDontMatch.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_cosign_unsigned: dao={} proposal_id={} new_cosigner_pkh={} cosigners_count={} fee={}",
|
||||
args.cfg.name,
|
||||
proposal_id,
|
||||
hex::encode(&args.cosigner_pkh),
|
||||
new_cosigners.len(),
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalCosign {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
proposal_id,
|
||||
cosigners_count: new_cosigners.len(),
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::plutus_data::constr;
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn cosigner_pkh() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn other_pkh_a() -> Vec<u8> {
|
||||
vec![0x10u8; 28]
|
||||
}
|
||||
fn other_pkh_b() -> Vec<u8> {
|
||||
vec![0xf0u8; 28]
|
||||
}
|
||||
|
||||
fn sample_proposal_datum() -> ProposalDatum {
|
||||
ProposalDatum {
|
||||
proposal_id: 1,
|
||||
effects_raw: constr(0, vec![]),
|
||||
status: ProposalStatus::Draft,
|
||||
cosigners: vec![Credential::PubKey(other_pkh_a())],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 7 * 86_400 * 1000,
|
||||
voting_time: 7 * 86_400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
starting_time: 1_780_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_args() -> ProposalCosignArgs {
|
||||
ProposalCosignArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(),
|
||||
output_index: 1,
|
||||
lovelace: 1_555_910,
|
||||
gov_token_qty: 50,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 50,
|
||||
owner: Credential::PubKey(cosigner_pkh()),
|
||||
delegated_to: None,
|
||||
locked_by: vec![],
|
||||
},
|
||||
},
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(),
|
||||
output_index: 0,
|
||||
lovelace: 2_000_000,
|
||||
proposal_st_asset_name_hex: "".into(),
|
||||
datum: sample_proposal_datum(),
|
||||
},
|
||||
cosigner_pkh: cosigner_pkh(),
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
tip_slot: 180_062_536,
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
proposal_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 1,
|
||||
},
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_unsigned_cosign_for_sulkta() {
|
||||
let unsigned = build_unsigned_proposal_cosign(sample_args()).unwrap();
|
||||
assert_eq!(unsigned.proposal_id, 1);
|
||||
assert_eq!(unsigned.cosigners_count, 2);
|
||||
assert!(!unsigned.tx_cbor_hex.is_empty());
|
||||
assert_eq!(unsigned.tx_hash_hex.len(), 64);
|
||||
assert!(unsigned.summary.contains("sulkta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_when_proposal_not_draft() {
|
||||
let mut args = sample_args();
|
||||
args.proposal.datum.status = ProposalStatus::VotingReady;
|
||||
let err = build_unsigned_proposal_cosign(args).unwrap_err();
|
||||
assert!(err.to_string().contains("Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_delegatee_cosigner() {
|
||||
let mut args = sample_args();
|
||||
// Stake has different owner; cosigner_pkh is delegatee.
|
||||
args.stake_in.datum.owner = Credential::PubKey(other_pkh_a());
|
||||
args.stake_in.datum.delegated_to = Some(Credential::PubKey(cosigner_pkh()));
|
||||
let err = build_unsigned_proposal_cosign(args).unwrap_err();
|
||||
assert!(err.to_string().contains("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_cosigner() {
|
||||
let mut args = sample_args();
|
||||
// Add cosigner_pkh as already-present cosigner.
|
||||
args.proposal
|
||||
.datum
|
||||
.cosigners
|
||||
.push(Credential::PubKey(cosigner_pkh()));
|
||||
let err = build_unsigned_proposal_cosign(args).unwrap_err();
|
||||
assert!(err.to_string().contains("already in cosigner list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_below_cosign_threshold() {
|
||||
let mut args = sample_args();
|
||||
args.stake_in.datum.staked_amount = 0;
|
||||
args.proposal.datum.thresholds.cosign = 100;
|
||||
let err = build_unsigned_proposal_cosign(args).unwrap_err();
|
||||
assert!(err.to_string().contains("cosign threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_when_cosigners_at_max() {
|
||||
let mut args = sample_args();
|
||||
args.cfg.max_cosigners = 1;
|
||||
// existing list already has 1 cosigner, adding ours makes 2 > 1.
|
||||
let err = build_unsigned_proposal_cosign(args).unwrap_err();
|
||||
assert!(err.to_string().contains("max_cosigners"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_unique_sorted_keeps_lex_order() {
|
||||
// [a (low), c (high)], insert b (middle) → [a, b, c]
|
||||
let a = Credential::PubKey(vec![0x10; 28]);
|
||||
let b = Credential::PubKey(vec![0x80; 28]);
|
||||
let c = Credential::PubKey(vec![0xf0; 28]);
|
||||
let result = insert_unique_sorted(&[a.clone(), c.clone()], &b.clone()).unwrap();
|
||||
assert_eq!(result, vec![a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_unique_sorted_appends_when_largest() {
|
||||
let a = Credential::PubKey(vec![0x10; 28]);
|
||||
let b = Credential::PubKey(vec![0x80; 28]);
|
||||
let c = Credential::PubKey(vec![0xf0; 28]);
|
||||
let result = insert_unique_sorted(&[a.clone(), b.clone()], &c.clone()).unwrap();
|
||||
assert_eq!(result, vec![a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_unique_sorted_prepends_when_smallest() {
|
||||
let a = Credential::PubKey(vec![0x10; 28]);
|
||||
let b = Credential::PubKey(vec![0x80; 28]);
|
||||
let c = Credential::PubKey(vec![0xf0; 28]);
|
||||
let result = insert_unique_sorted(&[b.clone(), c.clone()], &a.clone()).unwrap();
|
||||
assert_eq!(result, vec![a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_unique_sorted_pubkey_before_script() {
|
||||
let pk = Credential::PubKey(vec![0xf0; 28]);
|
||||
let sc = Credential::Script(vec![0x10; 28]);
|
||||
// PubKey variant=0 < Script variant=1 → PubKey first regardless of hash bytes.
|
||||
let result = insert_unique_sorted(&[sc.clone()], &pk.clone()).unwrap();
|
||||
assert_eq!(result, vec![pk, sc]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_existing_cosigner_a_keeps_position() {
|
||||
// sample's existing cosigner is other_pkh_a (0x10..). Adding cosigner_pkh
|
||||
// (84d0..) which sorts after 0x10 — should land at index 1.
|
||||
let unsigned = build_unsigned_proposal_cosign(sample_args()).unwrap();
|
||||
// Just verify count + that it built; ordering is checked by the
|
||||
// dedicated insert_unique_sorted_* tests.
|
||||
assert_eq!(unsigned.cosigners_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_b_first_then_a_correctly() {
|
||||
// sample has [a]; if we instead start with [b] and add cosigner_pkh
|
||||
// which sorts < b, cosigner ends up first.
|
||||
let mut args = sample_args();
|
||||
args.proposal.datum.cosigners = vec![Credential::PubKey(other_pkh_b())];
|
||||
let unsigned = build_unsigned_proposal_cosign(args).unwrap();
|
||||
assert_eq!(unsigned.cosigners_count, 2);
|
||||
}
|
||||
}
|
||||
880
crates/aldabra-dao/src/builder/proposal_create.rs
Normal file
880
crates/aldabra-dao/src/builder/proposal_create.rs
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
//! Build a `dao_proposal_create` transaction.
|
||||
//!
|
||||
//! This is the first DAO write path. The tx shape:
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The current governor UTxO (Plutus spend, redeemer = `CreateProposal`).
|
||||
//! - One wallet UTxO funding fees + min-UTxO for the new outputs.
|
||||
//! - **Collateral input**: a separate ADA-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**:
|
||||
//! - Governor validator script (so we don't inline 7213B).
|
||||
//! - ProposalST minting policy script.
|
||||
//! - **Mints**: +1 ProposalST token (asset_name = empty, qty=1).
|
||||
//! - **Outputs**:
|
||||
//! - New governor UTxO at `governor_addr`. Datum = old GovernorDatum
|
||||
//! with `next_proposal_id += 1`. Lovelace preserved.
|
||||
//! - New proposal UTxO at `proposal_addr`. Datum = fresh
|
||||
//! `ProposalDatum` (status=Draft, cosigners=[proposer], copied
|
||||
//! thresholds + timing, votes init to per-effect-tag zeros).
|
||||
//! Holds 1 ProposalST + min-UTxO ADA.
|
||||
//! - Wallet change.
|
||||
//!
|
||||
//! ## Why unsigned-first
|
||||
//!
|
||||
//! Treasury-bearing Plutus txs are too high-stakes to auto-sign. Caller
|
||||
//! gets the CBOR back, audits it (decode + check structure), then signs
|
||||
//! with `wallet_sign_partial` and submits via `wallet_submit_signed_tx`.
|
||||
//! Mirrors the cold-signing pattern aldabra already supports for
|
||||
//! `wallet_send_unsigned`.
|
||||
//!
|
||||
//! ## What's NOT in v1 (deferred)
|
||||
//!
|
||||
//! - **ExUnits via Koios `tx_evaluate`** — we use a generous static
|
||||
//! budget (`PROPOSAL_CREATE_EX_UNITS`) for the spend + the mint
|
||||
//! redeemers separately. Refine via real evaluator when we wire up.
|
||||
//! - **Non-empty `effects` map** — InfoOnly proposals only for v1.
|
||||
//! TreasuryWithdrawal effects need the effect-script address +
|
||||
//! datum-hash plumbing (Phase 4c).
|
||||
//! - **Multi-cosigner pre-population** — proposer is sole cosigner at
|
||||
//! creation. Additional cosigners join via `dao_proposal_cosign`.
|
||||
|
||||
use pallas_addresses::Address;
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_codec::utils::KeyValuePairs;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_primitives::PlutusData;
|
||||
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::governor::GovernorDatum;
|
||||
use crate::agora::proposal::{
|
||||
ProposalDatum, ProposalStatus, ProposalThresholds, ProposalTimingConfig, ProposalVotes,
|
||||
};
|
||||
use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Per-script ExUnits budget for proposal_create.
|
||||
///
|
||||
/// **AUDIT-H2 fix 2026-05-05:** Original values were 14M mem / 10G steps
|
||||
/// each — equal to per-tx Conway max. With 3 plutus contracts firing
|
||||
/// (governor spend + stake spend + ProposalST mint), the total claim
|
||||
/// would exceed the per-tx cap and node rejects pre-phase-2.
|
||||
///
|
||||
/// The reference tx (`7c8db1432a07...`) used 1208B tx size + 573_553
|
||||
/// lovelace fee, suggesting much smaller ExUnits per script. Drop to
|
||||
/// ~5M mem / 2G steps each — gives ~15M / 6G total (still under the
|
||||
/// 14M / 10G per-tx cap; node may bump per-tx caps in newer Conway
|
||||
/// epochs). Refine via Koios `tx_evaluate` once we have a working
|
||||
/// unsigned tx to evaluate.
|
||||
pub const PROPOSAL_CREATE_SPEND_EX_UNITS: ExUnits = ExUnits {
|
||||
mem: 5_000_000,
|
||||
steps: 2_000_000_000,
|
||||
};
|
||||
|
||||
pub const PROPOSAL_CREATE_MINT_EX_UNITS: ExUnits = ExUnits {
|
||||
mem: 2_000_000,
|
||||
steps: 1_000_000_000,
|
||||
};
|
||||
|
||||
/// Conway-era min UTxO floor we apply to script outputs. Real value
|
||||
/// depends on the output's serialized size; this constant is a generous
|
||||
/// bound that covers our governor + proposal output shapes.
|
||||
pub const SCRIPT_OUTPUT_MIN_LOVELACE: u64 = 2_000_000;
|
||||
|
||||
/// Minimum collateral lovelace per Conway. Same as
|
||||
/// `aldabra_core::MIN_COLLATERAL_LOVELACE`.
|
||||
pub const MIN_COLLATERAL_LOVELACE: u64 = 5_000_000;
|
||||
|
||||
/// One wallet UTxO available to fund or collateralize the tx.
|
||||
///
|
||||
/// Mirror of `aldabra_core::InputUtxo` — kept separate so this crate
|
||||
/// doesn't need a hard dep on the core's input shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WalletUtxo {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
pub lovelace: u64,
|
||||
/// Empty if pure-ADA UTxO; non-empty if it carries native assets
|
||||
/// (those get re-emitted to the change output, not the script outputs).
|
||||
pub assets: Vec<(String, String, u64)>,
|
||||
}
|
||||
|
||||
impl WalletUtxo {
|
||||
pub fn is_ada_only(&self) -> bool {
|
||||
self.assets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// On-chain governor state we need to spend.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GovernorUtxoIn {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
pub lovelace: u64,
|
||||
pub datum: GovernorDatum,
|
||||
/// 56-hex Governor State Thread (GST) policy id. The new governor
|
||||
/// output must carry +1 of this token to keep the singleton invariant.
|
||||
pub gst_policy_hex: String,
|
||||
/// Asset name (hex) of the GST token. Empty for Sulkta.
|
||||
pub gst_asset_name_hex: String,
|
||||
}
|
||||
|
||||
/// On-chain stake state we need to spend (proposer's existing stake).
|
||||
///
|
||||
/// AUDIT-C2 fix 2026-05-05: governor's `CreateProposal` branch hard-asserts
|
||||
/// `Stake input should present`. Builder MUST take a stake utxo to spend.
|
||||
/// The owner of the stake's datum must equal the tx's signer (proposer_pkh).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StakeUtxoIn {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
pub lovelace: u64,
|
||||
/// Current Terrapin/gov-token quantity on the UTxO. Must equal
|
||||
/// `datum.staked_amount` per stake validator invariant.
|
||||
pub gov_token_qty: u64,
|
||||
/// StakeST asset name (= stake validator script hash) for the +1 token
|
||||
/// the UTxO carries. Both stakes_addr UTxOs we've seen use the
|
||||
/// stake-validator script hash here.
|
||||
pub stake_st_asset_name_hex: String,
|
||||
/// Current StakeDatum on the UTxO. We append a `Created` ProposalLock
|
||||
/// to its `locked_by` field for the new stake output.
|
||||
pub datum: StakeDatum,
|
||||
}
|
||||
|
||||
/// Reference UTxO citing a deployed Agora script.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReferenceUtxo {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
}
|
||||
|
||||
impl ReferenceUtxo {
|
||||
/// Parse a `txhash#index` string.
|
||||
pub fn from_str(s: &str) -> DaoResult<Self> {
|
||||
let (h, i) = s.split_once('#').ok_or_else(|| {
|
||||
DaoError::Config(format!("reference utxo {s:?} not in 'txhash#index' form"))
|
||||
})?;
|
||||
let idx: u32 = i
|
||||
.parse()
|
||||
.map_err(|e| DaoError::Config(format!("reference utxo index {i:?} not a u32: {e}")))?;
|
||||
Ok(Self {
|
||||
tx_hash_hex: h.to_string(),
|
||||
output_index: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_create`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalCreateArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub governor: GovernorUtxoIn,
|
||||
/// Proposer's existing stake UTxO. AUDIT-C2 — required for the
|
||||
/// governor's CreateProposal branch to find a stake input. The stake's
|
||||
/// owner pkh must equal `proposer_pkh`, and `staked_amount + deposit`
|
||||
/// must clear `governor.proposal_thresholds.create`.
|
||||
pub stake_in: StakeUtxoIn,
|
||||
/// Proposer's payment-credential hash (28 bytes).
|
||||
pub proposer_pkh: Vec<u8>,
|
||||
/// Proposer wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Chain tip's POSIX time in milliseconds. Embedded in the new
|
||||
/// `ProposalDatum.starting_time` field. Must lie inside the tx's
|
||||
/// validity range when converted to slots — caller handles the
|
||||
/// slot↔ms conversion.
|
||||
pub starting_time_ms: i64,
|
||||
/// Slot to anchor `valid_from_slot(...)` on. Derived by the caller
|
||||
/// from `starting_time_ms` via the network's shelley constants.
|
||||
/// Anchoring on this (rather than a freshly-fetched koios tip) lets
|
||||
/// the caller compensate for Koios tip-lag by setting the
|
||||
/// starting_time slightly into the future. Validator's
|
||||
/// `pvalidateProposalStartingTime` sees `starting_time_slot ∈
|
||||
/// validRange` by construction.
|
||||
pub starting_time_slot: u64,
|
||||
/// Current chain tip slot. Retained for caller-side fee/sanity
|
||||
/// math; no longer drives the validity range as of 2026-05-07
|
||||
/// (see `starting_time_slot` above).
|
||||
pub tip_slot: u64,
|
||||
/// Reference UTxO to cite for the governor validator script.
|
||||
pub governor_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO to cite for the stake validator script. AUDIT-C2.
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO to cite for the ProposalST minting policy script.
|
||||
pub proposal_st_policy_ref: ReferenceUtxo,
|
||||
/// Estimated total fee. v1: caller-supplied. Phase-4-late: refine
|
||||
/// via Koios `tx_evaluate` + size-fee calc.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// Validity range width in slots. Sulkta's reference tx (`7c8db1432a07...`)
|
||||
/// used 1799 slots (~30 min - 1s) which fits inside
|
||||
/// `create_proposal_time_range_max_width = 1_800_000ms`. Match it.
|
||||
pub const VALIDITY_RANGE_SLOTS: u64 = 1799;
|
||||
|
||||
/// What `build_unsigned_proposal_create` returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalCreate {
|
||||
/// CBOR-hex of the unsigned tx body. Pass through
|
||||
/// `wallet_sign_partial` to add a vkey witness.
|
||||
pub tx_cbor_hex: String,
|
||||
/// Blake2b-256 hash of the tx body (for tracking submission).
|
||||
pub tx_hash_hex: String,
|
||||
/// The new `proposal_id` this tx will mint into existence.
|
||||
pub new_proposal_id: i64,
|
||||
/// Human-readable summary of what the tx does. Useful for the
|
||||
/// MCP tool to print on success.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-creation tx.
|
||||
///
|
||||
/// Two-pass fee is NOT used here in v1 — the caller estimates `fee_lovelace`
|
||||
/// up-front. This is a tradeoff: we get a smaller-LOC builder + the caller
|
||||
/// can iterate the fee against a Koios `tx_evaluate` external loop without
|
||||
/// us having to embed evaluator logic inline. v2 will fold the loop in.
|
||||
pub fn build_unsigned_proposal_create(
|
||||
args: ProposalCreateArgs,
|
||||
) -> DaoResult<UnsignedProposalCreate> {
|
||||
let new_proposal_id = args.governor.datum.next_proposal_id;
|
||||
let proposer_cred = Credential::PubKey(args.proposer_pkh.clone());
|
||||
|
||||
// ---- preflight: stake's owner must match proposer; stake meets create-threshold ----
|
||||
//
|
||||
// AUDIT-C2 + governor's `CreateProposal` invariants. Catch these
|
||||
// client-side rather than waste fees on a phase-2 reject.
|
||||
if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.proposer_pkh) {
|
||||
return Err(DaoError::State("stake owner pkh does not match proposer pkh — proposer must own the stake input".to_string()));
|
||||
}
|
||||
let create_threshold = args.governor.datum.proposal_thresholds.create;
|
||||
if (args.stake_in.datum.staked_amount as i128) < (create_threshold as i128) {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake amount {} < create threshold {} — cosigner support not yet implemented; \
|
||||
use a wallet with stake >= threshold OR (later) pass multiple cosigner stakes",
|
||||
args.stake_in.datum.staked_amount, create_threshold
|
||||
)));
|
||||
}
|
||||
let max_proposals_per_stake = args.governor.datum.maximum_created_proposals_per_stake;
|
||||
let n_created = args
|
||||
.stake_in
|
||||
.datum
|
||||
.locked_by
|
||||
.iter()
|
||||
.filter(|l| matches!(l.action, ProposalAction::Created))
|
||||
.count() as i64;
|
||||
if n_created >= max_proposals_per_stake {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake already has {} Created proposal locks; max is {}",
|
||||
n_created, max_proposals_per_stake
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- pick funding + collateral ---------------------------------------
|
||||
//
|
||||
// Same rule as `aldabra_core::build_signed_plutus_spend`: smallest
|
||||
// ada-only UTxO ≥ 5 ADA is collateral; largest remaining ada-only is
|
||||
// funding. Other wallet utxos are passed through to change as-is.
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(
|
||||
"need a SECOND ada-only wallet UTxO to fund the spend (separate from collateral)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ---- new datums -------------------------------------------------------
|
||||
|
||||
// Governor: copy old datum, increment next_proposal_id.
|
||||
let new_governor = GovernorDatum {
|
||||
next_proposal_id: args.governor.datum.next_proposal_id + 1,
|
||||
..args.governor.datum.clone()
|
||||
};
|
||||
|
||||
// Proposal: fresh ProposalDatum (Draft, sole cosigner, copied params).
|
||||
//
|
||||
// AUDIT-C1 fix 2026-05-05: effects must be a NON-empty map with at least
|
||||
// one neutral (empty inner map) entry, AND its keys must equal the votes
|
||||
// map's keys. Per Agora `Governor/Scripts.hs:437-462` validators
|
||||
// `phasNeutralEffect` (pany # pnull over inner maps) and
|
||||
// `pisEffectsVotesCompatible` (effects keys == votes keys).
|
||||
//
|
||||
// For InfoOnly: both ResultTag(0) and ResultTag(1) map to empty inner
|
||||
// maps (no effect scripts trigger regardless of vote outcome).
|
||||
let empty_inner: PlutusData =
|
||||
PlutusData::Map(KeyValuePairs::from(Vec::<(PlutusData, PlutusData)>::new()));
|
||||
let effects_pd = PlutusData::Map(KeyValuePairs::from(vec![
|
||||
(crate::agora::plutus_data::int(0)?, empty_inner.clone()),
|
||||
(crate::agora::plutus_data::int(1)?, empty_inner),
|
||||
]));
|
||||
|
||||
let new_proposal = ProposalDatum {
|
||||
proposal_id: new_proposal_id,
|
||||
effects_raw: effects_pd,
|
||||
status: ProposalStatus::Draft,
|
||||
cosigners: vec![proposer_cred.clone()],
|
||||
thresholds: ProposalThresholds {
|
||||
..args.governor.datum.proposal_thresholds.clone()
|
||||
},
|
||||
// Vote keys MUST equal effects keys (per pisEffectsVotesCompatible).
|
||||
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
..args.governor.datum.proposal_timings.clone()
|
||||
},
|
||||
starting_time: args.starting_time_ms,
|
||||
};
|
||||
|
||||
// New stake datum: copy old, PREPEND a Created lock for the new
|
||||
// proposal. Order matters — the stake validator's ppermitVote uses
|
||||
// `pcons NEW_LOCK old_locks` (head-cons, NOT append). If we append
|
||||
// and the input had pre-existing locks, the chain rejects with
|
||||
// CekError on the stake validator. Caught 2026-05-08 trying to
|
||||
// create proposal #1 while the stake still held a Created lock
|
||||
// from proposal #0; cosign + vote builders already prepend.
|
||||
let mut new_locks = Vec::with_capacity(args.stake_in.datum.locked_by.len() + 1);
|
||||
new_locks.push(ProposalLock {
|
||||
proposal_id: new_proposal_id,
|
||||
action: ProposalAction::Created,
|
||||
});
|
||||
new_locks.extend(args.stake_in.datum.locked_by.iter().cloned());
|
||||
let mut new_stake = args.stake_in.datum.clone();
|
||||
new_stake.locked_by = new_locks;
|
||||
|
||||
let new_governor_datum_pd = new_governor.to_plutus_data()?;
|
||||
let new_proposal_datum_pd = new_proposal.to_plutus_data()?;
|
||||
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
||||
let new_governor_datum_cbor = minicbor::to_vec(&new_governor_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new governor datum encode: {e}")))?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
||||
|
||||
// ---- redeemers --------------------------------------------------------
|
||||
//
|
||||
// Governor spend: GovernorRedeemer::CreateProposal = Integer 0 (per
|
||||
// EnumIsData encoding fix 2026-05-05).
|
||||
//
|
||||
// Stake spend: redeemer is PermitVote (Constr 2 []). DepositWithdraw
|
||||
// requires locked_by to STAY empty — which conflicts with adding a
|
||||
// Created lock for the new proposal. PermitVote is the redeemer that
|
||||
// grants new locks (for create/vote/cosign) on a stake. Caught
|
||||
// 2026-05-07 PM via base64-decoded failing-script header (5178 bytes
|
||||
// = stake validator); the bare CekError under traces-stripped Agora
|
||||
// pointed at the stake's lock-state invariant.
|
||||
//
|
||||
// Mint redeemer: per `Agora/Proposal/Scripts.hs:118` the policy is
|
||||
// `\_gst _redeemer ctx -> ...` — redeemer is unused. Constr 0 [] is fine.
|
||||
|
||||
let governor_spend_redeemer_cbor = minicbor::to_vec(&crate::agora::plutus_data::int(0)?)
|
||||
.map_err(|e| DaoError::Cbor(format!("governor spend redeemer encode: {e}")))?;
|
||||
let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake spend redeemer encode: {e}")))?;
|
||||
let mint_redeemer_cbor = minicbor::to_vec(crate::agora::plutus_data::constr(0, vec![]))
|
||||
.map_err(|e| DaoError::Cbor(format!("mint redeemer encode: {e}")))?;
|
||||
|
||||
// ---- balance + change -------------------------------------------------
|
||||
//
|
||||
// total_in = governor + stake + funding (collateral held separately).
|
||||
// outputs = new_governor + new_stake + new_proposal + change.
|
||||
// Change = total_in - sum(script outputs) - fee.
|
||||
|
||||
let new_governor_lovelace = args.governor.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_proposal_lovelace = SCRIPT_OUTPUT_MIN_LOVELACE;
|
||||
|
||||
let total_in = args
|
||||
.governor
|
||||
.lovelace
|
||||
.checked_add(args.stake_in.lovelace)
|
||||
.and_then(|x| x.checked_add(funding.lovelace))
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_governor_lovelace
|
||||
.checked_add(new_stake_lovelace)
|
||||
.and_then(|x| x.checked_add(new_proposal_lovelace))
|
||||
.and_then(|x| x.checked_add(args.fee_lovelace))
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out} \
|
||||
(governor_out={new_governor_lovelace} + stake_out={new_stake_lovelace} + \
|
||||
proposal_out={new_proposal_lovelace} + fee={})",
|
||||
args.fee_lovelace
|
||||
))
|
||||
})?;
|
||||
// Wallet change can be a regular pubkey output — lower min-utxo floor.
|
||||
// AUDIT-M2: previous code required script-floor (2 ADA) for wallet
|
||||
// change; that's wrong, use 1 ADA (still conservative for Conway).
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({}); top up wallet or increase funding",
|
||||
WALLET_CHANGE_MIN_LOVELACE
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble pallas StagingTransaction -------------------------------
|
||||
|
||||
let governor_addr = parse_address(&args.cfg.governor_addr)?;
|
||||
let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"proposal_addr not set on DaoConfig — register or discover_scripts first".into(),
|
||||
)
|
||||
})?)?;
|
||||
let stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let governor_input = Input::new(
|
||||
parse_tx_hash(&args.governor.tx_hash_hex)?,
|
||||
args.governor.output_index as u64,
|
||||
);
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(
|
||||
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||
funding.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let governor_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.governor_validator_ref.tx_hash_hex)?,
|
||||
args.governor_validator_ref.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let proposal_st_policy_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_st_policy_ref.tx_hash_hex)?,
|
||||
args.proposal_st_policy_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let proposal_st_policy_hash =
|
||||
parse_script_hash(args.cfg.proposal_st_policy.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"proposal_st_policy not set on DaoConfig — register or discover_scripts first"
|
||||
.into(),
|
||||
)
|
||||
})?)?;
|
||||
let stake_st_policy_hash =
|
||||
parse_script_hash(args.cfg.stake_st_policy.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"stake_st_policy not set on DaoConfig — register or discover_scripts first".into(),
|
||||
)
|
||||
})?)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
let gst_policy_hash = parse_script_hash(&args.governor.gst_policy_hex)?;
|
||||
let gst_name_bytes = hex::decode(&args.governor.gst_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gst_asset_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
// New governor output: same address, +1 GST, updated datum.
|
||||
let new_governor_output = Output::new(governor_addr, new_governor_lovelace)
|
||||
.set_inline_datum(new_governor_datum_cbor.clone())
|
||||
.add_asset(gst_policy_hash, gst_name_bytes.clone(), 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add gst asset to governor output: {e}")))?;
|
||||
|
||||
// New stake output: same stakes_addr, same StakeST + same gov-token qty,
|
||||
// datum carries the new Created lock.
|
||||
let new_stake_output = Output::new(stakes_addr, new_stake_lovelace)
|
||||
.set_inline_datum(new_stake_datum_cbor.clone())
|
||||
.add_asset(stake_st_policy_hash, stake_st_asset_name.clone(), 1)
|
||||
.and_then(|o| {
|
||||
o.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes.clone(),
|
||||
args.stake_in.gov_token_qty,
|
||||
)
|
||||
})
|
||||
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
||||
|
||||
// New proposal output: ProposalST + min-utxo + datum.
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, vec![], 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
// 3 regular inputs: governor (script), stake (script), funding (wallet).
|
||||
staging = staging.input(governor_input.clone());
|
||||
staging = staging.input(stake_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
// 3 reference inputs: governor + stake validators + ProposalST policy.
|
||||
staging = staging.reference_input(governor_validator_ref_input);
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(proposal_st_policy_ref_input);
|
||||
// 4 outputs (3 script + maybe 1 change): governor, stake, proposal, change.
|
||||
staging = staging.output(new_governor_output);
|
||||
staging = staging.output(new_stake_output);
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
// Re-emit any native assets the funding UTxO carried (none in v1
|
||||
// since we picked ada-only — but caller could pass a non-ada-only
|
||||
// funding utxo via an alt args struct in the future).
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
// Mint +1 ProposalST (asset_name = empty bytes per Sulkta convention).
|
||||
staging = staging
|
||||
.mint_asset(proposal_st_policy_hash, vec![], 1)
|
||||
.map_err(|e| DaoError::Backend(format!("mint_asset: {e}")))?;
|
||||
|
||||
// Three plutus contract invocations: spend governor, spend stake, mint ProposalST.
|
||||
staging = staging.add_spend_redeemer(
|
||||
governor_input,
|
||||
governor_spend_redeemer_cbor,
|
||||
Some(PROPOSAL_CREATE_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(PROPOSAL_CREATE_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_mint_redeemer(
|
||||
proposal_st_policy_hash,
|
||||
mint_redeemer_cbor,
|
||||
Some(PROPOSAL_CREATE_MINT_EX_UNITS),
|
||||
);
|
||||
|
||||
// AUDIT-C3 fix: tx validity range + disclosed_signer.
|
||||
//
|
||||
// pvalidateProposalStartingTime requires a bounded validRange ≤
|
||||
// create_proposal_time_range_max_width that includes starting_time.
|
||||
// Reference tx (7c8db1432a07...) used 1799 slots = ~30 min.
|
||||
//
|
||||
// pauthorizedBy on the stake checks proposer's pkh appears in
|
||||
// txInfoSignatories — we disclose it explicitly so pallas-txbuilder
|
||||
// knows to require + emit the corresponding witness.
|
||||
// Range width must be ≤ governor.create_proposal_time_range_max_width
|
||||
// (in ms; slot length on every Shelley+ network is 1 second). For
|
||||
// Sulkta-shape governors with 30min windows, the legacy 1799-slot
|
||||
// const fits. For tiny test DAOs (preprod_test: 30s) it must shrink
|
||||
// to the per-DAO budget. Subtract 1 slot for safety against round-up.
|
||||
let max_width_slots = ((args.governor.datum.create_proposal_time_range_max_width / 1_000)
|
||||
as u64)
|
||||
.saturating_sub(1)
|
||||
.min(VALIDITY_RANGE_SLOTS);
|
||||
// 2026-05-07: anchor the validity range to caller-supplied
|
||||
// `starting_time_slot` instead of `tip_slot`. Public Koios's tip
|
||||
// endpoint can lag the actual chain by 100+ slots; under a 29s
|
||||
// governor window that lag pushes invalid_after into the past
|
||||
// before the tx ever reaches a node. Caller passes a slightly-future
|
||||
// starting_time_slot (e.g. tip+30); the on-chain
|
||||
// `OutsideValidityIntervalUTxO` check then has a window that
|
||||
// straddles when the tx actually lands, while the in-script
|
||||
// `pvalidateProposalStartingTime` is satisfied because
|
||||
// `starting_time_slot ∈ [valid_from, invalid_after - 1]` by
|
||||
// construction.
|
||||
// 2026-05-08: CENTER `starting_time_slot` inside the validity range
|
||||
// (rather than putting it at the lower bound). Tiny test DAOs run on
|
||||
// a 30-second create_proposal_time_range_max_width, and koios's tip
|
||||
// endpoint lag vs. the actual node can swing ±60s. With
|
||||
// valid_from = starting_time, the window only spans [now, now+30].
|
||||
// If chain is even slightly past `now` when the tx lands, the tx
|
||||
// expires. Centering gives [now-15, now+15] of slack — same width,
|
||||
// same validator-bound, but the chain-now-at-block-time can drift
|
||||
// ±15s without missing the window.
|
||||
let half_width_slots = max_width_slots / 2;
|
||||
let valid_from = args.starting_time_slot.saturating_sub(half_width_slots);
|
||||
let invalid_from = valid_from + max_width_slots;
|
||||
staging = staging.valid_from_slot(valid_from);
|
||||
staging = staging.invalid_from_slot(invalid_from);
|
||||
|
||||
let proposer_pkh_arr: [u8; 28] = args.proposer_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"proposer_pkh must be 28 bytes, got {}",
|
||||
args.proposer_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(proposer_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// Wire the V2 cost model so pallas computes script_data_hash. Without
|
||||
// this the chain rejects with PPViewHashesDontMatch — same trap the
|
||||
// plutus_mint path tripped over on 2026-05-07. All Agora validators
|
||||
// we witness here (governor, stake, proposalSt policy) are PlutusV2
|
||||
// on the current preprod linker output, so a single language_view
|
||||
// entry covers all three.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
// Tx hash = blake2b-256 of the body. pallas-txbuilder gives us this back
|
||||
// via the built struct's hash field.
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_create_unsigned: dao={} new_proposal_id={} action=InfoOnly proposer_pkh={} fee={}",
|
||||
args.cfg.name,
|
||||
new_proposal_id,
|
||||
hex::encode(&args.proposer_pkh),
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalCreate {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
new_proposal_id,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- helpers --------------------------------------------------------
|
||||
//
|
||||
// These are `pub(super)` so sibling builders (proposal_vote, proposal_advance,
|
||||
// etc.) can reuse them without re-implementing parse logic.
|
||||
|
||||
pub(super) fn parse_address(bech32: &str) -> DaoResult<Address> {
|
||||
Address::from_bech32(bech32).map_err(|e| DaoError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn parse_tx_hash(hex_str: &str) -> DaoResult<Hash<32>> {
|
||||
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("tx_hash hex: {e}")))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(DaoError::Cbor(format!(
|
||||
"tx_hash must be 32 bytes, got {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(Hash::from(arr))
|
||||
}
|
||||
|
||||
pub(super) fn parse_script_hash(hex_str: &str) -> DaoResult<Hash<28>> {
|
||||
let bytes =
|
||||
hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("script_hash hex: {e}")))?;
|
||||
if bytes.len() != 28 {
|
||||
return Err(DaoError::Cbor(format!(
|
||||
"script_hash must be 28 bytes, got {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut arr = [0u8; 28];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(Hash::from(arr))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::governor::GovernorDatum;
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
|
||||
fn sample_governor_datum() -> GovernorDatum {
|
||||
GovernorDatum {
|
||||
proposal_thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
next_proposal_id: 1,
|
||||
proposal_timings: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
create_proposal_time_range_max_width: 30 * 60 * 1000,
|
||||
maximum_created_proposals_per_stake: 20,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_args() -> ProposalCreateArgs {
|
||||
ProposalCreateArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0"
|
||||
.into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: Default::default(),
|
||||
},
|
||||
governor: GovernorUtxoIn {
|
||||
tx_hash_hex: "7c8db1432a07143eaf7755257baf8691a8e24ee8b2f3a139fa1ce222f2821c47"
|
||||
.into(),
|
||||
output_index: 1,
|
||||
lovelace: 1_254_210,
|
||||
datum: sample_governor_datum(),
|
||||
// Sulkta GST policy (discovered via tx_info on the create tx).
|
||||
gst_policy_hex: "568ee4f1cb41050000000000000000000000000000000000000000ee".into(),
|
||||
gst_asset_name_hex: "".into(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6"
|
||||
.into(),
|
||||
output_index: 1,
|
||||
lovelace: 1_555_910,
|
||||
gov_token_qty: 250,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 250,
|
||||
owner: Credential::PubKey(
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3")
|
||||
.unwrap(),
|
||||
),
|
||||
delegated_to: None,
|
||||
locked_by: vec![],
|
||||
},
|
||||
},
|
||||
tip_slot: 180_062_536,
|
||||
starting_time_slot: 180_062_536,
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
proposer_pkh: hex::decode(
|
||||
"84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3",
|
||||
)
|
||||
.unwrap(),
|
||||
change_address: "addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6".into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "0000000000000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "0000000000000000000000000000000000000000000000000000000000000002".into(),
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
starting_time_ms: 1_780_000_000_000,
|
||||
governor_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 3,
|
||||
},
|
||||
proposal_st_policy_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 0,
|
||||
},
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_unsigned_tx_for_sulkta_infoonly() {
|
||||
let unsigned = build_unsigned_proposal_create(sample_args()).unwrap();
|
||||
assert_eq!(unsigned.new_proposal_id, 1);
|
||||
assert!(!unsigned.tx_cbor_hex.is_empty());
|
||||
assert_eq!(unsigned.tx_hash_hex.len(), 64);
|
||||
assert!(unsigned.summary.contains("sulkta"));
|
||||
assert!(unsigned.summary.contains("InfoOnly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_proposal_addr_missing() {
|
||||
let mut args = sample_args();
|
||||
args.cfg.proposal_addr = None;
|
||||
let err = build_unsigned_proposal_create(args).unwrap_err();
|
||||
assert!(err.to_string().contains("proposal_addr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_no_funding_utxo() {
|
||||
let mut args = sample_args();
|
||||
// Only collateral-eligible utxo, no second ada-only.
|
||||
args.wallet_utxos = vec![WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(32),
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
}];
|
||||
let err = build_unsigned_proposal_create(args).unwrap_err();
|
||||
assert!(err.to_string().contains("SECOND ada-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_no_collateral() {
|
||||
let mut args = sample_args();
|
||||
// All utxos below 5 ADA — no collateral candidate.
|
||||
args.wallet_utxos = vec![WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(32),
|
||||
output_index: 0,
|
||||
lovelace: 4_000_000,
|
||||
assets: vec![],
|
||||
}];
|
||||
let err = build_unsigned_proposal_create(args).unwrap_err();
|
||||
assert!(err.to_string().contains("collateral"));
|
||||
}
|
||||
}
|
||||
895
crates/aldabra-dao/src/builder/proposal_retract_votes.rs
Normal file
895
crates/aldabra-dao/src/builder/proposal_retract_votes.rs
Normal file
|
|
@ -0,0 +1,895 @@
|
|||
//! Build a `dao_proposal_retract_votes` transaction.
|
||||
//!
|
||||
//! Retract one stake's locks for a given proposal. Pairs with the proposal
|
||||
//! being spent under `UnlockStake`. Two distinct effects depending on the
|
||||
//! proposal's current status:
|
||||
//!
|
||||
//! - **Proposal in `VotingReady` (and within voting window)**: removes the
|
||||
//! stake's `Voted` lock for that proposal AND subtracts the stake's
|
||||
//! `staked_amount` from `proposal.votes[voted_tag]`. Stake's
|
||||
//! `Created`/`Cosigned` locks for that proposal are kept.
|
||||
//! - **Proposal in `Finished`**: removes ALL locks for that proposal_id
|
||||
//! (Voted + Created + Cosigned). Proposal datum is unchanged. This is the
|
||||
//! path that finally lets a stake become unlocked + destroyable after a
|
||||
//! proposal has resolved.
|
||||
//! - **Proposal in any other status / outside voting window**: removes only
|
||||
//! `Voted` locks, AND only if the lock's cooldown has elapsed
|
||||
//! (`createdAt + minStakeVotingTime ≤ tx_lower_ms`). Proposal datum is
|
||||
//! unchanged. Useful for unlocking a stake on a `Locked` proposal whose
|
||||
//! voting closed but isn't fully `Finished` yet.
|
||||
//!
|
||||
//! ## Tx shape
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The voter's stake UTxO (Plutus spend, redeemer = `RetractVotes`).
|
||||
//! - The target proposal UTxO (Plutus spend, redeemer = `UnlockStake`).
|
||||
//! - One ada-only wallet UTxO for funding.
|
||||
//! - **Collateral input**: separate ada-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**:
|
||||
//! - Stake validator script.
|
||||
//! - Proposal validator script.
|
||||
//! - **No mints**.
|
||||
//! - **Outputs**:
|
||||
//! - New stake UTxO at `stakes_addr`. Datum = old StakeDatum with
|
||||
//! `locked_by` filtered per the rules above. StakeST + gov-token qty
|
||||
//! preserved.
|
||||
//! - New proposal UTxO at `proposal_addr`. Datum is either:
|
||||
//! - votes-mutated copy of input datum (only if VotingReady + in
|
||||
//! voting window — matches `shouldUpdateVotes` in `Agora/Proposal/
|
||||
//! Scripts.hs:601-611`),
|
||||
//! - or bit-identical copy of input datum (every other case).
|
||||
//! ProposalST preserved.
|
||||
//! - Wallet change.
|
||||
//!
|
||||
//! ## What the validator enforces (must match)
|
||||
//!
|
||||
//! From `Agora/Stake/Redeemers.hs:326` `pretractVote`:
|
||||
//!
|
||||
//! 1. ProposalContext is `PSpendProposal proposal UnlockStake currentTime`
|
||||
//! — i.e. the same tx must spend a proposal under UnlockStake.
|
||||
//! 2. Owner OR delegatee signs.
|
||||
//! 3. Output stake datum equals input with only `locked_by` mutated to the
|
||||
//! filtered list.
|
||||
//! 4. Filter rule (per `premoveLocks` at `Stake/Redeemers.hs:284`):
|
||||
//! - Voted lock for proposal_id: removed iff
|
||||
//! `(mode = RemoveAllLocks)` OR
|
||||
//! `(createdAt + minStakeVotingTime ≤ lowerBound)`.
|
||||
//! If not in either case, validator errors.
|
||||
//! - Created/Cosigned lock for proposal_id: removed iff
|
||||
//! `mode = RemoveAllLocks`.
|
||||
//! - Mode = `RemoveAllLocks` if proposal is `Finished`, else
|
||||
//! `RemoveVoterLockOnly`.
|
||||
//!
|
||||
//! From `Agora/Proposal/Scripts.hs:569` `PUnlockStake` branch:
|
||||
//!
|
||||
//! 5. Every spent stake input must have `locked_by` containing at least one
|
||||
//! lock for this proposal_id (i.e. `pgetStakeRoles` must NOT return
|
||||
//! `PIrrelevant`). Pre-flighted client-side.
|
||||
//! 6. If `currentStatus == VotingReady && tx-validity inside voting period`:
|
||||
//! proposal output votes = `pretractVotes` over input stakes (subtract
|
||||
//! each voter stake's amount from its voted tag). Other proposal fields
|
||||
//! bit-identical.
|
||||
//! 7. Otherwise: proposal output = bit-identical copy of input.
|
||||
//! 8. Validity range width ≤ `votingTimeRangeMaxWidth`.
|
||||
//!
|
||||
//! ## What's NOT in v1
|
||||
//!
|
||||
//! - **Multi-stake retraction** — `pretractVotes` over multiple stakes is
|
||||
//! what the proposal validator supports; v1 supports a single stake to
|
||||
//! match the rest of our builder family.
|
||||
//! - **Selecting which lock to retract when a stake has multiple voted
|
||||
//! locks for the same proposal_id** — that shouldn't happen (the voter
|
||||
//! path prevents double-vote), but if it ever does, we retract all of
|
||||
//! them together.
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes};
|
||||
use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as RETRACT_SPEND_EX_UNITS,
|
||||
SCRIPT_OUTPUT_MIN_LOVELACE, VALIDITY_RANGE_SLOTS,
|
||||
};
|
||||
use super::proposal_vote::ProposalUtxoIn;
|
||||
|
||||
/// Wallet-change min-UTxO floor. Same value used in proposal_vote.
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_retract_votes`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalRetractVotesArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub stake_in: StakeUtxoIn,
|
||||
pub proposal: ProposalUtxoIn,
|
||||
/// Voter's payment-credential hash (28 bytes). Must equal stake's
|
||||
/// owner pkh OR stake's delegated_to pkh.
|
||||
pub voter_pkh: Vec<u8>,
|
||||
/// Voter wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Current chain tip slot. Sets `valid_from_slot(tip_slot)` and
|
||||
/// `invalid_from_slot(tip_slot + VALIDITY_RANGE_SLOTS)`.
|
||||
pub tip_slot: u64,
|
||||
/// POSIX-ms equivalent of the validity range's LOWER bound (= tip_slot
|
||||
/// converted to ms via the Shelley genesis epoch). Used for cooldown
|
||||
/// preflight on Voted locks: `createdAt + minStakeVotingTime ≤ this`.
|
||||
/// Caller is responsible for the slot↔ms conversion.
|
||||
pub validity_lower_ms: i64,
|
||||
/// Reference UTxO citing the stake validator script.
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO citing the proposal validator script.
|
||||
pub proposal_validator_ref: ReferenceUtxo,
|
||||
/// Estimated total fee. Caller-supplied for v1.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_proposal_retract_votes`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalRetractVotes {
|
||||
/// CBOR-hex of the unsigned tx body.
|
||||
pub tx_cbor_hex: String,
|
||||
/// Blake2b-256 hash of the tx body.
|
||||
pub tx_hash_hex: String,
|
||||
/// The proposal_id whose locks were retracted.
|
||||
pub proposal_id: i64,
|
||||
/// Number of locks dropped from the stake.
|
||||
pub locks_removed: usize,
|
||||
/// Vote weight subtracted from the proposal's votes map (0 if the
|
||||
/// proposal datum was unchanged — either no Voted lock or proposal
|
||||
/// outside its voting window).
|
||||
pub vote_weight_retracted: i64,
|
||||
/// Whether the new stake datum has any locks remaining for this
|
||||
/// proposal_id (true means stake is still partially locked by this
|
||||
/// proposal — e.g. still has Created lock after retracting Voted).
|
||||
pub stake_still_locked_by_this_proposal: bool,
|
||||
/// Human-readable summary.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-retract-votes tx.
|
||||
pub fn build_unsigned_proposal_retract_votes(
|
||||
args: ProposalRetractVotesArgs,
|
||||
) -> DaoResult<UnsignedProposalRetractVotes> {
|
||||
let proposal_id = args.proposal.datum.proposal_id;
|
||||
|
||||
// ---- preflight checks ------------------------------------------------
|
||||
|
||||
// Voter must be owner or delegatee.
|
||||
let voter_is_owner = matches!(
|
||||
&args.stake_in.datum.owner,
|
||||
Credential::PubKey(h) if *h == args.voter_pkh
|
||||
);
|
||||
let voter_is_delegate = match &args.stake_in.datum.delegated_to {
|
||||
Some(Credential::PubKey(h)) => *h == args.voter_pkh,
|
||||
_ => false,
|
||||
};
|
||||
if !voter_is_owner && !voter_is_delegate {
|
||||
return Err(DaoError::State(
|
||||
"voter pkh is neither stake owner nor delegatee — cannot retract with this stake"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Stake must have at least one lock for this proposal_id (else the
|
||||
// proposal validator's `pisIrrelevant` check rejects).
|
||||
let locks_for_proposal: Vec<&ProposalLock> = args
|
||||
.stake_in
|
||||
.datum
|
||||
.locked_by
|
||||
.iter()
|
||||
.filter(|l| l.proposal_id == proposal_id)
|
||||
.collect();
|
||||
if locks_for_proposal.is_empty() {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake has no locks for proposal #{} — nothing to retract",
|
||||
proposal_id
|
||||
)));
|
||||
}
|
||||
|
||||
// Decide the retract mode the validator will see.
|
||||
let mode = if args.proposal.datum.status == ProposalStatus::Finished {
|
||||
RetractMode::RemoveAllLocks
|
||||
} else {
|
||||
RetractMode::RemoveVoterLockOnly
|
||||
};
|
||||
|
||||
// Determine voting-window state. Matters because the proposal validator
|
||||
// only allows votes-mutation if `status == VotingReady && tx_validity
|
||||
// inside [start+draft, start+draft+voting]`.
|
||||
let voting_start_ms =
|
||||
args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time;
|
||||
let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time;
|
||||
let tx_lower_ms = args.validity_lower_ms;
|
||||
let tx_upper_ms = tx_lower_ms + (VALIDITY_RANGE_SLOTS as i64) * 1000;
|
||||
let in_voting_window = tx_lower_ms >= voting_start_ms && tx_upper_ms <= voting_end_ms;
|
||||
let proposal_datum_will_change =
|
||||
args.proposal.datum.status == ProposalStatus::VotingReady && in_voting_window;
|
||||
|
||||
// Voter cooldown preflight. Per Agora's `premoveLocks`, a Voted lock
|
||||
// must satisfy `createdAt + minStakeVotingTime ≤ lowerBound` to be
|
||||
// removable — UNLESS the retract also mutates the proposal's vote
|
||||
// tally (i.e. retracting during the voting window of a VotingReady
|
||||
// proposal). In that path the validator takes a different branch
|
||||
// (Vote-with-RetractVotes / UnlockStake) where cooldown does NOT
|
||||
// apply. Cooldown only matters for "lock cleanup after voting
|
||||
// closed but before Finished" — the post-window pre-Finished case.
|
||||
let unlock_cooldown = args.proposal.datum.timing_config.min_stake_voting_time;
|
||||
let mut voted_lock_to_retract: Option<&ProposalLock> = None;
|
||||
for lock in &locks_for_proposal {
|
||||
if let ProposalAction::Voted { posix_time, .. } = &lock.action {
|
||||
// Skip cooldown when proposal datum WILL change (in-voting-window
|
||||
// path) or when we're in RemoveAllLocks mode (Finished path).
|
||||
let cooldown_required =
|
||||
matches!(mode, RetractMode::RemoveVoterLockOnly) && !proposal_datum_will_change;
|
||||
if cooldown_required {
|
||||
let ready_at = posix_time
|
||||
.checked_add(unlock_cooldown)
|
||||
.ok_or_else(|| DaoError::State("cooldown overflow".into()))?;
|
||||
if tx_lower_ms < ready_at {
|
||||
return Err(DaoError::State(format!(
|
||||
"Voted lock for proposal #{} not past cooldown yet: \
|
||||
tx_lower_ms={} < createdAt({})+minStakeVotingTime({})={}",
|
||||
proposal_id, tx_lower_ms, posix_time, unlock_cooldown, ready_at
|
||||
)));
|
||||
}
|
||||
}
|
||||
voted_lock_to_retract = Some(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compute new datums ---------------------------------------------
|
||||
|
||||
// Filter `locked_by` per the validator's `premoveLocks` rule.
|
||||
let mut new_locks: Vec<ProposalLock> = Vec::with_capacity(args.stake_in.datum.locked_by.len());
|
||||
let mut locks_removed = 0usize;
|
||||
for lock in &args.stake_in.datum.locked_by {
|
||||
let keep = if lock.proposal_id != proposal_id {
|
||||
// Different proposal — keep.
|
||||
true
|
||||
} else {
|
||||
match (&mode, &lock.action) {
|
||||
// RemoveAll: drop everything for this proposal_id.
|
||||
(RetractMode::RemoveAllLocks, _) => false,
|
||||
// RemoveVoterOnly: drop only Voted locks.
|
||||
(RetractMode::RemoveVoterLockOnly, ProposalAction::Voted { .. }) => false,
|
||||
// Created/Cosigned in voter-only mode: keep.
|
||||
(RetractMode::RemoveVoterLockOnly, _) => true,
|
||||
}
|
||||
};
|
||||
if keep {
|
||||
new_locks.push(lock.clone());
|
||||
} else {
|
||||
locks_removed += 1;
|
||||
}
|
||||
}
|
||||
let new_stake = StakeDatum {
|
||||
staked_amount: args.stake_in.datum.staked_amount,
|
||||
owner: args.stake_in.datum.owner.clone(),
|
||||
delegated_to: args.stake_in.datum.delegated_to.clone(),
|
||||
locked_by: new_locks.clone(),
|
||||
};
|
||||
|
||||
// Build the new proposal datum. Two paths:
|
||||
// - VotingReady + in voting window AND we have a Voted lock to retract:
|
||||
// proposal.votes[voted_tag] -= stake.staked_amount.
|
||||
// - Otherwise: bit-identical copy of input datum.
|
||||
let mut vote_weight_retracted: i64 = 0;
|
||||
let new_proposal_datum = if proposal_datum_will_change {
|
||||
if let Some(ProposalLock {
|
||||
action: ProposalAction::Voted { result_tag, .. },
|
||||
..
|
||||
}) = voted_lock_to_retract.cloned()
|
||||
{
|
||||
// Subtract this stake's vote weight from the matching tag.
|
||||
let mut new_votes_inner = args.proposal.datum.votes.0.clone();
|
||||
let mut found = false;
|
||||
for (k, v) in new_votes_inner.iter_mut() {
|
||||
if *k == result_tag {
|
||||
let stake_amt = args.stake_in.datum.staked_amount;
|
||||
*v = v.checked_sub(stake_amt).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"vote retract underflow: votes[{result_tag}]={} - staked_amount={}",
|
||||
v, stake_amt
|
||||
))
|
||||
})?;
|
||||
vote_weight_retracted = stake_amt;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(DaoError::State(format!(
|
||||
"voted result_tag {} not present in proposal.votes — datum corruption?",
|
||||
result_tag
|
||||
)));
|
||||
}
|
||||
ProposalDatum {
|
||||
proposal_id,
|
||||
effects_raw: args.proposal.datum.effects_raw.clone(),
|
||||
status: args.proposal.datum.status,
|
||||
cosigners: args.proposal.datum.cosigners.clone(),
|
||||
thresholds: args.proposal.datum.thresholds.clone(),
|
||||
votes: ProposalVotes(new_votes_inner),
|
||||
timing_config: args.proposal.datum.timing_config.clone(),
|
||||
starting_time: args.proposal.datum.starting_time,
|
||||
}
|
||||
} else {
|
||||
// VotingReady + window-open but no Voted lock to retract — datum
|
||||
// must be unchanged per `shouldUpdateVotes` requiring votes to
|
||||
// ACTUALLY change ("Votes changed" trace). We fall through to
|
||||
// unchanged-datum path.
|
||||
args.proposal.datum.clone()
|
||||
}
|
||||
} else {
|
||||
args.proposal.datum.clone()
|
||||
};
|
||||
|
||||
// Re-evaluate whether the proposal datum is bit-identical or mutated.
|
||||
// If bit-identical, the validator takes the "Proposal unchanged" branch.
|
||||
let proposal_datum_actually_changed = vote_weight_retracted != 0;
|
||||
if proposal_datum_will_change && !proposal_datum_actually_changed {
|
||||
// VotingReady + in-window but no votes to retract — validator
|
||||
// still requires the votes-mutation branch (shouldUpdateVotes=true)
|
||||
// and "Votes changed" assertion will fail. Bail out client-side.
|
||||
return Err(DaoError::State(format!(
|
||||
"proposal #{} is VotingReady + in voting window but stake has no Voted lock — \
|
||||
validator requires votes to change in this branch. Wait until proposal \
|
||||
advances out of VotingReady (or window closes) before retracting Created/Cosigned.",
|
||||
proposal_id
|
||||
)));
|
||||
}
|
||||
|
||||
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
||||
let new_proposal_datum_pd = new_proposal_datum.to_plutus_data()?;
|
||||
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
|
||||
// ---- redeemers -------------------------------------------------------
|
||||
|
||||
let stake_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&StakeRedeemer::RetractVotes.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?;
|
||||
let proposal_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&ProposalRedeemer::UnlockStake.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?;
|
||||
|
||||
// ---- pick funding + collateral ---------------------------------------
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(
|
||||
"need a SECOND ada-only wallet UTxO to fund the spend (separate from collateral)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ---- balance + change ------------------------------------------------
|
||||
|
||||
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let total_in = args
|
||||
.stake_in
|
||||
.lovelace
|
||||
.checked_add(args.proposal.lovelace)
|
||||
.and_then(|x| x.checked_add(funding.lovelace))
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_stake_lovelace
|
||||
.checked_add(new_proposal_lovelace)
|
||||
.and_then(|x| x.checked_add(args.fee_lovelace))
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out}"
|
||||
))
|
||||
})?;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({WALLET_CHANGE_MIN_LOVELACE})"
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble pallas StagingTransaction ------------------------------
|
||||
|
||||
let stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
||||
let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"proposal_addr not set on DaoConfig — register or discover_scripts first".into(),
|
||||
)
|
||||
})?)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let proposal_input = Input::new(
|
||||
parse_tx_hash(&args.proposal.tx_hash_hex)?,
|
||||
args.proposal.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(
|
||||
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||
funding.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let proposal_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?,
|
||||
args.proposal_validator_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let stake_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.stake_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("stake_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let proposal_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.proposal_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
let new_stake_output = Output::new(stakes_addr, new_stake_lovelace)
|
||||
.set_inline_datum(new_stake_datum_cbor.clone())
|
||||
.add_asset(stake_st_policy_hash, stake_st_asset_name, 1)
|
||||
.and_then(|o| {
|
||||
o.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes,
|
||||
args.stake_in.gov_token_qty,
|
||||
)
|
||||
})
|
||||
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
||||
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
staging = staging.input(stake_input.clone());
|
||||
staging = staging.input(proposal_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(proposal_validator_ref_input);
|
||||
staging = staging.output(new_stake_output);
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(RETRACT_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_spend_redeemer(
|
||||
proposal_input,
|
||||
proposal_spend_redeemer_cbor,
|
||||
Some(RETRACT_SPEND_EX_UNITS),
|
||||
);
|
||||
|
||||
// Validity range. For Finished proposals, no window constraint applies
|
||||
// (proposal datum is unchanged, no `inVotingPeriod` check). For
|
||||
// VotingReady + window-open, we must stay within [voting_start,
|
||||
// voting_end] for the votes-mutation path. For other statuses, no
|
||||
// constraint. We use a wide-by-default range and the caller (MCP) can
|
||||
// narrow via tip-slot picking if needed.
|
||||
staging = staging.valid_from_slot(args.tip_slot);
|
||||
staging = staging.invalid_from_slot(args.tip_slot + VALIDITY_RANGE_SLOTS);
|
||||
|
||||
// Disclosed signer: voter pkh.
|
||||
let voter_pkh_arr: [u8; 28] = args.voter_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"voter_pkh must be 28 bytes, got {}",
|
||||
args.voter_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// V2 cost model — same fix as proposal_create / proposal_advance / etc.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let stake_still_locked_by_this_proposal =
|
||||
new_locks.iter().any(|l| l.proposal_id == proposal_id);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_retract_votes_unsigned: dao={} proposal_id={} mode={:?} \
|
||||
locks_removed={} vote_weight_retracted={} stake_still_locked={} fee={}",
|
||||
args.cfg.name,
|
||||
proposal_id,
|
||||
mode,
|
||||
locks_removed,
|
||||
vote_weight_retracted,
|
||||
stake_still_locked_by_this_proposal,
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalRetractVotes {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
proposal_id,
|
||||
locks_removed,
|
||||
vote_weight_retracted,
|
||||
stake_still_locked_by_this_proposal,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors Agora's `PRemoveLocksMode`. Selected by inspecting the proposal's
|
||||
/// status — not a caller-supplied arg, since picking the wrong mode would
|
||||
/// just get rejected by the validator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RetractMode {
|
||||
/// Drop only `Voted` locks for the proposal_id (and only those past
|
||||
/// cooldown). Kept: `Created`, `Cosigned`. Selected when proposal is
|
||||
/// not `Finished`.
|
||||
RemoveVoterLockOnly,
|
||||
/// Drop ALL locks for the proposal_id. Selected when proposal is
|
||||
/// `Finished` — the only path that lets a stake get fully unlocked
|
||||
/// after a proposal it created/cosigned has resolved.
|
||||
RemoveAllLocks,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::plutus_data::constr;
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn voter_pkh_bytes() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn sample_proposal_datum_finished() -> ProposalDatum {
|
||||
ProposalDatum {
|
||||
proposal_id: 7,
|
||||
effects_raw: constr(0, vec![]),
|
||||
status: ProposalStatus::Finished,
|
||||
cosigners: vec![Credential::PubKey(voter_pkh_bytes())],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 250), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
starting_time: 1_780_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_proposal_datum_voting_ready() -> ProposalDatum {
|
||||
let mut d = sample_proposal_datum_finished();
|
||||
d.proposal_id = 5;
|
||||
d.status = ProposalStatus::VotingReady;
|
||||
d
|
||||
}
|
||||
|
||||
fn sample_args(
|
||||
proposal_datum: ProposalDatum,
|
||||
stake_locks: Vec<ProposalLock>,
|
||||
validity_lower_ms: i64,
|
||||
) -> ProposalRetractVotesArgs {
|
||||
ProposalRetractVotesArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(),
|
||||
output_index: 1,
|
||||
lovelace: 5_000_000,
|
||||
gov_token_qty: 250,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 250,
|
||||
owner: Credential::PubKey(voter_pkh_bytes()),
|
||||
delegated_to: None,
|
||||
locked_by: stake_locks,
|
||||
},
|
||||
},
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(),
|
||||
output_index: 0,
|
||||
lovelace: 2_000_000,
|
||||
proposal_st_asset_name_hex: "".into(),
|
||||
datum: proposal_datum,
|
||||
},
|
||||
voter_pkh: voter_pkh_bytes(),
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
tip_slot: 180_062_536,
|
||||
validity_lower_ms,
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
proposal_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 1,
|
||||
},
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_proposal_drops_all_locks_for_id() {
|
||||
// Stake has Created lock for #7 (Finished) and Voted lock for #7,
|
||||
// plus Created for #99 (other proposal). Retract on #7 should drop
|
||||
// both #7 locks, keep #99.
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: 1_780_000_000_000,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 99,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.proposal_id, 7);
|
||||
assert_eq!(unsigned.locks_removed, 2);
|
||||
// Finished + datum unchanged → vote_weight_retracted reports 0 even
|
||||
// if a Voted lock was dropped, because we don't mutate the proposal.
|
||||
assert_eq!(unsigned.vote_weight_retracted, 0);
|
||||
assert!(!unsigned.stake_still_locked_by_this_proposal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voting_ready_in_window_subtracts_vote_weight() {
|
||||
// VotingReady proposal #5 with starting_time + draft = voting_start.
|
||||
// Pick validity_lower in the voting window. Stake has Voted lock on
|
||||
// #5 for tag 0. Retract should subtract 250 from votes[0] and
|
||||
// remove ONLY the Voted lock (Created locks for #5 stay).
|
||||
let proposal = sample_proposal_datum_voting_ready();
|
||||
let voting_start = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let validity_lower = voting_start + 60_000; // 1 min into voting window
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: voting_start - 30_000,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.proposal_id, 5);
|
||||
assert_eq!(unsigned.locks_removed, 1);
|
||||
assert_eq!(unsigned.vote_weight_retracted, 250);
|
||||
assert!(unsigned.stake_still_locked_by_this_proposal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_locks_for_proposal() {
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 99,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err.to_string().contains("no locks for proposal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voted_lock_in_cooldown() {
|
||||
// VotingReady but voter cooldown not yet elapsed. Locked status not
|
||||
// Finished → RemoveVoterLockOnly mode → cooldown applies.
|
||||
let mut proposal = sample_proposal_datum_voting_ready();
|
||||
proposal.status = ProposalStatus::Locked; // voting closed; not Finished
|
||||
let proposal_id = proposal.proposal_id;
|
||||
let voted_at = 1_780_000_500_000i64;
|
||||
let cooldown_ms = proposal.timing_config.min_stake_voting_time;
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 0,
|
||||
posix_time: voted_at,
|
||||
},
|
||||
}];
|
||||
let validity_lower = voted_at + cooldown_ms - 1; // 1 ms shy of cooldown
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err.to_string().contains("not past cooldown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voting_ready_in_window_without_voted_lock() {
|
||||
// VotingReady + in-window but stake only has Created lock — the
|
||||
// validator's "Votes changed" assertion would fail. Builder must
|
||||
// bail client-side.
|
||||
let proposal = sample_proposal_datum_voting_ready();
|
||||
let voting_start = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let validity_lower = voting_start + 60_000;
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 5,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("validator requires votes to change"),
|
||||
"unexpected err: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voter_neither_owner_nor_delegate() {
|
||||
let locks = vec![ProposalLock {
|
||||
proposal_id: 7,
|
||||
action: ProposalAction::Created,
|
||||
}];
|
||||
let mut args = sample_args(sample_proposal_datum_finished(), locks, 1_780_010_000_000);
|
||||
args.voter_pkh = vec![0xee; 28];
|
||||
let err = build_unsigned_proposal_retract_votes(args).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("neither stake owner nor delegatee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_status_after_voting_window_drops_voted_lock_only() {
|
||||
// Locked status → RemoveVoterLockOnly. Cooldown elapsed →
|
||||
// Voted lock dropped, Created kept. Proposal datum unchanged
|
||||
// (not VotingReady → shouldUpdateVotes = false).
|
||||
let mut proposal = sample_proposal_datum_voting_ready();
|
||||
proposal.status = ProposalStatus::Locked;
|
||||
let proposal_id = proposal.proposal_id;
|
||||
let voted_at = proposal.starting_time + proposal.timing_config.draft_time;
|
||||
let cooldown_ms = proposal.timing_config.min_stake_voting_time;
|
||||
let validity_lower = voted_at + cooldown_ms + 1_000;
|
||||
let locks = vec![
|
||||
ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 1,
|
||||
posix_time: voted_at,
|
||||
},
|
||||
},
|
||||
ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Created,
|
||||
},
|
||||
];
|
||||
let args = sample_args(proposal, locks, validity_lower);
|
||||
let unsigned = build_unsigned_proposal_retract_votes(args).expect("build");
|
||||
assert_eq!(unsigned.locks_removed, 1);
|
||||
assert_eq!(unsigned.vote_weight_retracted, 0); // proposal datum unchanged
|
||||
assert!(unsigned.stake_still_locked_by_this_proposal); // Created still there
|
||||
}
|
||||
}
|
||||
779
crates/aldabra-dao/src/builder/proposal_vote.rs
Normal file
779
crates/aldabra-dao/src/builder/proposal_vote.rs
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
//! Build a `dao_proposal_vote` transaction.
|
||||
//!
|
||||
//! This is the second DAO write path. The tx shape:
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The voter's stake UTxO (Plutus spend, redeemer = `PermitVote`).
|
||||
//! - The target proposal UTxO (Plutus spend, redeemer = `Vote(result_tag)`).
|
||||
//! - One wallet UTxO funding fees + min-UTxO for the new outputs.
|
||||
//! - **Collateral input**: a separate ADA-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**:
|
||||
//! - Stake validator script.
|
||||
//! - Proposal validator script.
|
||||
//! - **No mints** — vote is a state transition only.
|
||||
//! - **Outputs**:
|
||||
//! - New stake UTxO at `stakes_addr`. Datum = old StakeDatum with a
|
||||
//! fresh `Voted { result_tag, posix_time = validity_upper_ms }`
|
||||
//! lock **prepended** to `locked_by` (per Agora's `paddNewLock = pcons`).
|
||||
//! StakeST + gov-token quantity preserved.
|
||||
//! - New proposal UTxO at `proposal_addr`. Datum = old ProposalDatum
|
||||
//! with `votes[result_tag] += stake.staked_amount`. Everything else
|
||||
//! preserved bit-exact (validator does record `==`).
|
||||
//! ProposalST preserved.
|
||||
//! - Wallet change.
|
||||
//!
|
||||
//! ## What the validator enforces (must match)
|
||||
//!
|
||||
//! From `Agora/Proposal/Scripts.hs` `PVote` branch (~line 484):
|
||||
//!
|
||||
//! 1. Stakes input: at least one. Sum of staked_amount ≥ thresholds.vote.
|
||||
//! 2. None of the input stakes already have a Voted lock for this proposal.
|
||||
//! 3. Status is `VotingReady`.
|
||||
//! 4. Tx validity range fully inside `[starting_time + draft_time,
|
||||
//! starting_time + draft_time + voting_time]`.
|
||||
//! 5. Validity range width ≤ `voting_time_range_max_width` (Sulkta: 30min).
|
||||
//! 6. result_tag is a key already in `proposal.votes`.
|
||||
//! 7. Output proposal datum equals input datum with **only**
|
||||
//! `votes[result_tag] += sum(staked_amount)` and everything else identical.
|
||||
//!
|
||||
//! From `Agora/Stake/Redeemers.hs` `ppermitVote` (~line 196):
|
||||
//!
|
||||
//! 8. Owner OR delegatee signs.
|
||||
//! 9. Single stake input (`pisSingleton # ctxF.stakeInputDatums`).
|
||||
//! 10. Output stake datum equals input with only `locked_by` mutated to
|
||||
//! `pcons NEW_LOCK old_locks`.
|
||||
//! 11. The new lock's `posix_time` is the **upper bound** of validity range
|
||||
//! (`PFullyBoundedTimeRange _ upperBound`).
|
||||
//!
|
||||
//! ## What's NOT in v1
|
||||
//!
|
||||
//! - **Multi-stake voting** — per the validator, `pfoldMap` over stakes
|
||||
//! means several stakes can chip in to clear the threshold. v1 supports
|
||||
//! single-stake votes only (matches the `pisSingleton` check on the
|
||||
//! stake side anyway). Multi-stake bundling lands in Phase 4b alongside
|
||||
//! cosign.
|
||||
//! - **Delegated voting (`delegatedTo`)** — handled by the validator
|
||||
//! (`pisSignedBy True` accepts the delegatee), but the builder
|
||||
//! currently doesn't expose a "vote on someone else's stake" arg. Add
|
||||
//! later if real users want it.
|
||||
//! - **Vote retraction** — `RetractVotes` redeemer path is its own builder
|
||||
//! (Phase 4 follow-up).
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::proposal::{ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes};
|
||||
use crate::agora::stake::{Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_SPEND_EX_UNITS as VOTE_SPEND_EX_UNITS,
|
||||
SCRIPT_OUTPUT_MIN_LOVELACE,
|
||||
};
|
||||
|
||||
/// Wallet-change min-UTxO floor. Same value used in proposal_create.
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// On-chain proposal state we need to spend.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalUtxoIn {
|
||||
pub tx_hash_hex: String,
|
||||
pub output_index: u32,
|
||||
pub lovelace: u64,
|
||||
/// ProposalST asset name (hex). Empty for Sulkta convention.
|
||||
pub proposal_st_asset_name_hex: String,
|
||||
/// Current ProposalDatum on the UTxO.
|
||||
pub datum: ProposalDatum,
|
||||
}
|
||||
|
||||
/// Args bundle for [`build_unsigned_proposal_vote`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalVoteArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub stake_in: StakeUtxoIn,
|
||||
pub proposal: ProposalUtxoIn,
|
||||
/// Voter's payment-credential hash (28 bytes). Must equal stake's
|
||||
/// owner pkh OR stake's delegated_to pkh.
|
||||
pub voter_pkh: Vec<u8>,
|
||||
/// Result tag to vote for. Must already be a key in
|
||||
/// `proposal.datum.votes` (== `effects` keys per Agora compatibility).
|
||||
/// Sulkta InfoOnly: 0 = "yes", 1 = "no".
|
||||
pub result_tag: i64,
|
||||
/// Voter wallet's bech32 address (for change).
|
||||
pub change_address: String,
|
||||
/// Spendable wallet UTxOs.
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
/// Current chain tip slot. Sets `valid_from_slot(tip_slot)`.
|
||||
pub tip_slot: u64,
|
||||
/// Tx upper-bound slot. Sets `invalid_from_slot(validity_upper_slot)`.
|
||||
/// Caller may clamp this to (e.g.) the proposal's voting_end_slot to
|
||||
/// keep the validity range inside the voting window. MUST be consistent
|
||||
/// with `validity_upper_ms` — both should encode the SAME slot via the
|
||||
/// network's slot↔ms conversion. The chain's V2 ScriptContext computes
|
||||
/// `txInfo.validRange.upperBound` from this slot, and the validator's
|
||||
/// `ppermitVote` synthesizes the expected `Voted.posix_time` from
|
||||
/// THAT upper bound. If the slot underlying `validity_upper_ms` differs
|
||||
/// from this slot, the validator's `passert "Correct outputs"` fails.
|
||||
pub validity_upper_slot: u64,
|
||||
/// POSIX-ms equivalent of `validity_upper_slot`. Embedded as
|
||||
/// `Voted.posix_time` on the new stake lock — must match what the
|
||||
/// validator extracts from `PFullyBoundedTimeRange _ upperBound`.
|
||||
/// Caller is responsible for the slot↔ms conversion AND for ensuring
|
||||
/// `slot_to_posix_ms(validity_upper_slot) == validity_upper_ms`.
|
||||
pub validity_upper_ms: i64,
|
||||
/// Reference UTxO citing the stake validator script.
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
/// Reference UTxO citing the proposal validator script.
|
||||
pub proposal_validator_ref: ReferenceUtxo,
|
||||
/// Estimated total fee. Caller-supplied for v1.
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_proposal_vote`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedProposalVote {
|
||||
/// CBOR-hex of the unsigned tx body.
|
||||
pub tx_cbor_hex: String,
|
||||
/// Blake2b-256 hash of the tx body.
|
||||
pub tx_hash_hex: String,
|
||||
/// The proposal_id this tx votes on.
|
||||
pub proposal_id: i64,
|
||||
/// The result_tag voted for.
|
||||
pub result_tag: i64,
|
||||
/// The vote weight added (= stake.staked_amount).
|
||||
pub vote_weight: i64,
|
||||
/// Human-readable summary.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Build the unsigned proposal-vote tx.
|
||||
pub fn build_unsigned_proposal_vote(args: ProposalVoteArgs) -> DaoResult<UnsignedProposalVote> {
|
||||
let proposal_id = args.proposal.datum.proposal_id;
|
||||
|
||||
// ---- preflight checks ------------------------------------------------
|
||||
//
|
||||
// Catch every validator failure mode client-side. Each maps to one of
|
||||
// the numbered rules in the module docstring.
|
||||
|
||||
// (8) Voter must be owner or delegatee.
|
||||
let voter_is_owner = matches!(
|
||||
&args.stake_in.datum.owner,
|
||||
Credential::PubKey(h) if *h == args.voter_pkh
|
||||
);
|
||||
let voter_is_delegate = match &args.stake_in.datum.delegated_to {
|
||||
Some(Credential::PubKey(h)) => *h == args.voter_pkh,
|
||||
_ => false,
|
||||
};
|
||||
if !voter_is_owner && !voter_is_delegate {
|
||||
return Err(DaoError::State(
|
||||
"voter pkh is neither stake owner nor delegatee — cannot vote with this stake".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// (3) Proposal must be VotingReady.
|
||||
if args.proposal.datum.status != ProposalStatus::VotingReady {
|
||||
return Err(DaoError::State(format!(
|
||||
"proposal #{} status is {:?}, must be VotingReady to vote",
|
||||
proposal_id, args.proposal.datum.status
|
||||
)));
|
||||
}
|
||||
|
||||
// (2) Stake must not have already voted on this proposal. Per
|
||||
// `pisVoter # pgetStakeRoles`, a stake "is a voter" if any
|
||||
// ProposalLock for proposal_id has a Voted action.
|
||||
let already_voted =
|
||||
args.stake_in.datum.locked_by.iter().any(|l| {
|
||||
l.proposal_id == proposal_id && matches!(l.action, ProposalAction::Voted { .. })
|
||||
});
|
||||
if already_voted {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake already has a Voted lock for proposal #{} — \
|
||||
same stake cannot vote on the same proposal twice",
|
||||
proposal_id
|
||||
)));
|
||||
}
|
||||
|
||||
// (1) Stake must clear the vote threshold on its own (single-stake v1).
|
||||
let vote_threshold = args.proposal.datum.thresholds.vote;
|
||||
if args.stake_in.datum.staked_amount < vote_threshold {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake amount {} < proposal vote threshold {} — \
|
||||
multi-stake voting not yet implemented",
|
||||
args.stake_in.datum.staked_amount, vote_threshold
|
||||
)));
|
||||
}
|
||||
|
||||
// (6) result_tag must be a key in proposal.votes.
|
||||
let mut found_tag_idx: Option<usize> = None;
|
||||
for (idx, (k, _)) in args.proposal.datum.votes.0.iter().enumerate() {
|
||||
if *k == args.result_tag {
|
||||
found_tag_idx = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let tag_idx = found_tag_idx.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"result_tag {} is not a valid vote option for proposal #{} — keys are {:?}",
|
||||
args.result_tag,
|
||||
proposal_id,
|
||||
args.proposal
|
||||
.datum
|
||||
.votes
|
||||
.0
|
||||
.iter()
|
||||
.map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// (4) Validity range must lie inside voting window.
|
||||
//
|
||||
// Voting window in POSIX-ms: [starting_time + draft_time,
|
||||
// starting_time + draft_time + voting_time].
|
||||
// We set tx upper bound to `validity_upper_ms`; lower bound is implicit
|
||||
// from tip_slot but we ALSO cross-check window membership client-side
|
||||
// since a misconfigured caller (vote_time outside window) wastes ~5 ADA.
|
||||
let voting_start_ms =
|
||||
args.proposal.datum.starting_time + args.proposal.datum.timing_config.draft_time;
|
||||
let voting_end_ms = voting_start_ms + args.proposal.datum.timing_config.voting_time;
|
||||
if args.validity_upper_ms < voting_start_ms || args.validity_upper_ms > voting_end_ms {
|
||||
return Err(DaoError::State(format!(
|
||||
"validity_upper_ms {} outside voting window [{}, {}] for proposal #{} — \
|
||||
voting opens {} ms after proposal start",
|
||||
args.validity_upper_ms,
|
||||
voting_start_ms,
|
||||
voting_end_ms,
|
||||
proposal_id,
|
||||
args.proposal.datum.timing_config.draft_time,
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- pick funding + collateral ---------------------------------------
|
||||
//
|
||||
// Same shape as proposal_create: smallest ada-only utxo ≥ 5 ADA is
|
||||
// collateral; a separate ada-only utxo is funding.
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let funding = ada_only
|
||||
.iter()
|
||||
.find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(
|
||||
"need a SECOND ada-only wallet UTxO to fund the spend (separate from collateral)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// ---- compute new datums ---------------------------------------------
|
||||
|
||||
// Stake: prepend the new Voted lock (matches `paddNewLock = pcons`).
|
||||
let mut new_locks = Vec::with_capacity(args.stake_in.datum.locked_by.len() + 1);
|
||||
new_locks.push(ProposalLock {
|
||||
proposal_id,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: args.result_tag,
|
||||
posix_time: args.validity_upper_ms,
|
||||
},
|
||||
});
|
||||
new_locks.extend(args.stake_in.datum.locked_by.iter().cloned());
|
||||
let new_stake = StakeDatum {
|
||||
staked_amount: args.stake_in.datum.staked_amount,
|
||||
owner: args.stake_in.datum.owner.clone(),
|
||||
delegated_to: args.stake_in.datum.delegated_to.clone(),
|
||||
locked_by: new_locks,
|
||||
};
|
||||
|
||||
// Proposal: votes[result_tag] += stake.staked_amount, all else unchanged.
|
||||
let mut new_votes_inner = args.proposal.datum.votes.0.clone();
|
||||
new_votes_inner[tag_idx].1 = new_votes_inner[tag_idx]
|
||||
.1
|
||||
.checked_add(args.stake_in.datum.staked_amount)
|
||||
.ok_or_else(|| DaoError::State("vote count overflow on add".into()))?;
|
||||
|
||||
let new_proposal = ProposalDatum {
|
||||
proposal_id,
|
||||
effects_raw: args.proposal.datum.effects_raw.clone(),
|
||||
status: args.proposal.datum.status,
|
||||
cosigners: args.proposal.datum.cosigners.clone(),
|
||||
thresholds: args.proposal.datum.thresholds.clone(),
|
||||
votes: ProposalVotes(new_votes_inner),
|
||||
timing_config: args.proposal.datum.timing_config.clone(),
|
||||
starting_time: args.proposal.datum.starting_time,
|
||||
};
|
||||
|
||||
let new_stake_datum_pd = new_stake.to_plutus_data()?;
|
||||
let new_proposal_datum_pd = new_proposal.to_plutus_data()?;
|
||||
let new_stake_datum_cbor = minicbor::to_vec(&new_stake_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new stake datum encode: {e}")))?;
|
||||
let new_proposal_datum_cbor = minicbor::to_vec(&new_proposal_datum_pd)
|
||||
.map_err(|e| DaoError::Cbor(format!("new proposal datum encode: {e}")))?;
|
||||
|
||||
// ---- redeemers -------------------------------------------------------
|
||||
|
||||
let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::PermitVote.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?;
|
||||
let proposal_spend_redeemer_cbor =
|
||||
minicbor::to_vec(&ProposalRedeemer::Vote(args.result_tag).to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("proposal redeemer encode: {e}")))?;
|
||||
|
||||
// ---- balance + change ------------------------------------------------
|
||||
//
|
||||
// total_in = stake + proposal + funding (collateral held separately).
|
||||
// outputs = new_stake + new_proposal + change.
|
||||
|
||||
let new_stake_lovelace = args.stake_in.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
let new_proposal_lovelace = args.proposal.lovelace.max(SCRIPT_OUTPUT_MIN_LOVELACE);
|
||||
|
||||
let total_in = args
|
||||
.stake_in
|
||||
.lovelace
|
||||
.checked_add(args.proposal.lovelace)
|
||||
.and_then(|x| x.checked_add(funding.lovelace))
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let total_out = new_stake_lovelace
|
||||
.checked_add(new_proposal_lovelace)
|
||||
.and_then(|x| x.checked_add(args.fee_lovelace))
|
||||
.ok_or_else(|| DaoError::State("output lovelace overflow".into()))?;
|
||||
let change_lovelace = total_in.checked_sub(total_out).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need={total_out} \
|
||||
(stake_out={new_stake_lovelace} + proposal_out={new_proposal_lovelace} + \
|
||||
fee={})",
|
||||
args.fee_lovelace
|
||||
))
|
||||
})?;
|
||||
if change_lovelace > 0 && change_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"change lovelace {change_lovelace} below min UTxO ({}); top up wallet",
|
||||
WALLET_CHANGE_MIN_LOVELACE
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble pallas StagingTransaction ------------------------------
|
||||
|
||||
let stakes_addr = parse_address(&args.cfg.stakes_addr)?;
|
||||
let proposal_addr = parse_address(args.cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"proposal_addr not set on DaoConfig — register or discover_scripts first".into(),
|
||||
)
|
||||
})?)?;
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let proposal_input = Input::new(
|
||||
parse_tx_hash(&args.proposal.tx_hash_hex)?,
|
||||
args.proposal.output_index as u64,
|
||||
);
|
||||
let funding_input = Input::new(
|
||||
parse_tx_hash(&funding.tx_hash_hex)?,
|
||||
funding.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let proposal_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.proposal_validator_ref.tx_hash_hex)?,
|
||||
args.proposal_validator_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let stake_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.stake_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("stake_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let proposal_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.proposal_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("proposal_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let proposal_st_asset_name = hex::decode(&args.proposal.proposal_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("proposal_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
// New stake output: same address, same StakeST + same gov-token qty,
|
||||
// updated datum.
|
||||
let new_stake_output = Output::new(stakes_addr, new_stake_lovelace)
|
||||
.set_inline_datum(new_stake_datum_cbor.clone())
|
||||
.add_asset(stake_st_policy_hash, stake_st_asset_name, 1)
|
||||
.and_then(|o| {
|
||||
o.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes,
|
||||
args.stake_in.gov_token_qty,
|
||||
)
|
||||
})
|
||||
.map_err(|e| DaoError::Backend(format!("add stake-output assets: {e}")))?;
|
||||
|
||||
// New proposal output: same address, same ProposalST, updated datum.
|
||||
let new_proposal_output = Output::new(proposal_addr, new_proposal_lovelace)
|
||||
.set_inline_datum(new_proposal_datum_cbor.clone())
|
||||
.add_asset(proposal_st_policy_hash, proposal_st_asset_name, 1)
|
||||
.map_err(|e| DaoError::Backend(format!("add proposal_st asset: {e}")))?;
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
// 3 regular inputs: stake (script), proposal (script), funding (wallet).
|
||||
staging = staging.input(stake_input.clone());
|
||||
staging = staging.input(proposal_input.clone());
|
||||
staging = staging.input(funding_input.clone());
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
// 2 reference inputs: stake + proposal validators.
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(proposal_validator_ref_input);
|
||||
// Outputs: new stake, new proposal, then change if any.
|
||||
staging = staging.output(new_stake_output);
|
||||
staging = staging.output(new_proposal_output);
|
||||
|
||||
if change_lovelace > 0 {
|
||||
let mut change_output = Output::new(change_addr, change_lovelace);
|
||||
for (policy_hex, name_hex, qty) in &funding.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
change_output = change_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to change: {e}")))?;
|
||||
}
|
||||
staging = staging.output(change_output);
|
||||
}
|
||||
|
||||
// Two plutus contract spends: stake (PermitVote) + proposal (Vote tag).
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(VOTE_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_spend_redeemer(
|
||||
proposal_input,
|
||||
proposal_spend_redeemer_cbor,
|
||||
Some(VOTE_SPEND_EX_UNITS),
|
||||
);
|
||||
|
||||
// Validity range — must be inside voting window (already preflighted)
|
||||
// AND its width must be ≤ votingTimeRangeMaxWidth. The TTL uses
|
||||
// `validity_upper_slot` (which the caller may have clamped) so the
|
||||
// chain's reconstructed `txInfo.validRange.upperBound` matches the
|
||||
// `validity_upper_ms` we embedded in the new `Voted` lock — otherwise
|
||||
// `ppermitVote`'s `passert "Correct outputs"` would crash silently.
|
||||
staging = staging.valid_from_slot(args.tip_slot);
|
||||
staging = staging.invalid_from_slot(args.validity_upper_slot);
|
||||
|
||||
// Disclosed signer: voter pkh. The validator's `pisSignedBy` checks
|
||||
// this against `txInfoSignatories`.
|
||||
let voter_pkh_arr: [u8; 28] = args.voter_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"voter_pkh must be 28 bytes, got {}",
|
||||
args.voter_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(voter_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// Wire V2 cost model — same fix as proposal_create / proposal_advance / proposal_cosign.
|
||||
// Without this the chain rejects with PPViewHashesDontMatch.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let summary = format!(
|
||||
"dao_proposal_vote_unsigned: dao={} proposal_id={} result_tag={} weight={} voter_pkh={} fee={}",
|
||||
args.cfg.name,
|
||||
proposal_id,
|
||||
args.result_tag,
|
||||
args.stake_in.datum.staked_amount,
|
||||
hex::encode(&args.voter_pkh),
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedProposalVote {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
proposal_id,
|
||||
result_tag: args.result_tag,
|
||||
vote_weight: args.stake_in.datum.staked_amount,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
// `parse_address` / `parse_tx_hash` / `parse_script_hash` are imported from
|
||||
// the proposal_create module — they are pub(super) helpers there.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::plutus_data::constr;
|
||||
use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig};
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn voter_pkh_bytes() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn sample_proposal_datum() -> ProposalDatum {
|
||||
ProposalDatum {
|
||||
proposal_id: 1,
|
||||
effects_raw: constr(0, vec![]),
|
||||
status: ProposalStatus::VotingReady,
|
||||
cosigners: vec![Credential::PubKey(voter_pkh_bytes())],
|
||||
thresholds: ProposalThresholds {
|
||||
execute: 20,
|
||||
create: 100,
|
||||
to_voting: 100,
|
||||
vote: 1,
|
||||
cosign: 1,
|
||||
},
|
||||
votes: ProposalVotes(vec![(0, 0), (1, 0)]),
|
||||
timing_config: ProposalTimingConfig {
|
||||
draft_time: 7 * 86400 * 1000,
|
||||
voting_time: 7 * 86400 * 1000,
|
||||
locking_time: 48 * 3600 * 1000,
|
||||
executing_time: 24 * 3600 * 1000,
|
||||
min_stake_voting_time: 60 * 60 * 1000,
|
||||
voting_time_range_max_width: 30 * 60 * 1000,
|
||||
},
|
||||
starting_time: 1_780_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_args() -> ProposalVoteArgs {
|
||||
let starting_time_ms: i64 = 1_780_000_000_000;
|
||||
let draft_ms: i64 = 7 * 86_400 * 1000;
|
||||
// Pick a vote upper-bound 1h into the voting window — well inside.
|
||||
let validity_upper_ms = starting_time_ms + draft_ms + 60 * 60 * 1000;
|
||||
|
||||
ProposalVoteArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: Some(
|
||||
"addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40".into(),
|
||||
),
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: Some(
|
||||
"9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(),
|
||||
),
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(),
|
||||
output_index: 1,
|
||||
lovelace: 1_555_910,
|
||||
gov_token_qty: 250,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 250,
|
||||
owner: Credential::PubKey(voter_pkh_bytes()),
|
||||
delegated_to: None,
|
||||
locked_by: vec![],
|
||||
},
|
||||
},
|
||||
proposal: ProposalUtxoIn {
|
||||
tx_hash_hex: "1111111111111111111111111111111111111111111111111111111111111111".into(),
|
||||
output_index: 0,
|
||||
lovelace: 2_000_000,
|
||||
proposal_st_asset_name_hex: "".into(),
|
||||
datum: sample_proposal_datum(),
|
||||
},
|
||||
voter_pkh: voter_pkh_bytes(),
|
||||
result_tag: 0,
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
tip_slot: 180_062_536,
|
||||
// Derive from validity_upper_ms via mainnet shelley_zero.
|
||||
// shelley_zero = (4_492_800, 1_596_059_091_000).
|
||||
// slot = 4_492_800 + (validity_upper_ms - 1_596_059_091_000) / 1000.
|
||||
validity_upper_slot: 4_492_800
|
||||
+ ((validity_upper_ms - 1_596_059_091_000) / 1000) as u64,
|
||||
validity_upper_ms,
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
proposal_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000001".into(),
|
||||
output_index: 1,
|
||||
},
|
||||
fee_lovelace: 2_500_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_unsigned_vote_for_sulkta() {
|
||||
let unsigned = build_unsigned_proposal_vote(sample_args()).unwrap();
|
||||
assert_eq!(unsigned.proposal_id, 1);
|
||||
assert_eq!(unsigned.result_tag, 0);
|
||||
assert_eq!(unsigned.vote_weight, 250);
|
||||
assert!(!unsigned.tx_cbor_hex.is_empty());
|
||||
assert_eq!(unsigned.tx_hash_hex.len(), 64);
|
||||
assert!(unsigned.summary.contains("sulkta"));
|
||||
assert!(unsigned.summary.contains("result_tag=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_when_proposal_not_voting_ready() {
|
||||
let mut args = sample_args();
|
||||
args.proposal.datum.status = ProposalStatus::Draft;
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err.to_string().contains("VotingReady"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_double_vote_on_same_proposal() {
|
||||
let mut args = sample_args();
|
||||
args.stake_in.datum.locked_by.push(ProposalLock {
|
||||
proposal_id: 1,
|
||||
action: ProposalAction::Voted {
|
||||
result_tag: 1,
|
||||
posix_time: 1_780_001_000_000,
|
||||
},
|
||||
});
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err.to_string().contains("already has a Voted lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_result_tag() {
|
||||
let mut args = sample_args();
|
||||
args.result_tag = 99;
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err.to_string().contains("not a valid vote option"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_below_vote_threshold() {
|
||||
let mut args = sample_args();
|
||||
args.stake_in.datum.staked_amount = 0;
|
||||
args.proposal.datum.thresholds.vote = 100;
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err.to_string().contains("vote threshold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_validity_outside_voting_window() {
|
||||
let mut args = sample_args();
|
||||
// Move upper bound BEFORE draft window ends.
|
||||
args.validity_upper_ms = args.proposal.datum.starting_time + 60 * 1000;
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err.to_string().contains("voting window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_voter_neither_owner_nor_delegate() {
|
||||
let mut args = sample_args();
|
||||
args.voter_pkh = vec![0xee; 28];
|
||||
let err = build_unsigned_proposal_vote(args).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("neither stake owner nor delegatee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_voter_accepted() {
|
||||
let mut args = sample_args();
|
||||
let owner_pkh = vec![0xab; 28];
|
||||
let delegate_pkh = vec![0xcd; 28];
|
||||
args.stake_in.datum.owner = Credential::PubKey(owner_pkh);
|
||||
args.stake_in.datum.delegated_to = Some(Credential::PubKey(delegate_pkh.clone()));
|
||||
args.voter_pkh = delegate_pkh;
|
||||
// Should succeed.
|
||||
let unsigned = build_unsigned_proposal_vote(args).unwrap();
|
||||
assert_eq!(unsigned.result_tag, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vote_increments_correct_tag() {
|
||||
let mut args = sample_args();
|
||||
args.result_tag = 1;
|
||||
// Pre-set non-zero counts so we can spot the increment.
|
||||
args.proposal.datum.votes = ProposalVotes(vec![(0, 5), (1, 10)]);
|
||||
// Decode the new datum from the built tx? Too invasive — instead
|
||||
// re-run the increment logic and check the expected value lands.
|
||||
let stake_amt = args.stake_in.datum.staked_amount;
|
||||
let unsigned = build_unsigned_proposal_vote(args.clone()).unwrap();
|
||||
// stake_amt was 250 → tag(1) becomes 10 + 250 = 260
|
||||
let _ = stake_amt;
|
||||
assert_eq!(unsigned.vote_weight, 250);
|
||||
// Built CBOR is opaque without a full re-decode; the unit checks
|
||||
// above + the build-success at minimum prove the path runs.
|
||||
assert_eq!(unsigned.result_tag, 1);
|
||||
}
|
||||
}
|
||||
403
crates/aldabra-dao/src/builder/stake_destroy.rs
Normal file
403
crates/aldabra-dao/src/builder/stake_destroy.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
//! Build a `dao_stake_destroy` transaction.
|
||||
//!
|
||||
//! Destroys a stake UTxO, burning its StakeST token and returning the
|
||||
//! locked governance tokens (TRP for Sulkta) + lovelace to the owner's
|
||||
//! wallet.
|
||||
//!
|
||||
//! ## Tx shape
|
||||
//!
|
||||
//! - **Inputs**:
|
||||
//! - The stake UTxO to destroy (Plutus spend, redeemer = `Destroy`).
|
||||
//! - Optionally a funding wallet UTxO (the stake's lovelace itself
|
||||
//! usually covers fees, so we make funding optional via collateral
|
||||
//! selection — caller still needs ≥5 ADA collateral).
|
||||
//! - **Collateral**: ADA-only ≥5 ADA wallet UTxO.
|
||||
//! - **Reference inputs**: stake validator + StakeST minting policy.
|
||||
//! - **Mint**: -1 StakeST (asset name = stake validator script hash).
|
||||
//! - **Outputs**: a single wallet output carrying `(stake.lovelace + funding -
|
||||
//! fee)` ADA + the gov-token quantity that was locked.
|
||||
//!
|
||||
//! ## What the validator enforces
|
||||
//!
|
||||
//! From `Agora/Stake/Redeemers.hs` `pdestroy` (~L432):
|
||||
//!
|
||||
//! 1. Owner signs (`pisSignedBy False` — delegatees rejected).
|
||||
//! 2. Stake is unlocked (`locked_by` is empty / no Created-or-Voted-or-Cosigned).
|
||||
//! 3. No stake UTxO at `stakes_addr` in outputs (= the stake is burnt).
|
||||
//!
|
||||
//! From `Agora/Stake/Scripts.hs` `stakePolicy` burn branch (~L161):
|
||||
//!
|
||||
//! 4. `burntST == -spentST` — quantity burnt equals what's input.
|
||||
//! Single-stake destroy means burn -1.
|
||||
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_txbuilder::{BuildConway, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::agora::stake::{Credential, StakeRedeemer};
|
||||
use crate::config::{DaoConfig, DaoNetwork};
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
use super::proposal_create::{
|
||||
parse_address, parse_script_hash, parse_tx_hash, ReferenceUtxo, StakeUtxoIn, WalletUtxo,
|
||||
MIN_COLLATERAL_LOVELACE, PROPOSAL_CREATE_MINT_EX_UNITS as DESTROY_MINT_EX_UNITS,
|
||||
PROPOSAL_CREATE_SPEND_EX_UNITS as DESTROY_SPEND_EX_UNITS,
|
||||
};
|
||||
|
||||
const WALLET_CHANGE_MIN_LOVELACE: u64 = 1_000_000;
|
||||
|
||||
/// Args bundle for [`build_unsigned_stake_destroy`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StakeDestroyArgs {
|
||||
pub cfg: DaoConfig,
|
||||
pub stake_in: StakeUtxoIn,
|
||||
/// Owner's payment-credential hash. Must match `stake.owner` per
|
||||
/// validator (delegatees rejected).
|
||||
pub owner_pkh: Vec<u8>,
|
||||
pub change_address: String,
|
||||
pub wallet_utxos: Vec<WalletUtxo>,
|
||||
pub stake_validator_ref: ReferenceUtxo,
|
||||
pub stake_st_policy_ref: ReferenceUtxo,
|
||||
pub fee_lovelace: u64,
|
||||
}
|
||||
|
||||
/// What [`build_unsigned_stake_destroy`] returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsignedStakeDestroy {
|
||||
pub tx_cbor_hex: String,
|
||||
pub tx_hash_hex: String,
|
||||
/// How much gov-token quantity returns to the wallet.
|
||||
pub returned_gov_token_qty: u64,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
pub fn build_unsigned_stake_destroy(args: StakeDestroyArgs) -> DaoResult<UnsignedStakeDestroy> {
|
||||
// ---- preflight ------------------------------------------------------
|
||||
|
||||
if !matches!(&args.stake_in.datum.owner, Credential::PubKey(h) if *h == args.owner_pkh) {
|
||||
return Err(DaoError::State(
|
||||
"owner pkh must equal stake.owner — delegatees cannot destroy".into(),
|
||||
));
|
||||
}
|
||||
if !args.stake_in.datum.locked_by.is_empty() {
|
||||
return Err(DaoError::State(format!(
|
||||
"stake has {} active lock(s); destroy requires unlocked stake — \
|
||||
retract votes or wait for proposals to finish first",
|
||||
args.stake_in.datum.locked_by.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- pick collateral ------------------------------------------------
|
||||
//
|
||||
// Destroy doesn't strictly need extra funding — the stake utxo itself
|
||||
// brings ~1.5 ADA which usually covers fees + min-utxo of the wallet
|
||||
// output. But we still need a separate ADA-only collateral.
|
||||
|
||||
let mut ada_only: Vec<WalletUtxo> = args
|
||||
.wallet_utxos
|
||||
.iter()
|
||||
.filter(|u| u.is_ada_only())
|
||||
.cloned()
|
||||
.collect();
|
||||
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
||||
|
||||
let collateral = ada_only
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|u| u.lovelace >= MIN_COLLATERAL_LOVELACE)
|
||||
.ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"no ada-only wallet UTxO ≥ {} lovelace for collateral",
|
||||
MIN_COLLATERAL_LOVELACE
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
|
||||
// Optional funding utxo: pick if there's a separate one. If only one
|
||||
// ada-only utxo and it's used as collateral, we don't add funding —
|
||||
// the stake's own ada covers the fee.
|
||||
let funding = ada_only.iter().find(|u| {
|
||||
!(u.tx_hash_hex == collateral.tx_hash_hex && u.output_index == collateral.output_index)
|
||||
});
|
||||
|
||||
// ---- redeemers ------------------------------------------------------
|
||||
|
||||
let stake_spend_redeemer_cbor = minicbor::to_vec(&StakeRedeemer::Destroy.to_plutus_data()?)
|
||||
.map_err(|e| DaoError::Cbor(format!("stake redeemer encode: {e}")))?;
|
||||
let mint_redeemer_cbor = minicbor::to_vec(crate::agora::plutus_data::constr(0, vec![]))
|
||||
.map_err(|e| DaoError::Cbor(format!("mint redeemer encode: {e}")))?;
|
||||
|
||||
// ---- balance --------------------------------------------------------
|
||||
|
||||
let funding_lovelace = funding.map(|f| f.lovelace).unwrap_or(0);
|
||||
let total_in = args
|
||||
.stake_in
|
||||
.lovelace
|
||||
.checked_add(funding_lovelace)
|
||||
.ok_or_else(|| DaoError::State("input lovelace overflow".into()))?;
|
||||
let wallet_out_lovelace = total_in.checked_sub(args.fee_lovelace).ok_or_else(|| {
|
||||
DaoError::State(format!(
|
||||
"insufficient input: total_in={total_in} need fee={}",
|
||||
args.fee_lovelace
|
||||
))
|
||||
})?;
|
||||
if wallet_out_lovelace < WALLET_CHANGE_MIN_LOVELACE {
|
||||
return Err(DaoError::State(format!(
|
||||
"wallet output lovelace {} below min ({}); add a funding utxo",
|
||||
wallet_out_lovelace, WALLET_CHANGE_MIN_LOVELACE
|
||||
)));
|
||||
}
|
||||
|
||||
// ---- assemble -------------------------------------------------------
|
||||
|
||||
let change_addr = parse_address(&args.change_address)?;
|
||||
|
||||
let stake_input = Input::new(
|
||||
parse_tx_hash(&args.stake_in.tx_hash_hex)?,
|
||||
args.stake_in.output_index as u64,
|
||||
);
|
||||
let collateral_input = Input::new(
|
||||
parse_tx_hash(&collateral.tx_hash_hex)?,
|
||||
collateral.output_index as u64,
|
||||
);
|
||||
let stake_validator_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_validator_ref.tx_hash_hex)?,
|
||||
args.stake_validator_ref.output_index as u64,
|
||||
);
|
||||
let stake_st_policy_ref_input = Input::new(
|
||||
parse_tx_hash(&args.stake_st_policy_ref.tx_hash_hex)?,
|
||||
args.stake_st_policy_ref.output_index as u64,
|
||||
);
|
||||
|
||||
let stake_st_policy_hash = parse_script_hash(
|
||||
args.cfg
|
||||
.stake_st_policy
|
||||
.as_deref()
|
||||
.ok_or_else(|| DaoError::Config("stake_st_policy not set on DaoConfig".into()))?,
|
||||
)?;
|
||||
let stake_st_asset_name = hex::decode(&args.stake_in.stake_st_asset_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("stake_st_asset_name_hex decode: {e}")))?;
|
||||
let gov_token_policy_hash = parse_script_hash(&args.cfg.gov_token_policy)?;
|
||||
let gov_token_name_bytes = hex::decode(&args.cfg.gov_token_name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("gov_token_name_hex decode: {e}")))?;
|
||||
|
||||
let network_id = match args.cfg.network {
|
||||
DaoNetwork::Mainnet => 1u8,
|
||||
DaoNetwork::Preprod | DaoNetwork::Preview => 0u8,
|
||||
};
|
||||
|
||||
// The wallet output: gov-tokens unlocked + lovelace residue.
|
||||
let mut wallet_output = Output::new(change_addr, wallet_out_lovelace);
|
||||
if args.stake_in.gov_token_qty > 0 {
|
||||
wallet_output = wallet_output
|
||||
.add_asset(
|
||||
gov_token_policy_hash,
|
||||
gov_token_name_bytes,
|
||||
args.stake_in.gov_token_qty,
|
||||
)
|
||||
.map_err(|e| DaoError::Backend(format!("add gov-token to wallet output: {e}")))?;
|
||||
}
|
||||
// Re-emit any native assets the funding utxo brought along.
|
||||
if let Some(f) = funding {
|
||||
for (policy_hex, name_hex, qty) in &f.assets {
|
||||
let policy = parse_script_hash(policy_hex)?;
|
||||
let name = hex::decode(name_hex)
|
||||
.map_err(|e| DaoError::Config(format!("asset name hex decode: {e}")))?;
|
||||
wallet_output = wallet_output
|
||||
.add_asset(policy, name, *qty)
|
||||
.map_err(|e| DaoError::Backend(format!("add asset to wallet output: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut staging = StagingTransaction::new();
|
||||
staging = staging.input(stake_input.clone());
|
||||
if let Some(f) = funding {
|
||||
staging = staging.input(Input::new(
|
||||
parse_tx_hash(&f.tx_hash_hex)?,
|
||||
f.output_index as u64,
|
||||
));
|
||||
}
|
||||
staging = staging.collateral_input(collateral_input);
|
||||
staging = staging.reference_input(stake_validator_ref_input);
|
||||
staging = staging.reference_input(stake_st_policy_ref_input);
|
||||
staging = staging.output(wallet_output);
|
||||
|
||||
// Burn -1 StakeST.
|
||||
staging = staging
|
||||
.mint_asset(stake_st_policy_hash, stake_st_asset_name, -1)
|
||||
.map_err(|e| DaoError::Backend(format!("mint_asset (burn): {e}")))?;
|
||||
|
||||
staging = staging.add_spend_redeemer(
|
||||
stake_input,
|
||||
stake_spend_redeemer_cbor,
|
||||
Some(DESTROY_SPEND_EX_UNITS),
|
||||
);
|
||||
staging = staging.add_mint_redeemer(
|
||||
stake_st_policy_hash,
|
||||
mint_redeemer_cbor,
|
||||
Some(DESTROY_MINT_EX_UNITS),
|
||||
);
|
||||
|
||||
let owner_pkh_arr: [u8; 28] = args.owner_pkh.as_slice().try_into().map_err(|_| {
|
||||
DaoError::Datum(format!(
|
||||
"owner_pkh must be 28 bytes, got {}",
|
||||
args.owner_pkh.len()
|
||||
))
|
||||
})?;
|
||||
staging = staging.disclosed_signer(Hash::<28>::from(owner_pkh_arr));
|
||||
|
||||
staging = staging.fee(args.fee_lovelace).network_id(network_id);
|
||||
|
||||
// Wire V2 cost model — same fix as the proposal builders.
|
||||
// stake_validator + stakeSt policy are PlutusV2.
|
||||
staging = staging.language_view(
|
||||
ScriptKind::PlutusV2,
|
||||
aldabra_core::plutus_cost_models::PLUTUS_V2_COST_MODEL_PREPROD.to_vec(),
|
||||
);
|
||||
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| DaoError::Backend(format!("build_conway_raw: {e}")))?;
|
||||
|
||||
let tx_cbor_hex = hex::encode(&built.tx_bytes.0);
|
||||
let tx_hash_hex = hex::encode(built.tx_hash.0);
|
||||
|
||||
let summary = format!(
|
||||
"dao_stake_destroy_unsigned: dao={} returned_gov_token_qty={} owner_pkh={} fee={}",
|
||||
args.cfg.name,
|
||||
args.stake_in.gov_token_qty,
|
||||
hex::encode(&args.owner_pkh),
|
||||
args.fee_lovelace,
|
||||
);
|
||||
|
||||
Ok(UnsignedStakeDestroy {
|
||||
tx_cbor_hex,
|
||||
tx_hash_hex,
|
||||
returned_gov_token_qty: args.stake_in.gov_token_qty,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agora::stake::{ProposalAction, ProposalLock, StakeDatum};
|
||||
use crate::config::ScriptRefs;
|
||||
|
||||
fn owner_pkh() -> Vec<u8> {
|
||||
hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap()
|
||||
}
|
||||
|
||||
fn sample_args() -> StakeDestroyArgs {
|
||||
StakeDestroyArgs {
|
||||
cfg: DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0".into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: None,
|
||||
stake_st_policy: Some(
|
||||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: None,
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
tx_hash_hex: "0823a9406da1a62743c3f4edd0ffcfb3ad6fda02cb312825d29fe655c53564a6".into(),
|
||||
output_index: 1,
|
||||
lovelace: 1_555_910,
|
||||
gov_token_qty: 250,
|
||||
stake_st_asset_name_hex:
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4".into(),
|
||||
datum: StakeDatum {
|
||||
staked_amount: 250,
|
||||
owner: Credential::PubKey(owner_pkh()),
|
||||
delegated_to: None,
|
||||
locked_by: vec![],
|
||||
},
|
||||
},
|
||||
owner_pkh: owner_pkh(),
|
||||
change_address:
|
||||
"addr1qxzdpzavu7jlydv3mq8frhxdg0araf263kt57gygg2m09ucqy7r9f734y9k9thlh5h0cvl3s8j5ehqfsajp85d34c79s6f8sq6"
|
||||
.into(),
|
||||
wallet_utxos: vec![
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "01",
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(31) + "02",
|
||||
output_index: 0,
|
||||
lovelace: 6_000_000,
|
||||
assets: vec![],
|
||||
},
|
||||
],
|
||||
stake_validator_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea".into(),
|
||||
output_index: 2,
|
||||
},
|
||||
stake_st_policy_ref: ReferenceUtxo {
|
||||
tx_hash_hex: "ed8f1daae3000000000000000000000000000000000000000000000000000000".into(),
|
||||
output_index: 0,
|
||||
},
|
||||
fee_lovelace: 2_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_unsigned_destroy_for_sulkta() {
|
||||
let unsigned = build_unsigned_stake_destroy(sample_args()).unwrap();
|
||||
assert_eq!(unsigned.returned_gov_token_qty, 250);
|
||||
assert!(!unsigned.tx_cbor_hex.is_empty());
|
||||
assert_eq!(unsigned.tx_hash_hex.len(), 64);
|
||||
assert!(unsigned.summary.contains("sulkta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_locked_stake() {
|
||||
let mut args = sample_args();
|
||||
args.stake_in.datum.locked_by.push(ProposalLock {
|
||||
proposal_id: 1,
|
||||
action: ProposalAction::Created,
|
||||
});
|
||||
let err = build_unsigned_stake_destroy(args).unwrap_err();
|
||||
assert!(err.to_string().contains("active lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_delegatee() {
|
||||
let mut args = sample_args();
|
||||
let other = vec![0xff; 28];
|
||||
args.stake_in.datum.owner = Credential::PubKey(other.clone());
|
||||
args.stake_in.datum.delegated_to = Some(Credential::PubKey(owner_pkh()));
|
||||
// owner_pkh is now the delegatee; validator rejects.
|
||||
let err = build_unsigned_stake_destroy(args).unwrap_err();
|
||||
assert!(err.to_string().contains("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_works_without_funding_utxo() {
|
||||
let mut args = sample_args();
|
||||
// Only collateral; no second ada-only utxo. Stake's own ada (1.5M)
|
||||
// + nothing else - 2M fee = -500k → fails because wallet output
|
||||
// would go below min utxo. Let's bump stake lovelace to make it work.
|
||||
args.wallet_utxos = vec![WalletUtxo {
|
||||
tx_hash_hex: "00".repeat(32),
|
||||
output_index: 0,
|
||||
lovelace: 10_000_000,
|
||||
assets: vec![],
|
||||
}];
|
||||
args.stake_in.lovelace = 5_000_000; // enough to pay 2M fee + leave 3M
|
||||
let unsigned = build_unsigned_stake_destroy(args).unwrap();
|
||||
assert_eq!(unsigned.returned_gov_token_qty, 250);
|
||||
}
|
||||
}
|
||||
454
crates/aldabra-dao/src/config.rs
Normal file
454
crates/aldabra-dao/src/config.rs
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
//! Per-DAO config files and active-DAO selector.
|
||||
//!
|
||||
//! ## Layout on disk
|
||||
//!
|
||||
//! ```text
|
||||
//! $ALDABRA_DATA/
|
||||
//! daos/
|
||||
//! sulkta.json ← named DAO config files
|
||||
//! bobs_dao.json
|
||||
//! alices_dao.json
|
||||
//! .active ← regular file containing the active DAO name
|
||||
//! ```
|
||||
//!
|
||||
//! `.active` is a regular text file (not a symlink) for portability across
|
||||
//! filesystems and to avoid confusing tools that follow symlinks.
|
||||
//!
|
||||
//! ## Adding a DAO
|
||||
//!
|
||||
//! Two paths:
|
||||
//!
|
||||
//! 1. Manual — caller supplies every field of [`DaoConfig`] (governor
|
||||
//! address, gov-token policy, etc) and we save it.
|
||||
//! 2. From-chain bootstrap — caller supplies the DAO's `initial_spend`
|
||||
//! txhash#index, we fetch the governor UTxO, decode the datum, infer
|
||||
//! the rest. See [`crate::reader::bootstrap_from_chain`]. This is
|
||||
//! the friendly path; the manual path is for cold-bootstrap or
|
||||
//! pre-genesis configuration.
|
||||
//!
|
||||
//! ## Why files, not env vars
|
||||
//!
|
||||
//! Multi-DAO would be unwieldy as env vars. Files let us hot-swap which
|
||||
//! DAO is "active" without restarting the daemon, and let users version-
|
||||
//! control their DAO list if they want to.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Cardano network the DAO lives on.
|
||||
///
|
||||
/// We keep our own `Network` mirror rather than re-export
|
||||
/// `aldabra_core::Network` because `DaoConfig` is serialized as JSON;
|
||||
/// having a stable wire type owned by this crate prevents config-file
|
||||
/// breakage if the core crate's Network enum gains variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum DaoNetwork {
|
||||
#[default]
|
||||
Mainnet,
|
||||
Preprod,
|
||||
Preview,
|
||||
}
|
||||
|
||||
|
||||
/// One named DAO. Captures every Sulkta-specific value as an
|
||||
/// instance field so the rest of the crate is config-driven.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DaoConfig {
|
||||
/// Human-friendly label. Also the basename of the config file
|
||||
/// (`<name>.json`). Constrained to `[a-z0-9_-]+` for filesystem
|
||||
/// safety; enforced by [`DaoStore::register`].
|
||||
pub name: String,
|
||||
|
||||
/// Free-form description for humans. Optional.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Governor script address (bech32). Holds the singleton
|
||||
/// GovernorDatum UTxO.
|
||||
pub governor_addr: String,
|
||||
|
||||
/// Stakes script address (bech32). Each user's stake is a UTxO at
|
||||
/// this address with a StakeDatum. For Agora deployments fronted by
|
||||
/// Clarity / MLabs, this address is shared across all DAOs (the
|
||||
/// Plutarch validator is parameterized by the gov token, so one
|
||||
/// script address handles all of them).
|
||||
pub stakes_addr: String,
|
||||
|
||||
/// Treasury script address (bech32). Holds DAO treasury funds. Spendable
|
||||
/// only by tx that burns a single GAT minted by a passed proposal.
|
||||
pub treasury_addr: String,
|
||||
|
||||
/// Governance token policy id (56 hex chars / 28 bytes).
|
||||
pub gov_token_policy: String,
|
||||
|
||||
/// Governance token asset name in hex.
|
||||
pub gov_token_name_hex: String,
|
||||
|
||||
/// Initial-spend tx ref of the DAO bootstrap, as `txhash#index`.
|
||||
/// Used by Agora as the "DAO identifier" — every Agora call cites
|
||||
/// this ref to disambiguate which DAO instance the tx is for.
|
||||
pub initial_spend: String,
|
||||
|
||||
/// Maximum cosigners on a proposal — copied from Agora bootstrap.
|
||||
pub max_cosigners: u32,
|
||||
|
||||
/// Treasury reference-config currency symbol (56 hex chars).
|
||||
/// Identifies a config-bearing UTxO referenced by Agora effects.
|
||||
pub treasury_ref_config: String,
|
||||
|
||||
/// Cardano network this DAO lives on.
|
||||
#[serde(default)]
|
||||
pub network: DaoNetwork,
|
||||
|
||||
// ─── Phase 4 prerequisites — populated by `dao_discover_scripts` ─────────
|
||||
//
|
||||
// All optional: existing configs registered before Phase 4 still load.
|
||||
// The dao_discover_scripts MCP tool fills these in by inspecting on-chain
|
||||
// state at the governor / stakes / treasury addresses.
|
||||
/// Proposal validator address (bech32). Where new proposal UTxOs land.
|
||||
/// Different from stakes_addr / governor_addr — separate parameterized
|
||||
/// validator. Discoverable from any tx that created a proposal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_addr: Option<String>,
|
||||
|
||||
/// StakeST minting policy id (56 hex chars). Mints exactly one
|
||||
/// "stake state thread" token per stake; the asset_name on the token
|
||||
/// equals the stake validator's script hash.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stake_st_policy: Option<String>,
|
||||
|
||||
/// ProposalST minting policy id (56 hex chars). Mints one token per
|
||||
/// proposal with empty asset name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_st_policy: Option<String>,
|
||||
|
||||
/// Reference UTxOs for each Agora script (so we don't re-discover on
|
||||
/// every tx). Stored as `txhash#index` strings. Optional — falls back
|
||||
/// to a lookup at use time when absent.
|
||||
#[serde(default)]
|
||||
pub script_refs: ScriptRefs,
|
||||
}
|
||||
|
||||
/// Cached UTxO references for the parameterized Agora scripts. Populated by
|
||||
/// [`dao_discover_scripts`], consumed by Phase 4 builders so each tx can
|
||||
/// cite reference inputs without a fresh chain query.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScriptRefs {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub governor_validator: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stake_validator: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_validator: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub treasury_validator: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stake_st_policy: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_st_policy: Option<String>,
|
||||
}
|
||||
|
||||
impl DaoConfig {
|
||||
/// Validate name + hex-formatted fields. Called on every save.
|
||||
pub fn validate(&self) -> DaoResult<()> {
|
||||
if self.name.is_empty() {
|
||||
return Err(DaoError::Config("name is empty".into()));
|
||||
}
|
||||
if !self
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(DaoError::Config(format!(
|
||||
"name {:?} must match [a-z0-9_-]+",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
if self.gov_token_policy.len() != 56 || hex::decode(&self.gov_token_policy).is_err() {
|
||||
return Err(DaoError::Config(format!(
|
||||
"gov_token_policy {:?} is not 56 hex chars",
|
||||
self.gov_token_policy
|
||||
)));
|
||||
}
|
||||
if hex::decode(&self.gov_token_name_hex).is_err() {
|
||||
return Err(DaoError::Config(format!(
|
||||
"gov_token_name_hex {:?} is not valid hex",
|
||||
self.gov_token_name_hex
|
||||
)));
|
||||
}
|
||||
if self.treasury_ref_config.len() != 56 || hex::decode(&self.treasury_ref_config).is_err() {
|
||||
return Err(DaoError::Config(format!(
|
||||
"treasury_ref_config {:?} is not 56 hex chars",
|
||||
self.treasury_ref_config
|
||||
)));
|
||||
}
|
||||
if !self.initial_spend.contains('#') {
|
||||
return Err(DaoError::Config(format!(
|
||||
"initial_spend {:?} must be in 'txhash#index' form",
|
||||
self.initial_spend
|
||||
)));
|
||||
}
|
||||
// Address validation is delegated to Pallas at first use; we
|
||||
// don't bech32-decode here to avoid coupling config validation
|
||||
// to the address parser.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for the active-DAO file content.
|
||||
///
|
||||
/// `.active` holds plain UTF-8 text matching one registered DAO name.
|
||||
/// Wrapper exists so [`DaoStore`] returns a typed handle rather than
|
||||
/// a raw string at the API boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ActiveDao(pub String);
|
||||
|
||||
impl ActiveDao {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// File-backed DAO config store rooted at `<data_dir>/daos/`.
|
||||
///
|
||||
/// Cheap to construct; no I/O happens until a method is called. Re-
|
||||
/// reading from disk on every call is intentional — we want
|
||||
/// add/remove/switch operations to take effect without daemon
|
||||
/// restart, and the volume of DAOs on a single user's machine is
|
||||
/// always tiny (single digits).
|
||||
pub struct DaoStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl DaoStore {
|
||||
/// Construct a store under `<data_dir>/daos/`. Does not create the
|
||||
/// directory — that happens lazily on first write.
|
||||
pub fn new(data_dir: &Path) -> Self {
|
||||
Self {
|
||||
root: data_dir.join("daos"),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path(&self, name: &str) -> PathBuf {
|
||||
self.root.join(format!("{name}.json"))
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.root.join(".active")
|
||||
}
|
||||
|
||||
fn ensure_dir(&self) -> DaoResult<()> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a config and (if no DAO is active yet) make it active.
|
||||
pub fn register(&self, cfg: &DaoConfig) -> DaoResult<()> {
|
||||
cfg.validate()?;
|
||||
self.ensure_dir()?;
|
||||
let path = self.config_path(&cfg.name);
|
||||
let bytes = serde_json::to_vec_pretty(cfg)?;
|
||||
fs::write(&path, bytes)?;
|
||||
|
||||
// First-registered DAO becomes active automatically — saves
|
||||
// a step for the typical single-DAO user.
|
||||
if self.get_active().is_err() {
|
||||
self.set_active(&cfg.name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load by name. Returns [`DaoError::Config`] if missing.
|
||||
pub fn load(&self, name: &str) -> DaoResult<DaoConfig> {
|
||||
let path = self.config_path(name);
|
||||
let bytes = fs::read(&path).map_err(|_| {
|
||||
DaoError::Config(format!(
|
||||
"DAO {name:?} not registered (no {})",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let cfg: DaoConfig = serde_json::from_slice(&bytes)?;
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// List all registered DAO names, sorted.
|
||||
pub fn list(&self) -> DaoResult<Vec<String>> {
|
||||
if !self.root.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(&self.root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
out.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Delete a config by name. If it was the active DAO, clears active.
|
||||
pub fn remove(&self, name: &str) -> DaoResult<()> {
|
||||
let path = self.config_path(name);
|
||||
if !path.exists() {
|
||||
return Err(DaoError::Config(format!("DAO {name:?} not registered")));
|
||||
}
|
||||
fs::remove_file(&path)?;
|
||||
if let Ok(active) = self.get_active() {
|
||||
if active.name() == name {
|
||||
let _ = fs::remove_file(self.active_path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the active DAO. Errors if `name` isn't registered.
|
||||
pub fn set_active(&self, name: &str) -> DaoResult<()> {
|
||||
if !self.config_path(name).exists() {
|
||||
return Err(DaoError::Config(format!(
|
||||
"cannot activate DAO {name:?}: not registered"
|
||||
)));
|
||||
}
|
||||
self.ensure_dir()?;
|
||||
fs::write(self.active_path(), name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the active DAO marker. Errors if no DAO is active.
|
||||
pub fn get_active(&self) -> DaoResult<ActiveDao> {
|
||||
let path = self.active_path();
|
||||
let bytes =
|
||||
fs::read(&path).map_err(|_| DaoError::Config("no active DAO selected".into()))?;
|
||||
let name = String::from_utf8(bytes)
|
||||
.map_err(|e| DaoError::Config(format!(".active is not valid UTF-8: {e}")))?;
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(DaoError::Config(".active is empty".into()));
|
||||
}
|
||||
Ok(ActiveDao(name))
|
||||
}
|
||||
|
||||
/// Resolve the named DAO, or fall through to the active one if `name`
|
||||
/// is `None`. The standard pattern at every DAO-tool entry point.
|
||||
pub fn resolve(&self, name: Option<&str>) -> DaoResult<DaoConfig> {
|
||||
match name {
|
||||
Some(n) => self.load(n),
|
||||
None => {
|
||||
let active = self.get_active()?;
|
||||
self.load(active.name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn cfg(name: &str) -> DaoConfig {
|
||||
DaoConfig {
|
||||
name: name.to_string(),
|
||||
description: Some("test".into()),
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0"
|
||||
.into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: DaoNetwork::Mainnet,
|
||||
proposal_addr: None,
|
||||
stake_st_policy: None,
|
||||
proposal_st_policy: None,
|
||||
script_refs: ScriptRefs::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_makes_first_dao_active() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("sulkta")).unwrap();
|
||||
assert_eq!(store.get_active().unwrap().name(), "sulkta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_register_does_not_change_active() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("sulkta")).unwrap();
|
||||
store.register(&cfg("bobs_dao")).unwrap();
|
||||
assert_eq!(store.get_active().unwrap().name(), "sulkta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_sorted() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("zzz")).unwrap();
|
||||
store.register(&cfg("aaa")).unwrap();
|
||||
store.register(&cfg("mmm")).unwrap();
|
||||
assert_eq!(store.list().unwrap(), vec!["aaa", "mmm", "zzz"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_active_rejects_unknown() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
assert!(store.set_active("nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_clears_active_if_was_active() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("sulkta")).unwrap();
|
||||
store.remove("sulkta").unwrap();
|
||||
assert!(store.get_active().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_through_to_active() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("sulkta")).unwrap();
|
||||
let cfg = store.resolve(None).unwrap();
|
||||
assert_eq!(cfg.name, "sulkta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_named_overrides_active() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = DaoStore::new(dir.path());
|
||||
store.register(&cfg("sulkta")).unwrap();
|
||||
store.register(&cfg("bobs_dao")).unwrap();
|
||||
let cfg = store.resolve(Some("bobs_dao")).unwrap();
|
||||
assert_eq!(cfg.name, "bobs_dao");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_bad_name() {
|
||||
let mut c = cfg("sulkta");
|
||||
c.name = "Sulkta DAO".into(); // uppercase + space
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_short_policy() {
|
||||
let mut c = cfg("sulkta");
|
||||
c.gov_token_policy = "abc".into();
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
}
|
||||
633
crates/aldabra-dao/src/discovery.rs
Normal file
633
crates/aldabra-dao/src/discovery.rs
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
//! Auto-discover Agora script hashes + reference UTxO refs from on-chain state.
|
||||
//!
|
||||
//! Closes the "user has to research and hand-populate ScriptRefs" gap by
|
||||
//! running the same Koios queries the human audit at
|
||||
//! `memory/audit-sulkta-agora-2026-05-05.md` performed.
|
||||
//!
|
||||
//! ## What we discover from the existing config
|
||||
//!
|
||||
//! Given a `DaoConfig` with at minimum {governor_addr, stakes_addr,
|
||||
//! treasury_addr, gov_token_policy, gov_token_name_hex} we can find:
|
||||
//!
|
||||
//! - **governor_validator_ref** — search known deployer addresses for a UTxO
|
||||
//! whose `reference_script.hash` matches the script hash extracted from
|
||||
//! `governor_addr`'s bech32.
|
||||
//! - **stake_validator_ref** — same pattern against `stakes_addr`'s hash.
|
||||
//! - **stake_st_policy + stake_st_policy_ref** — look at any existing stake
|
||||
//! UTxO at `stakes_addr` (filtered to those holding the gov token); the
|
||||
//! other asset on the UTxO is the StakeST. Then locate its ref-utxo at
|
||||
//! the deployer.
|
||||
//!
|
||||
//! ## What we DON'T cover in v1
|
||||
//!
|
||||
//! - **proposal_addr / ProposalST policy** — for v1 the user provides these
|
||||
//! explicitly. Discovery would require walking governor txs (CreateProposal
|
||||
//! spend → output at proposal_addr), which is a heavier lift. Phase 4b.
|
||||
//! - **treasury_validator_ref** — Sulkta's treasury validator wasn't found at
|
||||
//! the shared deployer per the audit. Possibly deployed elsewhere or not
|
||||
//! yet on chain. Phase 4c when treasury-spend ships.
|
||||
//!
|
||||
//! ## Deployer addresses to search
|
||||
//!
|
||||
//! MLabs's shared Agora deployer at `addr1w9gexmeunzsykesf42d4eqet5yvzeap6trjnflxqtkcf66g5740fw`
|
||||
//! (mainnet) is the standard for Clarity-deployed DAOs. We probe the
|
||||
//! addresses provided by the caller as additional deployer candidates;
|
||||
//! by default we just probe the MLabs shared one.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::DaoConfig;
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// Standard MLabs shared Agora deployer on mainnet. Hosts the parameterized
|
||||
/// validators + minting policies for many Clarity DAOs (Indigo, SundaeSwap,
|
||||
/// Sulkta, etc).
|
||||
pub const MAINNET_AGORA_SHARED_DEPLOYER: &str =
|
||||
"addr1w9gexmeunzsykesf42d4eqet5yvzeap6trjnflxqtkcf66g5740fw";
|
||||
|
||||
/// Trait for the chain reads we need. Lets tests stub Koios responses.
|
||||
#[async_trait::async_trait]
|
||||
pub trait DiscoveryClient: Send + Sync {
|
||||
async fn address_info(&self, address: &str) -> DaoResult<Vec<AddressInfo>>;
|
||||
}
|
||||
|
||||
/// Koios-backed [`DiscoveryClient`] for production use.
|
||||
///
|
||||
/// Mirrors the `KoiosDaoReader` shape — separate client because the trait
|
||||
/// surface is different and we don't want to entangle Phase 1 reads with
|
||||
/// Phase 4-prep discovery.
|
||||
pub struct KoiosDiscoveryClient {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl KoiosDiscoveryClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_bearer(base_url, None)
|
||||
}
|
||||
|
||||
/// Same as [`Self::new`] but with an optional `Authorization: Bearer
|
||||
/// <token>` default header for paid-tier Koios access. Bearer comes
|
||||
/// from `ALDABRA_KOIOS_BEARER` env var only — never from disk.
|
||||
pub fn with_bearer(base_url: impl Into<String>, bearer: Option<&str>) -> Self {
|
||||
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
|
||||
if let Some(token) = bearer {
|
||||
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||
let value = format!("Bearer {token}");
|
||||
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||
hv.set_sensitive(true);
|
||||
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||
builder = builder.default_headers(hdrs);
|
||||
}
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
http: builder.build().expect("reqwest client"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiscoveryClient for KoiosDiscoveryClient {
|
||||
async fn address_info(&self, address: &str) -> DaoResult<Vec<AddressInfo>> {
|
||||
let url = format!("{}/address_info", self.base_url);
|
||||
let body = serde_json::json!({ "_addresses": [address] });
|
||||
let resp = self
|
||||
.http
|
||||
.post(url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DaoError::Backend(format!("address_info {address}: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(DaoError::Backend(format!(
|
||||
"address_info {address}: HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
resp.json::<Vec<AddressInfo>>()
|
||||
.await
|
||||
.map_err(|e| DaoError::Backend(format!("address_info {address} parse: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Subset of Koios `address_info` JSON we need.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AddressInfo {
|
||||
#[allow(dead_code)]
|
||||
pub address: String,
|
||||
pub utxo_set: Vec<AddressUtxo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AddressUtxo {
|
||||
pub tx_hash: String,
|
||||
pub tx_index: u32,
|
||||
#[serde(default)]
|
||||
pub asset_list: Option<Vec<UtxoAsset>>,
|
||||
#[serde(default)]
|
||||
pub reference_script: Option<RefScript>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct UtxoAsset {
|
||||
pub policy_id: String,
|
||||
pub asset_name: String,
|
||||
pub quantity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct RefScript {
|
||||
pub hash: String,
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// What `discover_scripts` filled in vs. left blank.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscoveryReport {
|
||||
pub governor_validator_ref: Option<String>,
|
||||
pub stake_validator_ref: Option<String>,
|
||||
pub stake_st_policy: Option<String>,
|
||||
pub stake_st_policy_ref: Option<String>,
|
||||
/// Things we couldn't auto-find. Show these to the user so they know
|
||||
/// what to provide manually before running write tools.
|
||||
pub gaps: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decode a bech32 script address → 28-byte script-hash hex.
|
||||
pub fn script_hash_from_addr(bech32: &str) -> DaoResult<String> {
|
||||
use bech32::FromBase32;
|
||||
let (_hrp, data, _variant) =
|
||||
bech32::decode(bech32).map_err(|e| DaoError::Address(format!("bech32 decode: {e}")))?;
|
||||
let bytes = Vec::<u8>::from_base32(&data)
|
||||
.map_err(|e| DaoError::Address(format!("bech32 base32: {e}")))?;
|
||||
if bytes.len() < 29 {
|
||||
return Err(DaoError::Address(format!(
|
||||
"address too short ({} bytes)",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
// First byte = network/type header; next 28 = script hash.
|
||||
Ok(hex::encode(&bytes[1..29]))
|
||||
}
|
||||
|
||||
/// Auto-discover the script-ref UTxOs + StakeST policy for a DAO.
|
||||
///
|
||||
/// Caller supplies a [`DiscoveryClient`] (typically a Koios wrapper) and the
|
||||
/// config to inspect. Returns a [`DiscoveryReport`] with whatever was found
|
||||
/// + a list of `gaps` (things the user must still provide manually).
|
||||
pub async fn discover_scripts(
|
||||
cfg: &DaoConfig,
|
||||
client: &dyn DiscoveryClient,
|
||||
deployer_addresses: &[&str],
|
||||
) -> DaoResult<DiscoveryReport> {
|
||||
let mut report = DiscoveryReport::default();
|
||||
|
||||
// 1. Validator script hashes from address bech32.
|
||||
let governor_hash = script_hash_from_addr(&cfg.governor_addr)?;
|
||||
let stakes_hash = script_hash_from_addr(&cfg.stakes_addr)?;
|
||||
|
||||
// 2. StakeST policy from any stake UTxO at stakes_addr.
|
||||
//
|
||||
// A stake UTxO carries (gov_token, qty) + (stake_st_token, 1).
|
||||
//
|
||||
// **AUDIT-H6 fix 2026-05-05:** Previous logic was "first non-gov-token
|
||||
// asset on a stake UTxO" — would silently pick a wrong asset if anyone
|
||||
// ever sent a junk NFT to a stake UTxO (Cardano allows this). Tighten:
|
||||
// the StakeST minting policy mints with `asset_name = stake validator's
|
||||
// script hash` per `Stake/Scripts.hs:188-190` (`pscriptHashToTokenName`).
|
||||
// Match on that explicitly.
|
||||
match client.address_info(&cfg.stakes_addr).await {
|
||||
Ok(infos) => {
|
||||
let utxos = infos
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|i| i.utxo_set)
|
||||
.unwrap_or_default();
|
||||
let mut found_stake_st = None;
|
||||
for u in &utxos {
|
||||
let assets = match &u.asset_list {
|
||||
Some(a) => a,
|
||||
None => continue,
|
||||
};
|
||||
let has_gov = assets.iter().any(|a| a.policy_id == cfg.gov_token_policy);
|
||||
if !has_gov {
|
||||
continue;
|
||||
}
|
||||
// Match on asset_name == stakes_validator_hash (StakeST tokens
|
||||
// for THIS DAO's stakes will carry the stake validator hash
|
||||
// as their asset name; junk tokens won't).
|
||||
if let Some(stake_st) = assets
|
||||
.iter()
|
||||
.find(|a| a.policy_id != cfg.gov_token_policy && a.asset_name == stakes_hash)
|
||||
{
|
||||
found_stake_st = Some(stake_st.policy_id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(p) = found_stake_st {
|
||||
report.stake_st_policy = Some(p);
|
||||
} else {
|
||||
report.gaps.push(
|
||||
"stake_st_policy: no stakes-addr UTxO carries (gov_token + \
|
||||
StakeST_with_asset_name=stakes_validator_hash) — \
|
||||
either no stakes exist yet OR stakes_addr is wrong"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => report.gaps.push(format!(
|
||||
"stake_st_policy: address_info failed for stakes_addr: {e}"
|
||||
)),
|
||||
}
|
||||
|
||||
// 3. Reference-script UTxOs at the deployers.
|
||||
//
|
||||
// For each deployer address, fetch its UTxO set. Iterate UTxOs, match
|
||||
// each `reference_script.hash` against our targets:
|
||||
// - governor validator → governor_validator_ref
|
||||
// - stake validator → stake_validator_ref
|
||||
// - stake_st policy → stake_st_policy_ref (if we found the policy)
|
||||
|
||||
let stake_st_target = report.stake_st_policy.clone();
|
||||
|
||||
for &deployer in deployer_addresses.iter() {
|
||||
let infos = match client.address_info(deployer).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
report
|
||||
.gaps
|
||||
.push(format!("deployer {deployer} probe failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let utxos = infos
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|i| i.utxo_set)
|
||||
.unwrap_or_default();
|
||||
|
||||
for u in &utxos {
|
||||
let rs = match &u.reference_script {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let utxo_ref = format!("{}#{}", u.tx_hash, u.tx_index);
|
||||
|
||||
if rs.hash == governor_hash && report.governor_validator_ref.is_none() {
|
||||
report.governor_validator_ref = Some(utxo_ref.clone());
|
||||
}
|
||||
if rs.hash == stakes_hash && report.stake_validator_ref.is_none() {
|
||||
report.stake_validator_ref = Some(utxo_ref.clone());
|
||||
}
|
||||
if let Some(ref target) = stake_st_target {
|
||||
if &rs.hash == target && report.stake_st_policy_ref.is_none() {
|
||||
report.stake_st_policy_ref = Some(utxo_ref.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if report.governor_validator_ref.is_none() {
|
||||
report.gaps.push(format!(
|
||||
"governor_validator_ref: hash {} not found at any provided deployer address",
|
||||
&governor_hash[..16]
|
||||
));
|
||||
}
|
||||
if report.stake_validator_ref.is_none() {
|
||||
report.gaps.push(format!(
|
||||
"stake_validator_ref: hash {} not found at any provided deployer address",
|
||||
&stakes_hash[..16]
|
||||
));
|
||||
}
|
||||
if report.stake_st_policy.is_some() && report.stake_st_policy_ref.is_none() {
|
||||
report.gaps.push(
|
||||
"stake_st_policy_ref: policy id discovered but no ref-utxo found at deployers".into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Always add gaps for things v1 doesn't auto-discover.
|
||||
if cfg.proposal_addr.is_none() {
|
||||
report
|
||||
.gaps
|
||||
.push("proposal_addr: not auto-discovered in v1; provide via dao_register".into());
|
||||
}
|
||||
if cfg.proposal_st_policy.is_none() {
|
||||
report
|
||||
.gaps
|
||||
.push("proposal_st_policy: not auto-discovered in v1; provide via dao_register".into());
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Merge a [`DiscoveryReport`] into a [`DaoConfig`], filling in any field
|
||||
/// the report discovered. Returns the merged config — caller persists.
|
||||
pub fn apply_discovery(cfg: &mut DaoConfig, report: &DiscoveryReport) {
|
||||
if let Some(p) = &report.stake_st_policy {
|
||||
if cfg.stake_st_policy.is_none() {
|
||||
cfg.stake_st_policy = Some(p.clone());
|
||||
}
|
||||
}
|
||||
if let Some(r) = &report.governor_validator_ref {
|
||||
if cfg.script_refs.governor_validator.is_none() {
|
||||
cfg.script_refs.governor_validator = Some(r.clone());
|
||||
}
|
||||
}
|
||||
if let Some(r) = &report.stake_validator_ref {
|
||||
if cfg.script_refs.stake_validator.is_none() {
|
||||
cfg.script_refs.stake_validator = Some(r.clone());
|
||||
}
|
||||
}
|
||||
if let Some(r) = &report.stake_st_policy_ref {
|
||||
if cfg.script_refs.stake_st_policy.is_none() {
|
||||
cfg.script_refs.stake_st_policy = Some(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_script_hash_from_governor_addr() {
|
||||
let h = script_hash_from_addr("addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h,
|
||||
"d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_script_hash_from_real_stakes_addr() {
|
||||
let h = script_hash_from_addr("addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h,
|
||||
"f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_script_hash_from_treasury_addr() {
|
||||
let h = script_hash_from_addr("addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h,
|
||||
"9461852b2a4be42055e5083b6595e46af48930183bbe969609fea668"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stub client returning canned address_info for testing the discovery
|
||||
/// pipeline without hitting Koios.
|
||||
struct StubClient {
|
||||
responses: std::collections::HashMap<String, Vec<AddressInfo>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiscoveryClient for StubClient {
|
||||
async fn address_info(&self, address: &str) -> DaoResult<Vec<AddressInfo>> {
|
||||
Ok(self.responses.get(address).cloned().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
fn sulkta_cfg() -> DaoConfig {
|
||||
use crate::config::ScriptRefs;
|
||||
DaoConfig {
|
||||
name: "sulkta".into(),
|
||||
description: None,
|
||||
governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(),
|
||||
stakes_addr: "addr1w8msu7psehjlcu5glzjgjtq53m4mk75c9d2p8hfjwyknmfqskkah8".into(),
|
||||
treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(),
|
||||
gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(),
|
||||
gov_token_name_hex: "546572726170696e".into(),
|
||||
initial_spend: "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0"
|
||||
.into(),
|
||||
max_cosigners: 5,
|
||||
treasury_ref_config: "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(),
|
||||
network: crate::config::DaoNetwork::Mainnet,
|
||||
proposal_addr: None,
|
||||
stake_st_policy: None,
|
||||
proposal_st_policy: None,
|
||||
script_refs: ScriptRefs::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_stake_st_from_existing_stake() {
|
||||
let cfg = sulkta_cfg();
|
||||
let mut responses = std::collections::HashMap::new();
|
||||
// A fake stake UTxO at stakes_addr carrying gov-token + StakeST.
|
||||
// StakeST asset_name == Sulkta stake validator hash (per H-6 fix).
|
||||
responses.insert(
|
||||
cfg.stakes_addr.clone(),
|
||||
vec![AddressInfo {
|
||||
address: cfg.stakes_addr.clone(),
|
||||
utxo_set: vec![AddressUtxo {
|
||||
tx_hash: "deadbeef".repeat(8),
|
||||
tx_index: 0,
|
||||
asset_list: Some(vec![
|
||||
UtxoAsset {
|
||||
policy_id: cfg.gov_token_policy.clone(),
|
||||
asset_name: cfg.gov_token_name_hex.clone(),
|
||||
quantity: "50".into(),
|
||||
},
|
||||
UtxoAsset {
|
||||
policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696"
|
||||
.into(),
|
||||
// asset_name MUST match the stakes_addr's script hash for H-6 to pass:
|
||||
asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"
|
||||
.into(),
|
||||
quantity: "1".into(),
|
||||
},
|
||||
]),
|
||||
reference_script: None,
|
||||
}],
|
||||
}],
|
||||
);
|
||||
// Empty deployer for this test — we just want StakeST policy id.
|
||||
responses.insert(
|
||||
MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
vec![AddressInfo {
|
||||
address: MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
utxo_set: vec![],
|
||||
}],
|
||||
);
|
||||
|
||||
let client = StubClient { responses };
|
||||
let report = discover_scripts(&cfg, &client, &[MAINNET_AGORA_SHARED_DEPLOYER])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report.stake_st_policy.as_deref(),
|
||||
Some("732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_validator_refs_at_deployer() {
|
||||
let cfg = sulkta_cfg();
|
||||
let governor_hash = "d9e8b9231f3d0dcfd13db58a1ba9033ea5a20cc2fcd58978ac9df4d7";
|
||||
let stake_hash = "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4";
|
||||
|
||||
let mut responses = std::collections::HashMap::new();
|
||||
responses.insert(
|
||||
cfg.stakes_addr.clone(),
|
||||
vec![AddressInfo {
|
||||
address: cfg.stakes_addr.clone(),
|
||||
utxo_set: vec![],
|
||||
}],
|
||||
);
|
||||
responses.insert(
|
||||
MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
vec![AddressInfo {
|
||||
address: MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
utxo_set: vec![
|
||||
// Governor validator ref
|
||||
AddressUtxo {
|
||||
tx_hash: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea"
|
||||
.into(),
|
||||
tx_index: 3,
|
||||
asset_list: None,
|
||||
reference_script: Some(RefScript {
|
||||
hash: governor_hash.into(),
|
||||
kind: Some("plutusV2".into()),
|
||||
size: Some(7213),
|
||||
}),
|
||||
},
|
||||
// Stake validator ref
|
||||
AddressUtxo {
|
||||
tx_hash: "479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea"
|
||||
.into(),
|
||||
tx_index: 2,
|
||||
asset_list: None,
|
||||
reference_script: Some(RefScript {
|
||||
hash: stake_hash.into(),
|
||||
kind: Some("plutusV2".into()),
|
||||
size: Some(5182),
|
||||
}),
|
||||
},
|
||||
// Random other ref-utxo (different DAO's script — should be ignored)
|
||||
AddressUtxo {
|
||||
tx_hash: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
.into(),
|
||||
tx_index: 0,
|
||||
asset_list: None,
|
||||
reference_script: Some(RefScript {
|
||||
hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff".into(),
|
||||
kind: Some("plutusV2".into()),
|
||||
size: Some(1024),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}],
|
||||
);
|
||||
|
||||
let client = StubClient { responses };
|
||||
let report = discover_scripts(&cfg, &client, &[MAINNET_AGORA_SHARED_DEPLOYER])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report.governor_validator_ref.as_deref(),
|
||||
Some("479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea#3")
|
||||
);
|
||||
assert_eq!(
|
||||
report.stake_validator_ref.as_deref(),
|
||||
Some("479b8203c8b84ce20bb0a1c6ee1f527f122d1ce3e655dfc54635061eff622aea#2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_discovery_merges_into_config() {
|
||||
let mut cfg = sulkta_cfg();
|
||||
let report = DiscoveryReport {
|
||||
governor_validator_ref: Some("aa#1".into()),
|
||||
stake_validator_ref: Some("bb#2".into()),
|
||||
stake_st_policy: Some("ccdd".into()),
|
||||
stake_st_policy_ref: Some("ee#0".into()),
|
||||
gaps: vec![],
|
||||
};
|
||||
apply_discovery(&mut cfg, &report);
|
||||
assert_eq!(cfg.script_refs.governor_validator.as_deref(), Some("aa#1"));
|
||||
assert_eq!(cfg.script_refs.stake_validator.as_deref(), Some("bb#2"));
|
||||
assert_eq!(cfg.stake_st_policy.as_deref(), Some("ccdd"));
|
||||
assert_eq!(cfg.script_refs.stake_st_policy.as_deref(), Some("ee#0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_discovery_doesnt_overwrite_existing() {
|
||||
let mut cfg = sulkta_cfg();
|
||||
cfg.stake_st_policy = Some("preexisting".into());
|
||||
let report = DiscoveryReport {
|
||||
stake_st_policy: Some("would_overwrite".into()),
|
||||
..Default::default()
|
||||
};
|
||||
apply_discovery(&mut cfg, &report);
|
||||
assert_eq!(cfg.stake_st_policy.as_deref(), Some("preexisting"));
|
||||
}
|
||||
|
||||
/// Regression for AUDIT-H6: a stake UTxO with a junk third-party token
|
||||
/// must NOT pollute StakeST detection. The StakeST is only detected
|
||||
/// when its `asset_name == stakes_validator_script_hash`.
|
||||
#[tokio::test]
|
||||
async fn h6_junk_token_does_not_pollute_stake_st_detection() {
|
||||
let cfg = sulkta_cfg();
|
||||
let mut responses = std::collections::HashMap::new();
|
||||
responses.insert(
|
||||
cfg.stakes_addr.clone(),
|
||||
vec![AddressInfo {
|
||||
address: cfg.stakes_addr.clone(),
|
||||
utxo_set: vec![AddressUtxo {
|
||||
tx_hash: "deadbeef".repeat(8),
|
||||
tx_index: 0,
|
||||
asset_list: Some(vec![
|
||||
UtxoAsset {
|
||||
policy_id: cfg.gov_token_policy.clone(),
|
||||
asset_name: cfg.gov_token_name_hex.clone(),
|
||||
quantity: "50".into(),
|
||||
},
|
||||
// Junk NFT — wrong asset_name. Must NOT be picked.
|
||||
UtxoAsset {
|
||||
policy_id: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
.into(),
|
||||
asset_name: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||
.into(),
|
||||
quantity: "1".into(),
|
||||
},
|
||||
// Real StakeST — asset_name matches stake validator hash.
|
||||
UtxoAsset {
|
||||
policy_id: "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696"
|
||||
.into(),
|
||||
asset_name: "f70e7830cde5fc7288f8a4892c148eebbb7a982b5413dd32712d3da4"
|
||||
.into(),
|
||||
quantity: "1".into(),
|
||||
},
|
||||
]),
|
||||
reference_script: None,
|
||||
}],
|
||||
}],
|
||||
);
|
||||
responses.insert(
|
||||
MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
vec![AddressInfo {
|
||||
address: MAINNET_AGORA_SHARED_DEPLOYER.into(),
|
||||
utxo_set: vec![],
|
||||
}],
|
||||
);
|
||||
let client = StubClient { responses };
|
||||
let report = discover_scripts(&cfg, &client, &[MAINNET_AGORA_SHARED_DEPLOYER])
|
||||
.await
|
||||
.unwrap();
|
||||
// Picks the REAL StakeST (asset_name match), not the junk NFT.
|
||||
assert_eq!(
|
||||
report.stake_st_policy.as_deref(),
|
||||
Some("732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696")
|
||||
);
|
||||
}
|
||||
}
|
||||
52
crates/aldabra-dao/src/error.rs
Normal file
52
crates/aldabra-dao/src/error.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Crate-wide error type.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Anything that can go wrong inside `aldabra-dao`.
|
||||
///
|
||||
/// Boundary rule: `DaoError` does NOT wrap `aldabra_core::WalletError` or
|
||||
/// `aldabra_chain::ChainError` directly — those sit at different layers.
|
||||
/// Convert to a [`DaoError::Backend`] string via the `?`-helper at call sites
|
||||
/// to keep the public error surface flat for MCP consumers.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DaoError {
|
||||
/// Per-DAO config file is missing, malformed, or names a DAO we don't have.
|
||||
#[error("dao config: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Bech32 / address parse failure.
|
||||
#[error("address: {0}")]
|
||||
Address(String),
|
||||
|
||||
/// PlutusData encode/decode failure — the on-chain datum didn't match
|
||||
/// the shape we expected.
|
||||
#[error("datum: {0}")]
|
||||
Datum(String),
|
||||
|
||||
/// CBOR encode/decode failure (lower-level than [`Self::Datum`]).
|
||||
#[error("cbor: {0}")]
|
||||
Cbor(String),
|
||||
|
||||
/// Backend (Koios / submit) returned an error.
|
||||
#[error("backend: {0}")]
|
||||
Backend(String),
|
||||
|
||||
/// We couldn't find a required reference-script UTxO on chain.
|
||||
#[error("reference script not found: {0}")]
|
||||
RefScript(String),
|
||||
|
||||
/// Caller asked for something the DAO can't provide right now (no active
|
||||
/// proposal, no stake registered, locked stake, etc).
|
||||
#[error("invalid state: {0}")]
|
||||
State(String),
|
||||
|
||||
/// I/O error against the DAO config dir or similar.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// JSON serialization failure for [`crate::config::DaoConfig`].
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type DaoResult<T> = Result<T, DaoError>;
|
||||
36
crates/aldabra-dao/src/lib.rs
Normal file
36
crates/aldabra-dao/src/lib.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! aldabra-dao — Agora-on-Cardano DAO interaction.
|
||||
//!
|
||||
//! Native client for any [Agora](https://github.com/Liqwid-Labs/agora)
|
||||
//! deployment on Cardano. Multi-DAO from day one — Sulkta DAO, Bob's
|
||||
//! DAO, and Alice's DAO are all first-class, configured per-instance
|
||||
//! at `$ALDABRA_DATA/daos/<name>.json`.
|
||||
//!
|
||||
//! ## Surface
|
||||
//!
|
||||
//! - [`config`] — per-DAO config files + active-DAO selector.
|
||||
//! - [`agora`] — Plutarch datum/redeemer type ports with PlutusData
|
||||
//! encode/decode.
|
||||
//! - [`reader`] — Koios-backed state queries (governor, stakes,
|
||||
//! proposals).
|
||||
//! - [`builder`] — Plutus tx assembly per operation (stake_create,
|
||||
//! proposal_vote, etc).
|
||||
//! - [`error::DaoError`] — crate-internal error type.
|
||||
//!
|
||||
//! ## What this crate is NOT
|
||||
//!
|
||||
//! - Not a key store. Signing is delegated to `aldabra-core`.
|
||||
//! - Not an MCP server. The `dao_*` tools that wrap these primitives
|
||||
//! live in `aldabra-mcp`.
|
||||
//! - Not Sulkta-specific. Every Sulkta value (TRP policy, governor
|
||||
//! address, etc) comes from a [`config::DaoConfig`] loaded at
|
||||
//! runtime, never compile-time.
|
||||
|
||||
pub mod agora;
|
||||
pub mod builder;
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod reader;
|
||||
|
||||
pub use config::{ActiveDao, DaoConfig, DaoStore};
|
||||
pub use error::DaoError;
|
||||
334
crates/aldabra-dao/src/reader.rs
Normal file
334
crates/aldabra-dao/src/reader.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
//! On-chain DAO state reader.
|
||||
//!
|
||||
//! Phase 1 surface — Koios-backed queries that decode Agora datums
|
||||
//! into the typed Rust structs from [`crate::agora`].
|
||||
//!
|
||||
//! ## Why a separate reader vs raw Koios
|
||||
//!
|
||||
//! Three reasons:
|
||||
//!
|
||||
//! 1. **Datum decoding** — Koios returns datums as hex CBOR strings; we
|
||||
//! decode them through the agora type ports here so callers get
|
||||
//! `StakeDatum` / `ProposalDatum` / `GovernorDatum`, not raw hex.
|
||||
//! 2. **Filtering** — the stakes script address is shared across many
|
||||
//! Agora DAOs. A naive "list UTxOs at stakes_addr" returns 268+
|
||||
//! items most of which are not Sulkta's. Stakes for a particular
|
||||
//! DAO are tagged by the gov-token policy in their value, so we
|
||||
//! filter here rather than at every call site.
|
||||
//! 3. **DAO instance routing** — every read takes a [`DaoConfig`] so
|
||||
//! multi-DAO tools work without baking in a single instance.
|
||||
//!
|
||||
//! ## What's NOT here
|
||||
//!
|
||||
//! Submission. That's the chain backend's job; we only read.
|
||||
|
||||
use pallas_primitives::PlutusData;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::agora::{GovernorDatum, ProposalDatum, StakeDatum};
|
||||
use crate::config::DaoConfig;
|
||||
use crate::error::{DaoError, DaoResult};
|
||||
|
||||
/// One on-chain stake found at the stakes script address, filtered to
|
||||
/// the gov token policy of [`DaoConfig::gov_token_policy`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StakeUtxo {
|
||||
/// `txhash#index`, suitable for citing as an input.
|
||||
pub utxo_ref: String,
|
||||
/// Decoded StakeDatum.
|
||||
pub datum: StakeDatum,
|
||||
/// Lovelace at this UTxO.
|
||||
pub lovelace: u64,
|
||||
/// Gov-token (TRP) quantity at this UTxO. Should equal
|
||||
/// `datum.staked_amount` when the validator is correctly enforcing.
|
||||
pub gov_token_quantity: u64,
|
||||
}
|
||||
|
||||
/// One on-chain proposal at the proposal script address.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposalUtxo {
|
||||
pub utxo_ref: String,
|
||||
/// Lovelace at this UTxO. Preserved in vote/cosign/advance outputs.
|
||||
pub lovelace: u64,
|
||||
/// Asset name (hex) of the ProposalST token on this UTxO. Sulkta
|
||||
/// convention is empty bytes; community DAOs may use something else.
|
||||
pub proposal_st_asset_name_hex: String,
|
||||
pub datum: ProposalDatum,
|
||||
}
|
||||
|
||||
/// Trait for the chain reads we need. Lets tests stub Koios responses
|
||||
/// via fake implementations without spinning up a network.
|
||||
#[async_trait::async_trait]
|
||||
pub trait DaoReader: Send + Sync {
|
||||
/// Return the singleton governor UTxO + decoded datum.
|
||||
async fn get_governor(&self, cfg: &DaoConfig) -> DaoResult<(String, GovernorDatum)>;
|
||||
|
||||
/// Return all stakes for this DAO (filtered by gov-token policy).
|
||||
async fn list_stakes(&self, cfg: &DaoConfig) -> DaoResult<Vec<StakeUtxo>>;
|
||||
|
||||
/// Return all proposals for this DAO.
|
||||
async fn list_proposals(&self, cfg: &DaoConfig) -> DaoResult<Vec<ProposalUtxo>>;
|
||||
}
|
||||
|
||||
// ---------------- Koios-backed implementation ------------------------------
|
||||
|
||||
/// Koios-backed `DaoReader`.
|
||||
///
|
||||
/// Phase 1 implementation. Talks to Koios's REST API directly because:
|
||||
///
|
||||
/// - We already use Koios elsewhere in aldabra (no new infra dep).
|
||||
/// - Koios returns inline datums, not just hashes, in `address_info`.
|
||||
/// - It's free + has no auth requirements + has community fallbacks.
|
||||
///
|
||||
/// The full `inline_datum.value` field of an address-info response
|
||||
/// arrives as a JSON object whose shape matches Koios's "PlutusData
|
||||
/// JSON" representation. Simpler path: ask Koios for the *hex CBOR*
|
||||
/// of the datum and decode through `pallas_codec`.
|
||||
pub struct KoiosDaoReader {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl KoiosDaoReader {
|
||||
/// Construct against a Koios base URL (e.g. `https://api.koios.rest/api/v1`
|
||||
/// or `https://preprod.koios.rest/api/v1`).
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_bearer(base_url, None)
|
||||
}
|
||||
|
||||
/// Same as [`Self::new`] but with an optional `Authorization: Bearer
|
||||
/// <token>` default header for paid-tier Koios access. Bearer is
|
||||
/// supplied by the caller from `ALDABRA_KOIOS_BEARER` env var only.
|
||||
pub fn with_bearer(base_url: impl Into<String>, bearer: Option<&str>) -> Self {
|
||||
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
|
||||
if let Some(token) = bearer {
|
||||
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||
let value = format!("Bearer {token}");
|
||||
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||
hv.set_sensitive(true);
|
||||
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||
builder = builder.default_headers(hdrs);
|
||||
}
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
http: builder.build().expect("reqwest client"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn address_info(&self, address: &str) -> DaoResult<Vec<KoiosAddressInfo>> {
|
||||
let url = format!("{}/address_info", self.base_url);
|
||||
let body = serde_json::json!({ "_addresses": [address] });
|
||||
let resp = self
|
||||
.http
|
||||
.post(url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DaoError::Backend(format!("address_info: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(DaoError::Backend(format!(
|
||||
"address_info: HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
resp.json::<Vec<KoiosAddressInfo>>()
|
||||
.await
|
||||
.map_err(|e| DaoError::Backend(format!("address_info parse: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DaoReader for KoiosDaoReader {
|
||||
async fn get_governor(&self, cfg: &DaoConfig) -> DaoResult<(String, GovernorDatum)> {
|
||||
let infos = self.address_info(&cfg.governor_addr).await?;
|
||||
let utxos = infos
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|i| i.utxo_set)
|
||||
.unwrap_or_default();
|
||||
// Governor is a singleton — there should be exactly one UTxO with
|
||||
// an inline datum. Filter to ones with inline datums and pick the
|
||||
// first; if there are none we bail with a state error rather than
|
||||
// returning a partial result.
|
||||
for u in utxos {
|
||||
if let Some(d) = u.inline_datum.as_ref() {
|
||||
let pd = decode_datum_cbor_hex(&d.bytes)?;
|
||||
let datum = GovernorDatum::from_plutus_data(&pd)?;
|
||||
let utxo_ref = format!("{}#{}", u.tx_hash, u.tx_index);
|
||||
return Ok((utxo_ref, datum));
|
||||
}
|
||||
}
|
||||
Err(DaoError::State(format!(
|
||||
"no governor UTxO with inline datum at {}",
|
||||
cfg.governor_addr
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list_stakes(&self, cfg: &DaoConfig) -> DaoResult<Vec<StakeUtxo>> {
|
||||
let infos = self.address_info(&cfg.stakes_addr).await?;
|
||||
let utxos = infos
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|i| i.utxo_set)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for u in utxos {
|
||||
// Filter to UTxOs that hold this DAO's gov token. Stakes
|
||||
// address is shared across DAOs, so we discard any UTxO
|
||||
// that doesn't carry our policy.
|
||||
//
|
||||
// `Option<Vec<_>>::iter()` yields the inner Vec once if Some,
|
||||
// then `.flatten()` flattens to T. Works whether Koios returns
|
||||
// asset_list as `null` (None) or `[]` (Some(empty)).
|
||||
let mut gov_qty: u64 = 0;
|
||||
for a in u.asset_list.as_ref().into_iter().flatten() {
|
||||
if a.policy_id == cfg.gov_token_policy {
|
||||
gov_qty = a.quantity.parse().unwrap_or(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if gov_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(ref d) = u.inline_datum else {
|
||||
continue;
|
||||
};
|
||||
let pd = match decode_datum_cbor_hex(&d.bytes) {
|
||||
Ok(pd) => pd,
|
||||
Err(_) => continue, // wrong shape; not our datum
|
||||
};
|
||||
let datum = match StakeDatum::from_plutus_data(&pd) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
out.push(StakeUtxo {
|
||||
utxo_ref: format!("{}#{}", u.tx_hash, u.tx_index),
|
||||
datum,
|
||||
lovelace: u.value.parse().unwrap_or(0),
|
||||
gov_token_quantity: gov_qty,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_proposals(&self, cfg: &DaoConfig) -> DaoResult<Vec<ProposalUtxo>> {
|
||||
let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| {
|
||||
DaoError::Config(
|
||||
"DaoConfig.proposal_addr missing — register the DAO with proposal_addr \
|
||||
or run dao_discover_scripts first"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let infos = self.address_info(proposal_addr).await?;
|
||||
let utxos = infos
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|i| i.utxo_set)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for u in utxos {
|
||||
// Need an inline datum to be a real proposal UTxO. Skip orphans.
|
||||
let Some(ref d) = u.inline_datum else {
|
||||
continue;
|
||||
};
|
||||
let pd = match decode_datum_cbor_hex(&d.bytes) {
|
||||
Ok(pd) => pd,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let datum = match ProposalDatum::from_plutus_data(&pd) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Pick the ProposalST asset name. We don't have the policy id
|
||||
// baked into the trait surface (cfg.proposal_st_policy may or
|
||||
// may not be populated yet), so:
|
||||
// - if cfg.proposal_st_policy IS set, match exactly on it;
|
||||
// - otherwise fall back to "the first asset on the utxo,"
|
||||
// which is right for Sulkta convention (1 ProposalST + 0
|
||||
// other assets) but defends against the case where a
|
||||
// community DAO bundles other tokens in the proposal output.
|
||||
let proposal_st_asset_name_hex = match cfg.proposal_st_policy.as_deref() {
|
||||
Some(target_policy) => u
|
||||
.asset_list
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|a| {
|
||||
if a.policy_id == target_policy {
|
||||
Some(a.asset_name.clone().unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
None => u
|
||||
.asset_list
|
||||
.as_ref()
|
||||
.and_then(|al| al.first())
|
||||
.and_then(|a| a.asset_name.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
out.push(ProposalUtxo {
|
||||
utxo_ref: format!("{}#{}", u.tx_hash, u.tx_index),
|
||||
lovelace: u.value.parse().unwrap_or(0),
|
||||
proposal_st_asset_name_hex,
|
||||
datum,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Koios JSON shapes ---------------------------------------------
|
||||
|
||||
/// Subset of Koios's `address_info` response that we use.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KoiosAddressInfo {
|
||||
#[allow(dead_code)]
|
||||
address: String,
|
||||
utxo_set: Vec<KoiosUtxo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KoiosUtxo {
|
||||
tx_hash: String,
|
||||
tx_index: u32,
|
||||
/// Lovelace as a string (Koios convention for big numbers).
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
asset_list: Option<Vec<KoiosAsset>>,
|
||||
#[serde(default)]
|
||||
inline_datum: Option<KoiosInlineDatum>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KoiosAsset {
|
||||
policy_id: String,
|
||||
#[serde(default)]
|
||||
asset_name: Option<String>,
|
||||
quantity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KoiosInlineDatum {
|
||||
/// CBOR-hex encoded PlutusData.
|
||||
bytes: String,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Decode a CBOR-hex datum string into a typed `PlutusData`.
|
||||
///
|
||||
/// Uses the same path as `aldabra-core::cip68` round-trip tests:
|
||||
/// `pallas_codec::minicbor::decode(&bytes)`.
|
||||
fn decode_datum_cbor_hex(hex_str: &str) -> DaoResult<PlutusData> {
|
||||
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("hex decode: {e}")))?;
|
||||
pallas_codec::minicbor::decode::<PlutusData>(&bytes)
|
||||
.map_err(|e| DaoError::Cbor(format!("plutus data decode: {e}")))
|
||||
}
|
||||
|
|
@ -20,6 +20,15 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
aldabra-core = { path = "../aldabra-core" }
|
||||
aldabra-chain = { path = "../aldabra-chain" }
|
||||
aldabra-dao = { path = "../aldabra-dao" }
|
||||
|
||||
# Used directly in tools.rs to decode the wallet's bech32 address into a
|
||||
# payment-credential hash (so `dao_my_stake` can match against StakeDatum.owner).
|
||||
# Comes in transitively via aldabra-core too; declared here for clarity.
|
||||
pallas-addresses = { workspace = true }
|
||||
|
||||
# `hex::encode` for rendering pkh/script-hash bytes in dao_* JSON output.
|
||||
hex = "0.4"
|
||||
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -200,8 +200,7 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
|
|||
|
||||
eprintln!("aldabra: no key found at {}", data_dir.display());
|
||||
eprintln!("first-run bootstrap — this writes an encrypted mnemonic to disk.\n");
|
||||
fs::create_dir_all(data_dir)
|
||||
.with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
|
||||
eprint!("paste 24-word BIP-39 mnemonic (visible) and press Enter: ");
|
||||
std::io::stderr().flush().ok();
|
||||
|
|
@ -244,8 +243,7 @@ pub fn import_root_xprv(data_dir: &Path) -> Result<RootKey> {
|
|||
xprv_path.display()
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(data_dir)
|
||||
.with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
|
||||
eprint!("paste root_xsk1... bech32 root extended secret key and press Enter: ");
|
||||
std::io::stderr().flush().ok();
|
||||
|
|
@ -300,8 +298,7 @@ pub fn generate_and_save_root_key(data_dir: &Path) -> Result<RootKey> {
|
|||
path.display()
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(data_dir)
|
||||
.with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
fs::create_dir_all(data_dir).with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
|
||||
let (mnemonic, phrase) = Mnemonic::generate()?;
|
||||
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
|
||||
|
|
@ -397,21 +394,13 @@ mod tests {
|
|||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap();
|
||||
let addr_a = aldabra_core::derive_base_address(
|
||||
&root_a,
|
||||
aldabra_core::Network::Mainnet,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let addr_a =
|
||||
aldabra_core::derive_base_address(&root_a, aldabra_core::Network::Mainnet, 0, 0)
|
||||
.unwrap();
|
||||
let root_b = RootKey::from_root_xsk_bech32(&decrypted).unwrap();
|
||||
let addr_b = aldabra_core::derive_base_address(
|
||||
&root_b,
|
||||
aldabra_core::Network::Mainnet,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let addr_b =
|
||||
aldabra_core::derive_base_address(&root_b, aldabra_core::Network::Mainnet, 0, 0)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
addr_a, addr_b,
|
||||
"xprv import must derive the same address as mnemonic import"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ use thiserror::Error;
|
|||
pub struct Config {
|
||||
pub network: Network,
|
||||
pub koios_base: String,
|
||||
/// Optional Koios bearer token (paid-tier JWT). Sourced from the
|
||||
/// `ALDABRA_KOIOS_BEARER` env var only — never from the TOML file
|
||||
/// or CLI args. Bearers are credentials and must not get persisted
|
||||
/// alongside non-secret config. When set, every Koios request gets
|
||||
/// `Authorization: Bearer <token>` and bypasses the public-tier
|
||||
/// daily quota.
|
||||
pub koios_bearer: Option<String>,
|
||||
pub account: u32,
|
||||
pub index: u32,
|
||||
pub data_dir: PathBuf,
|
||||
|
|
@ -138,17 +145,26 @@ impl Config {
|
|||
.or(file_cfg.koios_base)
|
||||
.unwrap_or_else(|| default_koios_for(network).to_string());
|
||||
|
||||
// Koios bearer is env-only — never sourced from disk. Empty
|
||||
// string is treated as "no bearer" so that an unset systemd
|
||||
// EnvironmentFile entry doesn't accidentally send `Bearer ""`.
|
||||
let koios_bearer = std::env::var("ALDABRA_KOIOS_BEARER")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
let account = match std::env::var("ALDABRA_ACCOUNT") {
|
||||
Ok(s) => s
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_ACCOUNT", value: s })?,
|
||||
Ok(s) => s.parse::<u32>().map_err(|_| ConfigError::EnvParse {
|
||||
var: "ALDABRA_ACCOUNT",
|
||||
value: s,
|
||||
})?,
|
||||
Err(_) => file_cfg.account.unwrap_or(0),
|
||||
};
|
||||
|
||||
let index = match std::env::var("ALDABRA_INDEX") {
|
||||
Ok(s) => s
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_INDEX", value: s })?,
|
||||
Ok(s) => s.parse::<u32>().map_err(|_| ConfigError::EnvParse {
|
||||
var: "ALDABRA_INDEX",
|
||||
value: s,
|
||||
})?,
|
||||
Err(_) => file_cfg.index.unwrap_or(0),
|
||||
};
|
||||
|
||||
|
|
@ -165,6 +181,7 @@ impl Config {
|
|||
Ok(Self {
|
||||
network,
|
||||
koios_base,
|
||||
koios_bearer,
|
||||
account,
|
||||
index,
|
||||
data_dir,
|
||||
|
|
@ -201,9 +218,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_network_accepts_canonical_names() {
|
||||
assert!(matches!(parse_network("mainnet").unwrap(), Network::Mainnet));
|
||||
assert!(matches!(parse_network("Preview").unwrap(), Network::Preview));
|
||||
assert!(matches!(parse_network("PREPROD").unwrap(), Network::Preprod));
|
||||
assert!(matches!(
|
||||
parse_network("mainnet").unwrap(),
|
||||
Network::Mainnet
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_network("Preview").unwrap(),
|
||||
Network::Preview
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_network("PREPROD").unwrap(),
|
||||
Network::Preprod
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -229,8 +255,7 @@ mod tests {
|
|||
assert_eq!(default_max_send_for(Network::Preprod), 100_000_000);
|
||||
assert_eq!(default_max_send_for(Network::Preview), 100_000_000);
|
||||
assert!(
|
||||
default_max_send_for(Network::Mainnet)
|
||||
< default_max_send_for(Network::Preprod),
|
||||
default_max_send_for(Network::Mainnet) < default_max_send_for(Network::Preprod),
|
||||
"mainnet default must be strictly tighter than preprod"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ async fn run() -> Result<()> {
|
|||
tracing::info!(
|
||||
network = ?cfg.network,
|
||||
koios = %cfg.koios_base,
|
||||
koios_bearer_set = cfg.koios_bearer.is_some(),
|
||||
account = cfg.account,
|
||||
index = cfg.index,
|
||||
data_dir = %cfg.data_dir.display(),
|
||||
|
|
@ -106,14 +107,9 @@ async fn run() -> Result<()> {
|
|||
);
|
||||
};
|
||||
|
||||
let address = aldabra_core::derive_base_address(
|
||||
&root,
|
||||
cfg.network,
|
||||
cfg.account,
|
||||
cfg.index,
|
||||
)?;
|
||||
let payment_key =
|
||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
let address =
|
||||
aldabra_core::derive_base_address(&root, cfg.network, cfg.account, cfg.index)?;
|
||||
let payment_key = aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
||||
(payment_key, stake_key, address)
|
||||
// root drops here — XPrv::Drop wipes the 96 bytes
|
||||
|
|
@ -131,9 +127,11 @@ async fn run() -> Result<()> {
|
|||
cfg.network,
|
||||
address,
|
||||
cfg.koios_base,
|
||||
cfg.koios_bearer,
|
||||
payment_key,
|
||||
stake_key,
|
||||
cfg.max_send_lovelace,
|
||||
cfg.data_dir.clone(),
|
||||
);
|
||||
let server = service
|
||||
.serve(stdio())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue