From 5d1eb0e3cf7ae8df05efa8bfe573fa53abda740d Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Tue, 20 Oct 2026 19:09:19 +0200 Subject: [PATCH] ledger: query accounts, admin, network and blocks Add `account`, `admin`, `network` and `block.get-block` queries to `starstream:ledger`, served over `POST /rpc` like the existing ones: - `account.get-balance(key: list)` and `account.get-nonce(key)` return the balance and last publish nonce of the account identified by its raw Ed25519 public key. - `admin.get-nonce()` returns the last nonce used by the admin key to fund accounts, which is tracked separately from its publish nonce. - `network.get-name()` returns the network every envelope is bound to. - `block.get-block(height)` returns the envelopes of the block at `height`, in order, or `none` if there is no such block. Heights match `block.get-height`: block 1 holds the first envelope, and height 0 (genesis, served by `genesis.get-outputs`) is `none`. The client gains `get_account_balance`, `get_account_nonce`, `get_admin_nonce`, `get_network` and `get_block`, and the CLI gains `account balance`, `account nonce`, `admin nonce`, `block get` and `network name`. `--nonce` of `contract publish` and `account fund` is now optional and defaults to the last account or admin nonce reported by the ledger plus one, respectively. The client no longer carries a network: `fund`, `publish_contract` and `transact` take the network to bind the signed envelope to, and fetched envelopes are no longer checked against it. Reading a block, contract or transaction from a ledger thus works regardless of its network. The CLI drops the global `--network` argument and declares it only on the commands that sign envelopes: `account fund`, `contract publish` and `contract script call`. It defaults to the network reported by the ledger. `contract script call` rejects both `--network` and `--key` together with `--simulate`. The `contract.get-envelope`, `contract.get-wasm`, `transaction.get-envelope` and `transaction.get-transaction` handlers now clone the stored `Bytes` under the `contracts` or `transactions` read lock and release it before encoding the result, and UTXO method calls release the `contracts` read lock before merging the UTXO state into the contract Wasm, so writers are not stalled by large contracts. `get-error` is shared by all lookups: its `invalid-digest` variant is renamed to `malformed`, which also covers keys that are not valid Ed25519 public keys. API break: the generated `GetError::InvalidDigest` is now `GetError::Malformed`. `ClientBuilder::network` and `DEFAULT_NETWORK` are removed, and `Client::{fund, publish_contract, transact}` gain a `network` parameter. Assisted-by: anthropic:claude-opus-5-5 Signed-off-by: Roman Volosatovs --- starstream-ledger-cli/src/lib.rs | 191 +++++++++++++++++++---- starstream-ledger-cli/tests/cli.rs | 76 +++++++-- starstream-ledger/src/client/http.rs | 152 +++++++++++------- starstream-ledger/src/server/http/mod.rs | 116 +++++++++++--- starstream-ledger/src/wrpc/bindings.rs | 8 +- starstream-ledger/src/wrpc/mod.rs | 3 + starstream-ledger/tests/ledger.rs | 108 ++++++++++--- starstream-ledger/wit/ledger.wit | 21 ++- 8 files changed, 536 insertions(+), 139 deletions(-) diff --git a/starstream-ledger-cli/src/lib.rs b/starstream-ledger-cli/src/lib.rs index 6ea74002..b90fdcf9 100644 --- a/starstream-ledger-cli/src/lib.rs +++ b/starstream-ledger-cli/src/lib.rs @@ -26,6 +26,7 @@ use starstream_ledger::{ use starstream_runtime::Utxo; use tokio::fs; use tokio::io::{AsyncRead, AsyncWriteExt as _, stdout}; +use tokio::try_join; use tokio_util::codec::Encoder as _; use tracing::{info, warn}; use wasm_wave::wasm::WasmFunc as _; @@ -46,10 +47,6 @@ pub struct Args { )] url: Uri, - /// Network identifier the transaction is bound to. - #[arg(long, global = true, default_value = "dev")] - network: Box, - /// Time an idle connection to the node is kept open for reuse, in seconds. #[arg(long, global = true, value_name = "SECONDS", default_value_t = 15)] pool_idle_timeout: u64, @@ -64,6 +61,10 @@ enum Command { #[command(subcommand)] Account(AccountCommand), + /// Query the ledger admin. + #[command(subcommand)] + Admin(AdminCommand), + /// Query ledger blocks. #[command(subcommand)] Block(BlockCommand), @@ -85,6 +86,10 @@ enum Command { #[command(subcommand)] Key(KeyCommand), + /// Query the ledger network. + #[command(subcommand)] + Network(NetworkCommand), + /// Inspect transactions. #[command(subcommand)] Transaction(TransactionCommand), @@ -94,24 +99,27 @@ enum Command { Utxo(UtxoCommand), } -/// Arguments common to every signed transaction. -#[derive(Debug, clap::Args)] -struct SigningArgs { - /// Path to a file containing the hex-encoded Ed25519 signing key. - #[arg(long, value_name = "PATH")] - key: PathBuf, - - /// Transaction nonce. - #[arg(long)] - nonce: u64, -} - #[derive(Debug, Subcommand)] enum AccountCommand { + /// Get the balance of an account. + Balance { + /// Hex-encoded Ed25519 public key of the account. + #[arg(value_parser = parse_verifying_key)] + account: VerifyingKey, + }, /// Fund an account with a credit signed by the admin key. Fund { - #[command(flatten)] - signing: SigningArgs, + /// Path to a file containing the hex-encoded Ed25519 admin signing key. + #[arg(long, value_name = "PATH")] + key: PathBuf, + + /// Network identifier the envelope is bound to, defaults to the one reported by the ledger. + #[arg(long)] + network: Option, + + /// Admin nonce, defaults to the one following the last nonce used by the admin key. + #[arg(long)] + nonce: Option, /// Hex-encoded Ed25519 public key of the account to fund. #[arg(value_parser = parse_verifying_key)] @@ -120,10 +128,27 @@ enum AccountCommand { /// Amount to credit the account with. amount: u64, }, + /// Get the last nonce used by an account to publish contracts. + Nonce { + /// Hex-encoded Ed25519 public key of the account. + #[arg(value_parser = parse_verifying_key)] + account: VerifyingKey, + }, +} + +#[derive(Debug, Subcommand)] +enum AdminCommand { + /// Get the last nonce used by the admin key to fund accounts. + Nonce, } #[derive(Debug, Subcommand)] enum BlockCommand { + /// Print the hex-encoded envelopes of a ledger block, one per line. + Get { + /// Height of the block. + height: u64, + }, /// Get the height of the latest ledger block. Height, } @@ -133,8 +158,17 @@ enum BlockCommand { enum ContractCommand { /// Sign and publish a contract. Publish { - #[command(flatten)] - signing: SigningArgs, + /// Path to a file containing the hex-encoded Ed25519 signing key. + #[arg(long, value_name = "PATH")] + key: PathBuf, + + /// Network identifier the envelope is bound to, defaults to the one reported by the ledger. + #[arg(long)] + network: Option, + + /// Publish nonce, defaults to the one following the last nonce used by the key. + #[arg(long)] + nonce: Option, /// Path to the contract. wasm: PathBuf, @@ -149,9 +183,18 @@ enum ScriptCommand { /// Call a coordination script exported by a published contract and submit the resulting transaction. Call { /// Path to a file containing the hex-encoded Ed25519 signing key. - #[arg(long, value_name = "PATH", required_unless_present = "simulate")] + #[arg( + long, + value_name = "PATH", + required_unless_present = "simulate", + conflicts_with = "simulate" + )] key: Option, + /// Network identifier the envelope is bound to, defaults to the one reported by the ledger. + #[arg(long, conflicts_with = "simulate")] + network: Option, + /// Simulate the call instead of submitting the transaction. #[arg(long)] simulate: bool, @@ -211,6 +254,12 @@ enum KeyCommand { Generate, } +#[derive(Debug, Subcommand)] +enum NetworkCommand { + /// Get the name of the network the ledger accepts envelopes for. + Name, +} + #[derive(Debug, Subcommand)] enum TransactionCommand { /// Get a transaction from the ledger. @@ -352,10 +401,19 @@ impl Args { } } +async fn resolve_network( + client: &starstream_ledger::client::http::Client, + network: Option, +) -> anyhow::Result { + match network { + Some(network) => Ok(network), + None => client.get_network().await, + } +} + async fn exec(args: Args) -> anyhow::Result<()> { let Args { url, - network, pool_idle_timeout, command, } = args; @@ -363,17 +421,66 @@ async fn exec(args: Args) -> anyhow::Result<()> { let mut http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()); http.pool_timer(TokioTimer::new()) .pool_idle_timeout(Duration::from_secs(pool_idle_timeout)); - let client = ClientBuilder::new(http, HttpConnector::new(), url) - .network(network.as_ref()) - .build(); + let client = ClientBuilder::new(http, HttpConnector::new(), url).build(); match command { + Command::Account(AccountCommand::Balance { account }) => { + let balance = client.get_account_balance(&account).await?; + let balance = balance.with_context(|| { + format!("account `{}` not found", hex::encode(account.as_bytes())) + })?; + stdout() + .write_all(format!("{balance}\n").as_bytes()) + .await + .context("failed to write balance to stdout") + } Command::Account(AccountCommand::Fund { - signing: SigningArgs { key, nonce }, + key, + network, + nonce, account, amount, }) => { let key = read_signing_key(&key).await?; - client.fund(key, nonce, &account, amount).await + let (network, nonce) = try_join!(resolve_network(&client, network), async { + match nonce { + Some(nonce) => Ok(nonce), + None => { + let nonce = client.get_admin_nonce().await?; + nonce.checked_add(1).context("admin nonce exhausted") + } + } + })?; + client.fund(key, &network, nonce, &account, amount).await + } + Command::Account(AccountCommand::Nonce { account }) => { + let nonce = client.get_account_nonce(&account).await?; + let nonce = nonce.with_context(|| { + format!("account `{}` not found", hex::encode(account.as_bytes())) + })?; + stdout() + .write_all(format!("{nonce}\n").as_bytes()) + .await + .context("failed to write nonce to stdout") + } + Command::Admin(AdminCommand::Nonce) => { + let nonce = client.get_admin_nonce().await?; + stdout() + .write_all(format!("{nonce}\n").as_bytes()) + .await + .context("failed to write nonce to stdout") + } + Command::Block(BlockCommand::Get { height }) => { + let envelopes = client.get_block(height).await?; + let envelopes = envelopes.with_context(|| format!("block {height} not found"))?; + let mut out = String::default(); + for envelope in envelopes { + out.push_str(&hex::encode(envelope)); + out.push('\n'); + } + stdout() + .write_all(out.as_bytes()) + .await + .context("failed to write envelopes to stdout") } Command::Block(BlockCommand::Height) => { let height = client.block_height().await?; @@ -403,14 +510,29 @@ async fn exec(args: Args) -> anyhow::Result<()> { .context("failed to write genesis to stdout") } Command::Contract(ContractCommand::Publish { - signing: SigningArgs { key, nonce }, + key, + network, + nonce, wasm, }) => { let key = read_signing_key(&key).await?; let wasm = fs::read(&wasm) .await .with_context(|| format!("failed to read `{}`", wasm.display()))?; - let digest = client.publish_contract(key, nonce, wasm).await?; + let (network, nonce) = try_join!(resolve_network(&client, network), async { + match nonce { + Some(nonce) => Ok(nonce), + None => { + let account = key.verifying_key(); + let nonce = client.get_account_nonce(&account).await?; + let nonce = nonce.with_context(|| { + format!("account `{}` not found", hex::encode(account.as_bytes())) + })?; + nonce.checked_add(1).context("account nonce exhausted") + } + } + })?; + let digest = client.publish_contract(key, &network, nonce, wasm).await?; stdout() .write_all(format!("{}\n", encode_digest(&digest)).as_bytes()) .await @@ -418,6 +540,7 @@ async fn exec(args: Args) -> anyhow::Result<()> { } Command::Contract(ContractCommand::Script(ScriptCommand::Call { key, + network, simulate, output_transaction, imports, @@ -541,7 +664,8 @@ async fn exec(args: Args) -> anyhow::Result<()> { .with_context(|| format!("failed to write `{}`", path.display()))?; } if let Some(key) = key { - let digest = client.transact(key, tx).await?; + let network = resolve_network(&client, network).await?; + let digest = client.transact(key, &network, tx).await?; info!(digest = %encode_digest(&digest), "submitted transaction"); } results.push('\n'); @@ -561,6 +685,13 @@ async fn exec(args: Args) -> anyhow::Result<()> { .await .context("failed to write signing key to stdout") } + Command::Network(NetworkCommand::Name) => { + let name = client.get_network().await?; + stdout() + .write_all(format!("{name}\n").as_bytes()) + .await + .context("failed to write network name to stdout") + } Command::Transaction(TransactionCommand::Get { digest }) => { let tx = client.get_transaction(digest).await?; let tx = tx.context("transaction not found")?; diff --git a/starstream-ledger-cli/tests/cli.rs b/starstream-ledger-cli/tests/cli.rs index 6dfa708a..44725ffc 100644 --- a/starstream-ledger-cli/tests/cli.rs +++ b/starstream-ledger-cli/tests/cli.rs @@ -89,8 +89,6 @@ async fn cli() { "contract", "script", "call", - "--network", - NETWORK, "--simulate", "--output-transaction", &tx_file.path().to_string_lossy(), @@ -144,6 +142,11 @@ async fn cli() { .unwrap(); assert_eq!(stdout, b"0\n"); + let stdout = run_cli(["--url", &format!("http://{addr}"), "network", "name"]) + .await + .unwrap(); + assert_eq!(stdout, format!("{NETWORK}\n").as_bytes()); + let stdout = run_cli([ "--url", &format!("http://{addr}"), @@ -151,10 +154,6 @@ async fn cli() { "fund", "--key", &admin_file.path().to_string_lossy(), - "--network", - NETWORK, - "--nonce", - "1", &hex::encode(account.verifying_key().to_bytes()), &publish_cost.to_string(), ]) @@ -162,6 +161,33 @@ async fn cli() { .unwrap(); assert_eq!(stdout, b""); + let stdout = run_cli(["--url", &format!("http://{addr}"), "admin", "nonce"]) + .await + .unwrap(); + assert_eq!(stdout, b"1\n"); + + let stdout = run_cli([ + "--url", + &format!("http://{addr}"), + "account", + "balance", + &hex::encode(account.verifying_key().to_bytes()), + ]) + .await + .unwrap(); + assert_eq!(stdout, format!("{publish_cost}\n").as_bytes()); + + let stdout = run_cli([ + "--url", + &format!("http://{addr}"), + "account", + "nonce", + &hex::encode(account.verifying_key().to_bytes()), + ]) + .await + .unwrap(); + assert_eq!(stdout, b"0\n"); + let stdout = run_cli([ "--url", &format!("http://{addr}"), @@ -169,21 +195,47 @@ async fn cli() { "publish", "--key", &account_file.path().to_string_lossy(), - "--network", - NETWORK, - "--nonce", - "1", &score_wasm.path().to_string_lossy(), ]) .await .unwrap(); assert_eq!(stdout, format!("{score_digest}\n").as_bytes()); + let stdout = run_cli([ + "--url", + &format!("http://{addr}"), + "account", + "nonce", + &hex::encode(account.verifying_key().to_bytes()), + ]) + .await + .unwrap(); + assert_eq!(stdout, b"1\n"); + + let stdout = run_cli([ + "--url", + &format!("http://{addr}"), + "account", + "balance", + &hex::encode(account.verifying_key().to_bytes()), + ]) + .await + .unwrap(); + assert_eq!(stdout, b"0\n"); + let stdout = run_cli(["--url", &format!("http://{addr}"), "block", "height"]) .await .unwrap(); assert_eq!(stdout, b"2\n"); + let stdout = run_cli(["--url", &format!("http://{addr}"), "block", "get", "2"]) + .await + .unwrap(); + assert_eq!( + stdout, + format!("{}\n", hex::encode(&publish_envelope)).as_bytes() + ); + let tx_file = NamedTempFile::new().unwrap(); let stdout = run_cli([ "--url", @@ -191,8 +243,6 @@ async fn cli() { "contract", "script", "call", - "--network", - NETWORK, "--simulate", "--output-transaction", &tx_file.path().to_string_lossy(), @@ -257,8 +307,6 @@ interface utxo { "contract", "script", "call", - "--network", - NETWORK, "--simulate", &format!( "sha256:{}", diff --git a/starstream-ledger/src/client/http.rs b/starstream-ledger/src/client/http.rs index 6edc59e2..5a5fc841 100644 --- a/starstream-ledger/src/client/http.rs +++ b/starstream-ledger/src/client/http.rs @@ -33,9 +33,6 @@ use crate::{ parse_digest, }; -/// Default network used by the client -pub const DEFAULT_NETWORK: &str = "dev"; - fn endpoint_uri(base: &Uri, endpoint: impl AsRef) -> anyhow::Result { ensure!( base.query().is_none(), @@ -155,12 +152,39 @@ pub fn build_utxo_wit_request( build_get_request(uri, accept) } +fn verify_envelope( + body: Result, + digest: &[u8; 32], + context: u8, +) -> anyhow::Result> { + let body = match body { + Ok(body) => body, + Err(bindings::starstream::ledger::types::GetError::NotFound) => return Ok(None), + Err(err) => return Err(err.into()), + }; + let (_, envelope) = read_envelope(&body).context("invalid envelope")?; + ensure!( + envelope.message.context() == context, + "unexpected context `{}`, expected `{context}`", + envelope.message.context() + ); + let message_digest = envelope + .message + .payload_digest() + .context("failed to compute payload digest")?; + ensure!( + message_digest == *digest, + "digest mismatch, got `{}`", + encode_digest(&message_digest) + ); + Ok(Some(body)) +} + #[derive(Clone)] pub struct ClientBuilder { http: hyper_util::client::legacy::Builder, connect: C, api_base: Uri, - network: Box, engine: wasmtime::Engine, wizer: Wizer, } @@ -178,17 +202,11 @@ impl ClientBuilder { http, connect, api_base: api_base.into(), - network: DEFAULT_NETWORK.into(), engine, wizer: Wizer::new(), } } - pub fn network(mut self, network: impl Into>) -> Self { - self.network = network.into(); - self - } - pub fn engine(mut self, engine: wasmtime::Engine) -> Self { self.engine = engine; self @@ -211,7 +229,6 @@ pub struct Client { wrpc: wrpc_http::Client>, http: hyper_util::client::legacy::Client>, api_base: Uri, - network: Box, engine: wasmtime::Engine, wizer: Wizer, } @@ -225,7 +242,6 @@ where http, connect, api_base, - network, engine, wizer, }: ClientBuilder, @@ -236,7 +252,6 @@ where wrpc, http, api_base, - network, engine, wizer, } @@ -296,6 +311,62 @@ where bindings::starstream::ledger::block::get_height(&self.wrpc, cx).await } + /// Get the envelopes of the block at `height`, in order. + #[instrument(skip_all)] + pub async fn get_block(&self, height: u64) -> anyhow::Result>> { + let cx = wrpc_context(&self.api_base)?; + let envelopes = + bindings::starstream::ledger::block::get_block(&self.wrpc, cx, height).await?; + let Some(envelopes) = envelopes else { + return Ok(None); + }; + for envelope in &envelopes { + read_envelope(envelope).context("invalid envelope")?; + } + Ok(Some(envelopes)) + } + + /// Get the name of the network the ledger accepts envelopes for. + #[instrument(skip_all)] + pub async fn get_network(&self) -> anyhow::Result { + let cx = wrpc_context(&self.api_base)?; + bindings::starstream::ledger::network::get_name(&self.wrpc, cx).await + } + + /// Get the last nonce used by the admin key to fund accounts. + #[instrument(skip_all)] + pub async fn get_admin_nonce(&self) -> anyhow::Result { + let cx = wrpc_context(&self.api_base)?; + bindings::starstream::ledger::admin::get_nonce(&self.wrpc, cx).await + } + + /// Get the balance of the account `key`. + #[instrument(skip_all)] + pub async fn get_account_balance(&self, key: &VerifyingKey) -> anyhow::Result> { + let cx = wrpc_context(&self.api_base)?; + let key = Bytes::copy_from_slice(key.as_bytes()); + let balance = + bindings::starstream::ledger::account::get_balance(&self.wrpc, cx, &key).await?; + match balance { + Ok(balance) => Ok(Some(balance)), + Err(bindings::starstream::ledger::types::GetError::NotFound) => Ok(None), + Err(err) => Err(err).context("failed to get account balance"), + } + } + + /// Get the last nonce used by the account `key` to publish contracts. + #[instrument(skip_all)] + pub async fn get_account_nonce(&self, key: &VerifyingKey) -> anyhow::Result> { + let cx = wrpc_context(&self.api_base)?; + let key = Bytes::copy_from_slice(key.as_bytes()); + let nonce = bindings::starstream::ledger::account::get_nonce(&self.wrpc, cx, &key).await?; + match nonce { + Ok(nonce) => Ok(Some(nonce)), + Err(bindings::starstream::ledger::types::GetError::NotFound) => Ok(None), + Err(err) => Err(err).context("failed to get account nonce"), + } + } + /// Get the transaction UTXO referenced by `digest` and `idx`. #[instrument(skip_all)] pub async fn get_transaction_utxo( @@ -419,6 +490,7 @@ where pub async fn fund( &self, key: SigningKey, + network: &str, nonce: u64, account: &VerifyingKey, amount: u64, @@ -426,7 +498,7 @@ where let req = build_fund_request( &self.api_base, key, - self.network.as_ref(), + network, Fund { nonce, account: account.to_bytes(), @@ -447,13 +519,14 @@ where pub async fn publish_contract( &self, key: SigningKey, + network: &str, nonce: u64, wasm: impl Into>, ) -> anyhow::Result<[u8; 32]> { let req = build_contract_publish_request( &self.api_base, key, - self.network.as_ref(), + network, Publish { nonce, wasm: wasm.into(), @@ -467,8 +540,13 @@ where /// Submit the transaction `tx` and return its digest. #[instrument(skip_all)] - pub async fn transact(&self, key: SigningKey, tx: Transaction) -> anyhow::Result<[u8; 32]> { - let req = build_transaction_request(&self.api_base, key, self.network.as_ref(), tx)?; + pub async fn transact( + &self, + key: SigningKey, + network: &str, + tx: Transaction, + ) -> anyhow::Result<[u8; 32]> { + let req = build_transaction_request(&self.api_base, key, network, tx)?; let (http::response::Parts { status, .. }, body) = self.request(req).await?; let body = String::from_utf8_lossy(&body); ensure!(status.is_success(), "{body}"); @@ -500,41 +578,6 @@ where Ok(Some(tx)) } - fn verify_envelope( - &self, - body: Result, - digest: &[u8; 32], - context: u8, - ) -> anyhow::Result> { - let body = match body { - Ok(body) => body, - Err(bindings::starstream::ledger::types::GetError::NotFound) => return Ok(None), - Err(err) => return Err(err.into()), - }; - let (_, envelope) = read_envelope(&body).context("invalid envelope")?; - ensure!( - envelope.network == self.network, - "unexpected network `{}`, expected `{}`", - envelope.network, - self.network - ); - ensure!( - envelope.message.context() == context, - "unexpected context `{}`, expected `{context}`", - envelope.message.context() - ); - let message_digest = envelope - .message - .payload_digest() - .context("failed to compute payload digest")?; - ensure!( - message_digest == *digest, - "digest mismatch, got `{}`", - encode_digest(&message_digest) - ); - Ok(Some(body)) - } - /// Get the envelope of the transaction identified by `digest`. #[instrument(skip_all)] pub async fn get_transaction_envelope( @@ -548,7 +591,7 @@ where &encode_digest(digest), ) .await?; - self.verify_envelope(body, digest, TRANSACTION_CONTEXT) + verify_envelope(body, digest, TRANSACTION_CONTEXT) .context("failed to get transaction envelope") } @@ -593,7 +636,6 @@ where &encode_digest(digest), ) .await?; - self.verify_envelope(body, digest, PUBLISH_CONTEXT) - .context("failed to get contract envelope") + verify_envelope(body, digest, PUBLISH_CONTEXT).context("failed to get contract envelope") } } diff --git a/starstream-ledger/src/server/http/mod.rs b/starstream-ledger/src/server/http/mod.rs index 97cc2a1e..7a646f04 100644 --- a/starstream-ledger/src/server/http/mod.rs +++ b/starstream-ledger/src/server/http/mod.rs @@ -35,8 +35,8 @@ use tokio_util::codec::Encoder as _; use tokio_util::io::StreamReader; use tower_http::timeout::{TimeoutBody, TimeoutError}; use tracing::{Instrument as _, debug, error, info, instrument, warn}; -use wasm_tokio::cm::AsyncReadValue as _; -use wasm_tokio::{AsyncReadCore as _, AsyncReadLeb128 as _, CoreNameDecoder}; +use wasm_tokio::cm::{AsyncReadValue as _, U64Codec}; +use wasm_tokio::{AsyncReadCore as _, AsyncReadLeb128 as _, CoreNameDecoder, CoreVecDecoderBytes}; use wasmparser::WasmFeatures; use wasmtime::component::{ResourceTable, Type, Val}; @@ -47,7 +47,8 @@ use crate::wrpc::bindings; use crate::wrpc::bindings::starstream::ledger::types::GetError; use crate::wrpc::codec::{ValEncoder, read_value}; use crate::wrpc::{ - LEDGER_BLOCK_INSTANCE, LEDGER_CONTRACT_INSTANCE, LEDGER_GENESIS_INSTANCE, + LEDGER_ACCOUNT_INSTANCE, LEDGER_ADMIN_INSTANCE, LEDGER_BLOCK_INSTANCE, + LEDGER_CONTRACT_INSTANCE, LEDGER_GENESIS_INSTANCE, LEDGER_NETWORK_INSTANCE, LEDGER_TRANSACTION_INSTANCE, LEDGER_UTXO_INSTANCE, ensure_eof, flatten_stream, read_params, }; use crate::{ @@ -489,15 +490,16 @@ impl Ledger { transaction: Option<[u8; 32]>, index: usize, ) -> Option> { - let utxo = if let Some(transaction) = transaction { + if let Some(transaction) = transaction { let txs = self.transactions.read().await; let Transaction { outputs, .. } = txs.get(&transaction)?; - outputs.get(index).cloned() + let utxo = outputs.get(index)?; + utxo.as_ref().map(Arc::clone) } else { let outputs = self.genesis.outputs.read().await; - outputs.get(index).cloned() - }; - utxo.flatten() + let utxo = outputs.get(index)?; + utxo.as_ref().map(Arc::clone) + } } async fn handle_rpc_get( @@ -591,6 +593,14 @@ impl Ledger { vs.get(&digest).ok_or(GetError::NotFound) } + fn get_by_key<'a, T>( + vs: &'a HashMap, + key: &[u8], + ) -> Result<&'a T, GetError> { + let key = VerifyingKey::try_from(key)?; + vs.get(&key).ok_or(GetError::NotFound) + } + let content_type = headers .get(CONTENT_TYPE) .ok_or(RpcPostError::ContentTypeMissing)?; @@ -627,6 +637,35 @@ impl Ledger { let mut body = flatten_stream(body); let mut data = BytesMut::new(); match (instance.as_str(), name.as_str()) { + (LEDGER_ADMIN_INSTANCE, "get-nonce") => { + ensure_eof(body) + .await + .map_err(RpcPostError::ParameterDecoding)?; + let nonce = self.admin.last_nonce.load(Ordering::Relaxed); + wrpc_pack::pack(nonce, &mut data).map_err(RpcPostError::ResultEncoding)?; + } + (LEDGER_ACCOUNT_INSTANCE, "get-balance") => { + let key = read_params(body, ::default()) + .await + .map_err(RpcPostError::ParameterDecoding)?; + let balance = { + let accounts = self.accounts.read().await; + get_by_key(&accounts, &key) + .map(|account| account.balance.load(Ordering::Relaxed)) + }; + wrpc_pack::pack(balance, &mut data).map_err(RpcPostError::ResultEncoding)?; + } + (LEDGER_ACCOUNT_INSTANCE, "get-nonce") => { + let key = read_params(body, ::default()) + .await + .map_err(RpcPostError::ParameterDecoding)?; + let nonce = { + let accounts = self.accounts.read().await; + get_by_key(&accounts, &key) + .map(|account| account.last_nonce.load(Ordering::Relaxed)) + }; + wrpc_pack::pack(nonce, &mut data).map_err(RpcPostError::ResultEncoding)?; + } (LEDGER_BLOCK_INSTANCE, "get-height") => { ensure_eof(body) .await @@ -636,43 +675,73 @@ impl Ledger { .map_err(|err| RpcPostError::ResultEncoding(std::io::Error::other(err)))?; wrpc_pack::pack(height, &mut data).map_err(RpcPostError::ResultEncoding)?; } + (LEDGER_BLOCK_INSTANCE, "get-block") => { + let height = read_params(body, U64Codec) + .await + .map_err(RpcPostError::ParameterDecoding)?; + let envelopes = { + let blocks = self.blocks.read().await; + usize::try_from(height) + .ok() + .and_then(|height| height.checked_sub(1)) + .and_then(|i| blocks.get(i)) + .map(|block| { + block + .actions + .iter() + .map(|action| match action { + Action::UploadContract(envelope) + | Action::FundAccount(envelope) + | Action::Transaction(envelope) => envelope.clone(), + }) + .collect::>() + }) + }; + wrpc_pack::pack(envelopes, &mut data).map_err(RpcPostError::ResultEncoding)?; + } (LEDGER_CONTRACT_INSTANCE, "get-envelope") => { let digest = read_params(body, ::default()) .await .map_err(RpcPostError::ParameterDecoding)?; - let contracts = self.contracts.read().await; - let envelope = - get_by_digest(&contracts, &digest).map(|contract| &contract.envelope); + let envelope = { + let contracts = self.contracts.read().await; + get_by_digest(&contracts, &digest).map(|contract| contract.envelope.clone()) + }; wrpc_pack::pack(envelope, &mut data).map_err(RpcPostError::ResultEncoding)?; } (LEDGER_CONTRACT_INSTANCE, "get-wasm") => { let digest = read_params(body, ::default()) .await .map_err(RpcPostError::ParameterDecoding)?; - let contracts = self.contracts.read().await; - let wasm = get_by_digest(&contracts, &digest).map(|contract| &contract.wasm); + let wasm = { + let contracts = self.contracts.read().await; + get_by_digest(&contracts, &digest).map(|contract| contract.wasm.clone()) + }; wrpc_pack::pack(wasm, &mut data).map_err(RpcPostError::ResultEncoding)?; } (LEDGER_TRANSACTION_INSTANCE, "get-envelope") => { let digest = read_params(body, ::default()) .await .map_err(RpcPostError::ParameterDecoding)?; - let txs = self.transactions.read().await; - let envelope = - get_by_digest(&txs, &digest).map(|Transaction { envelope, .. }| envelope); + let envelope = { + let txs = self.transactions.read().await; + get_by_digest(&txs, &digest).map(|tx| tx.envelope.clone()) + }; wrpc_pack::pack(envelope, &mut data).map_err(RpcPostError::ResultEncoding)?; } (LEDGER_TRANSACTION_INSTANCE, "get-transaction") => { let digest = read_params(body, ::default()) .await .map_err(RpcPostError::ParameterDecoding)?; - let txs = self.transactions.read().await; - let tx = get_by_digest(&txs, &digest).map(|Transaction { encoded, .. }| encoded); + let tx = { + let txs = self.transactions.read().await; + get_by_digest(&txs, &digest).map(|tx| tx.encoded.clone()) + }; match tx { Ok(tx) => { // `result::ok` discriminant followed by the stored wRPC-encoded `transaction` data.put_u8(0); - data.extend_from_slice(tx); + data.extend_from_slice(&tx); } Err(err) => wrpc_pack::pack(Err::<(), _>(err), &mut data) .map_err(RpcPostError::ResultEncoding)?, @@ -684,6 +753,12 @@ impl Ledger { .map_err(RpcPostError::ParameterDecoding)?; data.extend_from_slice(&self.genesis.encoded); } + (LEDGER_NETWORK_INSTANCE, "get-name") => { + ensure_eof(body) + .await + .map_err(RpcPostError::ParameterDecoding)?; + wrpc_pack::pack(&*self.network, &mut data).map_err(RpcPostError::ResultEncoding)?; + } (LEDGER_UTXO_INSTANCE, _) => { let tx = if body .read_option_status() @@ -717,8 +792,9 @@ impl Ledger { let contract = contracts .get(&contract) .ok_or(RpcPostError::ContractNotFound)?; - apply_state(&contract.wasm, &utxo.state).map_err(RpcPostError::StateMerge)? + contract.wasm.clone() }; + let wasm = apply_state(&wasm, &utxo.state).map_err(RpcPostError::StateMerge)?; // TODO: Insert traps in place of all coordination script imports diff --git a/starstream-ledger/src/wrpc/bindings.rs b/starstream-ledger/src/wrpc/bindings.rs index c1b46ea6..b7842d3d 100644 --- a/starstream-ledger/src/wrpc/bindings.rs +++ b/starstream-ledger/src/wrpc/bindings.rs @@ -11,7 +11,13 @@ use starstream::ledger::types; impl From for types::GetError { fn from(err: DigestParseError) -> Self { - Self::InvalidDigest(err.to_string()) + Self::Malformed(err.to_string()) + } +} + +impl From for types::GetError { + fn from(err: ed25519_dalek::SignatureError) -> Self { + Self::Malformed(err.to_string()) } } diff --git a/starstream-ledger/src/wrpc/mod.rs b/starstream-ledger/src/wrpc/mod.rs index 2236fcf0..163586f8 100644 --- a/starstream-ledger/src/wrpc/mod.rs +++ b/starstream-ledger/src/wrpc/mod.rs @@ -13,10 +13,13 @@ pub mod bindings; #[cfg(any(feature = "client", feature = "server"))] pub mod codec; +pub const LEDGER_ADMIN_INSTANCE: &str = "starstream:ledger/admin"; +pub const LEDGER_ACCOUNT_INSTANCE: &str = "starstream:ledger/account"; pub const LEDGER_BLOCK_INSTANCE: &str = "starstream:ledger/block"; pub const LEDGER_CONTRACT_INSTANCE: &str = "starstream:ledger/contract"; pub const LEDGER_TRANSACTION_INSTANCE: &str = "starstream:ledger/transaction"; pub const LEDGER_GENESIS_INSTANCE: &str = "starstream:ledger/genesis"; +pub const LEDGER_NETWORK_INSTANCE: &str = "starstream:ledger/network"; pub const LEDGER_UTXO_INSTANCE: &str = "starstream:ledger/utxo"; /// Flatten a framed wRPC stream diff --git a/starstream-ledger/tests/ledger.rs b/starstream-ledger/tests/ledger.rs index d6edb42f..4c77bf3a 100644 --- a/starstream-ledger/tests/ledger.rs +++ b/starstream-ledger/tests/ledger.rs @@ -17,12 +17,12 @@ use http_body_util::{BodyExt as _, Full}; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::TokioExecutor; use sha2::{Digest as _, Sha256}; -use starstream_ledger::client::build_publish_envelope; use starstream_ledger::client::http::{ ClientBuilder, build_contract_get_request, build_contract_publish_request, build_fund_request, build_transaction_get_request, }; use starstream_ledger::client::runtime::{Client, call_coordination_script, compile_contract}; +use starstream_ledger::client::{build_fund_envelope, build_publish_envelope}; use starstream_ledger::server::Ledger; use starstream_ledger::wrpc::LEDGER_BLOCK_INSTANCE; use starstream_ledger::wrpc::codec::ValEncoder; @@ -175,14 +175,33 @@ async fn http() { let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()); let api_base = Uri::from_str(&format!("http://{addr}")).unwrap(); - let client = ClientBuilder::new(http.clone(), HttpConnector::new(), api_base.clone()) - .network(NETWORK) - .build(); + let client = ClientBuilder::new(http.clone(), HttpConnector::new(), api_base.clone()).build(); let http = http.build_http(); let height = client.block_height().await.unwrap(); assert_eq!(height, 0); + let name = client.get_network().await.unwrap(); + assert_eq!(name, NETWORK); + + let nonce = client.get_admin_nonce().await.unwrap(); + assert_eq!(nonce, 0); + let balance = client + .get_account_balance(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(balance, None); + let nonce = client + .get_account_nonce(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(nonce, None); + + let block = client.get_block(0).await.unwrap(); + assert_eq!(block, None); + let block = client.get_block(1).await.unwrap(); + assert_eq!(block, None); + let outputs = client.get_genesis().await.unwrap(); assert_eq!(outputs, genesis); @@ -231,13 +250,45 @@ async fn http() { let balance = score_publish_cost.saturating_sub(1000); client - .fund(ADMIN.clone(), 1, &ADMIN.verifying_key(), balance as _) + .fund( + ADMIN.clone(), + NETWORK, + 1, + &ADMIN.verifying_key(), + balance as _, + ) .await .unwrap(); let height = client.block_height().await.unwrap(); assert_eq!(height, 1); + let nonce = client.get_admin_nonce().await.unwrap(); + assert_eq!(nonce, 1); + let got = client + .get_account_balance(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(got, Some(balance as _)); + let nonce = client + .get_account_nonce(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(nonce, Some(0)); + + let fund_envelope = build_fund_envelope( + ADMIN.clone(), + NETWORK, + starstream_ledger::Fund { + nonce: 1, + account: ADMIN.verifying_key().to_bytes(), + amount: balance as _, + }, + ) + .unwrap(); + let block = client.get_block(1).await.unwrap(); + assert_eq!(block, Some(vec![Bytes::from(fund_envelope)])); + let req = build_fund_request( &api_base, ADMIN.clone(), @@ -275,6 +326,7 @@ async fn http() { client .fund( ADMIN.clone(), + NETWORK, 2, &ADMIN.verifying_key(), (score_publish_cost - balance) as _, @@ -298,7 +350,7 @@ async fn http() { assert!(body.starts_with("invalid Wasm: "), "{body}"); let digest = client - .publish_contract(ADMIN.clone(), 2, SCORE_WASM.clone()) + .publish_contract(ADMIN.clone(), NETWORK, 2, SCORE_WASM.clone()) .await .unwrap(); assert_eq!(digest, *SCORE_WASM_DIGEST); @@ -344,6 +396,24 @@ async fn http() { let height = client.block_height().await.unwrap(); assert_eq!(height, 3); + let nonce = client.get_admin_nonce().await.unwrap(); + assert_eq!(nonce, 2); + let balance = client + .get_account_balance(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(balance, Some(0)); + let nonce = client + .get_account_nonce(&ADMIN.verifying_key()) + .await + .unwrap(); + assert_eq!(nonce, Some(2)); + + let block = client.get_block(3).await.unwrap(); + assert_eq!(block, Some(vec![Bytes::from(score_publish_envelope)])); + let block = client.get_block(4).await.unwrap(); + assert_eq!(block, None); + let score_contract = compile_contract( &client, client.engine(), @@ -400,6 +470,7 @@ async fn http() { client .transact( ADMIN.clone(), + NETWORK, Transaction { inputs, outputs: outputs.clone(), @@ -413,6 +484,7 @@ async fn http() { let err = client .transact( ADMIN.clone(), + NETWORK, Transaction { inputs: vec![TransactionInput::genesis(0), TransactionInput::genesis(0)], outputs: outputs.clone(), @@ -432,7 +504,10 @@ async fn http() { }; let tx_cbor = minicbor::to_vec(&tx).unwrap(); let tx_digest: [u8; 32] = Sha256::digest(&tx_cbor).into(); - let digest = client.transact(ADMIN.clone(), tx.clone()).await.unwrap(); + let digest = client + .transact(ADMIN.clone(), NETWORK, tx.clone()) + .await + .unwrap(); assert_eq!(digest, tx_digest); let height = client.block_height().await.unwrap(); assert_eq!(height, 4); @@ -479,6 +554,7 @@ async fn http() { let err = client .transact( ADMIN.clone(), + NETWORK, Transaction { outputs: Vec::default(), ..tx @@ -581,11 +657,9 @@ async fn serve( let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()); let api_base = Uri::from_str(&format!("http://{addr}")).unwrap(); - let client = ClientBuilder::new(http, HttpConnector::new(), api_base) - .network(NETWORK) - .build(); + let client = ClientBuilder::new(http, HttpConnector::new(), api_base).build(); client - .fund(ADMIN.clone(), 1, &ADMIN.verifying_key(), 1 << 20) + .fund(ADMIN.clone(), NETWORK, 1, &ADMIN.verifying_key(), 1 << 20) .await .unwrap(); @@ -656,7 +730,7 @@ script fn create() { .unwrap(); let (client, shutdown, ledger) = serve(tx.outputs).await; client - .publish_contract(ADMIN.clone(), 2, wasm) + .publish_contract(ADMIN.clone(), NETWORK, 2, wasm) .await .unwrap(); @@ -721,9 +795,7 @@ async fn stalled_connections_are_closed() { let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()); let api_base = Uri::from_str(&format!("http://{addr}")).unwrap(); - let client = ClientBuilder::new(http, HttpConnector::new(), api_base) - .network(NETWORK) - .build(); + let client = ClientBuilder::new(http, HttpConnector::new(), api_base).build(); let height = tokio::time::timeout(Duration::from_secs(10), client.block_height()) .await .expect("idle connection must be closed") @@ -807,7 +879,7 @@ utxo Counter { }; let (client, shutdown, ledger) = serve([output]).await; client - .publish_contract(ADMIN.clone(), 2, wasm) + .publish_contract(ADMIN.clone(), NETWORK, 2, wasm) .await .unwrap(); @@ -853,7 +925,7 @@ utxo Counter { let (client, shutdown, ledger) = serve([]).await; let err = client - .publish_contract(ADMIN.clone(), 2, wasm) + .publish_contract(ADMIN.clone(), NETWORK, 2, wasm) .await .expect_err("publishing a UTXO method taking a UTXO must fail"); shutdown.notify_one(); @@ -882,7 +954,7 @@ async fn publish_package_cycle() { let (client, shutdown, ledger) = serve([]).await; client - .publish_contract(ADMIN.clone(), 2, wasm) + .publish_contract(ADMIN.clone(), NETWORK, 2, wasm) .await .unwrap(); shutdown.notify_one(); diff --git a/starstream-ledger/wit/ledger.wit b/starstream-ledger/wit/ledger.wit index 78c05322..5a1cc975 100644 --- a/starstream-ledger/wit/ledger.wit +++ b/starstream-ledger/wit/ledger.wit @@ -41,8 +41,8 @@ interface types { } variant get-error { - invalid-digest(string), not-found, + malformed(string), } record transaction { @@ -53,8 +53,20 @@ interface types { } } +interface admin { + get-nonce: func() -> u64; +} + +interface account { + use types.{get-error}; + + get-balance: func(key: list) -> result; + get-nonce: func(key: list) -> result; +} + interface block { get-height: func() -> u64; + get-block: func(height: u64) -> option>>; } interface contract { @@ -77,9 +89,16 @@ interface genesis { get-outputs: func() -> list; } +interface network { + get-name: func() -> string; +} + world client { + import admin; + import account; import block; import contract; import transaction; import genesis; + import network; }