From 7317cee06106cc3e1e299e0b98b56c12237abf1f Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Wed, 21 Oct 2026 17:05:32 +0200 Subject: [PATCH 1/4] deps: relock stale `tower-http` `bitflags` dependency #268 and #265 raced: `tower-http` still pointed at `bitflags` 2.9.4, which #265 dropped from the lock, breaking `cargo check --locked`. Assisted-by: anthropic:claude-opus-5-5 Signed-off-by: Roman Volosatovs --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 56b6a89d..a7a664a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4174,7 +4174,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "bytes", "http", "http-body", From 3cad76588313e689d8c0fa5769208c1e3810fe2a Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 19 Oct 2026 16:46:47 +0200 Subject: [PATCH 2/4] runtime: identify instances by `utxo`/`token` resources `Contract::utxos` treated every exported instance exporting anything named `utxo` as a UTXO instance, while `get_utxo_typed` then rejects it unless that export is a resource, and the typed UTXO imports are already matched only on a `utxo` resource. Require the export to be a resource, so an instance exporting a `utxo` type of another kind, or a `utxo` function, is not listed as a UTXO instance. Assisted-by: anthropic:claude-opus-5-5 Signed-off-by: Roman Volosatovs --- starstream-runtime/src/lib.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/starstream-runtime/src/lib.rs b/starstream-runtime/src/lib.rs index 27537f7a..87214d67 100644 --- a/starstream-runtime/src/lib.rs +++ b/starstream-runtime/src/lib.rs @@ -1354,9 +1354,14 @@ impl Contract { let engine = self.pre.engine(); self.ty.exports(engine).filter_map( move |(name, types::ComponentExtern { ty, .. })| match ty { - types::ComponentItem::ComponentInstance(ty) - if ty.get_export(engine, "utxo").is_some() => - { + types::ComponentItem::ComponentInstance(ty) => { + let Some(types::ComponentExtern { + ty: types::ComponentItem::Resource(..), + .. + }) = ty.get_export(engine, "utxo") + else { + return None; + }; Some((name, self.get_utxo_typed(name, ty))) } _ => None, @@ -1536,9 +1541,14 @@ impl Contract { let engine = self.pre.engine(); self.ty.exports(engine).filter_map( move |(name, types::ComponentExtern { ty, .. })| match ty { - types::ComponentItem::ComponentInstance(ty) - if ty.get_export(engine, "token").is_some() => - { + types::ComponentItem::ComponentInstance(ty) => { + let Some(types::ComponentExtern { + ty: types::ComponentItem::Resource(..), + .. + }) = ty.get_export(engine, "token") + else { + return None; + }; Some((name, self.get_token_typed(name, ty))) } _ => None, From c47faa86bde11d2f86888ea8ab8922d0b7e78d31 Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Mon, 19 Oct 2026 17:26:28 +0200 Subject: [PATCH 3/4] deps: pin `headers-accept` to the `q`-insensitive matching fix `headers-accept` is pinned to the head of maxcountryman/headers-accept#24, on top of the unreleased upstream main, whose media range matching ignores `q` when comparing parameters, so `Accept: text/plain;q=0.9` matches the `text/plain; charset=UTF-8` the ledger responds with. The PR additionally compares charset values case-insensitively, so `charset=utf-8` matches it too. The revision is fetched from the upstream repository, which holds the PR head, rather than from the fork it was pushed to. Assisted-by: anthropic:claude-opus-5-5 Assisted-by: anthropic:claude-fable-5-1 Signed-off-by: Roman Volosatovs --- Cargo.lock | 3 +-- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7a664a4..a0a67605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1540,8 +1540,7 @@ dependencies = [ [[package]] name = "headers-accept" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78beb98ea9a8f76b4a38c5ca651db6aa84701f307b63b4e0d78342ad4219c7e4" +source = "git+https://github.com/maxcountryman/headers-accept?rev=e5a875f2504267f2fa4913e595e4299a76365db8#e5a875f2504267f2fa4913e595e4299a76365db8" dependencies = [ "headers-core", "http", diff --git a/Cargo.toml b/Cargo.toml index 98cf1e8d..378156a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ coset = { version = "0.3", default-features = false } cranelift-codegen = { version = "0.136", default-features = false } ed25519-dalek = { version = "2", default-features = false } futures = { version = "0.3", default-features = false } -headers-accept = { version = "0.3", default-features = false } +headers-accept = { git = "https://github.com/maxcountryman/headers-accept", rev = "e5a875f2504267f2fa4913e595e4299a76365db8", default-features = false } headers-core = { version = "0.3", default-features = false } hex = { version = "0.4.3", default-features = false } http = { version = "1", default-features = false } From 57a9fd44ca38c1218eeeda95df0712856d7a93ab Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Tue, 20 Oct 2026 12:04:50 +0200 Subject: [PATCH 4/4] ledger: serve invocation WIT on `GET /rpc` `GET /rpc` returns the `starstream:ledger` package describing the ledger queries `POST /rpc` accepts. `GET /rpc?index=INDEX`, optionally with `&transaction=DIGEST`, instead returns a `starstream:ledger` package holding only a `utxo` interface for that unspent output: its UTXO's methods as the freestanding functions `/rpc` invokes. Each takes the output reference (`transaction`, `index`) followed by the method parameters, which are prefixed with `arg-` so the names never clash. `index` is a `u32` and `transaction` a digest, defaulting to the genesis block. Repeating either is rejected with 400, and an empty query (`GET /rpc?`, `GET /rpc?&`) is treated as no query. Both are served as WIT text by default, or encoded as Wasm on `Accept: application/wasm`, so clients can generate wRPC bindings without decoding the contract themselves. `HEAD /rpc` returns the headers of the same response. The `GET` and `HEAD` handlers of `/contracts`, `/transactions` and `/rpc` are merged into one handler each, generic over `HEAD`, which omits the body but keeps `Content-Length`. The UTXO WIT of every exported instance that exports a `utxo` resource is built once, at publish, and served from memory, keyed by export name as `POST /rpc` resolves it. It is converted from the component types the publish validation already computes into a fresh `Resolve` holding only the ledger package. The contract's WIT is never decoded with wit-parser, whose decoder overflows the stack, aborting the node, on valid components whose imported packages depend on each other. Only the shapes the compiler produces are handled: synchronous methods whose parameters and results are booleans, integers, and options, results and tuples of them. The `utxo` interface therefore defines no named types and depends on no other package. Publishing now rejects with 400 Wasm that is not a component and contracts with UTXO methods taking or returning anything else, such as a UTXO handle, naming the offending parameter or result. The ledger package is encoded to Wasm by a build script, only with the `server` feature, which is also the only one pulling in the script's `anyhow` build dependency. The ledger client gains `build_utxo_wit_request` and `Client::get_utxo_wit`, which takes an optional `Accept` media type and returns the raw body. GET request builders share a helper. `TransactionInput` gains `genesis` and `new` constructors. The CLI gains `ledger utxo wit [--transaction DIGEST] `, printing a UTXO's WIT in the shape `ledger utxo call` invokes it. `ledger utxo call` now resolves the method type from the Wasm-encoded UTXO WIT instead of downloading and decoding the whole contract. The CLI e2e test now starts the ledger with the outputs of the simulated `example` call as genesis, prints the UTXO's WIT and calls one of its methods. The ledger tests cover `GET /rpc`, the UTXO WIT of a compiled contract using options, results and tuples with parameters named `index` and `transaction`, rejecting a UTXO method taking a UTXO, and publishing a component whose imported packages form a cycle. Their `serve` helper no longer publishes the contract itself. Assisted-by: anthropic:claude-opus-5-5 Assisted-by: anthropic:claude-fable-5-1 Signed-off-by: Roman Volosatovs --- Cargo.lock | 2 + Cargo.toml | 1 + starstream-ledger-cli/src/lib.rs | 117 +++++----- starstream-ledger-cli/tests/cli.rs | 72 ++++-- starstream-ledger/Cargo.toml | 11 + starstream-ledger/build.rs | 21 ++ starstream-ledger/src/client/http.rs | 58 +++-- starstream-ledger/src/lib.rs | 16 ++ starstream-ledger/src/server/http/error.rs | 52 ++++- starstream-ledger/src/server/http/mod.rs | 235 +++++++++++++------- starstream-ledger/src/server/mod.rs | 2 + starstream-ledger/src/server/wit.rs | 246 +++++++++++++++++++++ starstream-ledger/tests/ledger.rs | 242 ++++++++++++++------ 13 files changed, 826 insertions(+), 249 deletions(-) create mode 100644 starstream-ledger/build.rs create mode 100644 starstream-ledger/src/server/wit.rs diff --git a/Cargo.lock b/Cargo.lock index a0a67605..658352cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3583,6 +3583,7 @@ dependencies = [ "bytes", "coset", "ed25519-dalek", + "form_urlencoded", "futures", "headers-accept", "headers-core", @@ -3618,6 +3619,7 @@ dependencies = [ "wat", "wit-bindgen-wrpc", "wit-component 0.258.3", + "wit-parser 0.258.3", "wrpc-http", "wrpc-pack", "wrpc-transport", diff --git a/Cargo.toml b/Cargo.toml index 378156a2..f968cf6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ clap = { version = "4.6.1", default-features = false } coset = { version = "0.3", default-features = false } cranelift-codegen = { version = "0.136", default-features = false } ed25519-dalek = { version = "2", default-features = false } +form_urlencoded = { version = "1", default-features = false } futures = { version = "0.3", default-features = false } headers-accept = { git = "https://github.com/maxcountryman/headers-accept", rev = "e5a875f2504267f2fa4913e595e4299a76365db8", default-features = false } headers-core = { version = "0.3", default-features = false } diff --git a/starstream-ledger-cli/src/lib.rs b/starstream-ledger-cli/src/lib.rs index 5637679f..6ea74002 100644 --- a/starstream-ledger-cli/src/lib.rs +++ b/starstream-ledger-cli/src/lib.rs @@ -21,7 +21,7 @@ use starstream_ledger::client::http::ClientBuilder; use starstream_ledger::client::runtime::UtxoCtx; use starstream_ledger::client::runtime::{Client, Ctx, call_coordination_script, compile_contract}; use starstream_ledger::{ - CardanoCtx, Transaction, TransactionInput, TransactionOutput, encode_digest, + APPLICATION_WASM, CardanoCtx, Transaction, TransactionInput, TransactionOutput, encode_digest, }; use starstream_runtime::Utxo; use tokio::fs; @@ -226,17 +226,32 @@ enum TransactionCommand { }, } +#[derive(Debug, clap::Args)] +struct UtxoArgs { + /// Digest of the transaction that produced the UTXO, either as multibase multihash + /// or `sha256:HEX`, defaults to the genesis block. + #[arg(long, value_name = "DIGEST", value_parser = parse_digest)] + transaction: Option<[u8; 32]>, + + /// Index of the UTXO in the transaction outputs. + index: u32, +} + +impl From for TransactionInput { + fn from(UtxoArgs { transaction, index }: UtxoArgs) -> Self { + match transaction { + Some(transaction) => Self::new(transaction, index), + None => Self::genesis(index), + } + } +} + #[derive(Debug, Subcommand)] enum UtxoCommand { /// Call a method of a UTXO on the ledger, discarding the resulting state. Call { - /// Digest of the transaction that produced the UTXO, either as multibase multihash - /// or `sha256:HEX`, defaults to the genesis block. - #[arg(long, value_name = "DIGEST", value_parser = parse_digest)] - transaction: Option<[u8; 32]>, - - /// Index of the UTXO in the transaction outputs. - index: u32, + #[command(flatten)] + utxo: UtxoArgs, /// Method to call. method: Box, @@ -244,14 +259,11 @@ enum UtxoCommand { /// WAVE-encoded method arguments. args: Vec>, }, -} - -fn decode_component(wasm: &[u8]) -> anyhow::Result<(wit_parser::Resolve, wit_parser::WorldId)> { - let wasm = wit_parser::decoding::decode(wasm).context("failed to decode Wasm")?; - let wit_parser::decoding::DecodedWasm::Component(resolve, world) = wasm else { - bail!("Wasm is not a component") - }; - Ok((resolve, world)) + /// Print the WIT of a UTXO's methods, in the shape `utxo call` invokes them. + Wit { + #[command(flatten)] + utxo: UtxoArgs, + }, } /// Encode WAVE-encoded `args` as parameters of type `ty`. @@ -319,14 +331,12 @@ fn parse_input(s: &str) -> anyhow::Result { let (transaction, index) = s .rsplit_once(':') .context("input must be formatted as `[TRANSACTION]:INDEX`")?; - let transaction = if transaction.is_empty() { - Box::default() - } else { - let digest = parse_digest(transaction).context("transaction digest is not valid")?; - encode_digest(&digest).into() - }; let index = index.parse().context("input index is not valid")?; - Ok(TransactionInput { transaction, index }) + if transaction.is_empty() { + return Ok(TransactionInput::genesis(index)); + } + let transaction = parse_digest(transaction).context("transaction digest is not valid")?; + Ok(TransactionInput::new(transaction, index)) } fn parse_verifying_key(s: &str) -> anyhow::Result { @@ -571,50 +581,23 @@ async fn exec(args: Args) -> anyhow::Result<()> { .await .context("failed to write transaction to stdout") } - Command::Utxo(UtxoCommand::Call { - transaction, - index, - method, - args, - }) => { - let transaction = if let Some(transaction) = transaction { - encode_digest(&transaction).into() - } else { - Box::default() + Command::Utxo(UtxoCommand::Call { utxo, method, args }) => { + let input = TransactionInput::from(utxo); + let wasm = client.get_utxo_wit(&input, Some(APPLICATION_WASM)).await?; + let wasm = wit_parser::decoding::decode(&wasm).context("failed to decode UTXO WIT")?; + let wit_parser::decoding::DecodedWasm::WitPackage(resolve, pkg) = wasm else { + bail!("UTXO WIT is not a WIT package") }; - let input = TransactionInput { transaction, index }; - let TransactionOutput { - contract, instance, .. - } = client.get_input_utxo(&input).await?; - let digest = starstream_ledger::parse_digest(&contract) - .with_context(|| format!("failed to parse `{contract}` as multibase multihash"))?; - let wasm = client.get_contract(digest).await?; - let wasm = wasm.context("contract not found")?; - let (resolve, world) = decode_component(&wasm)?; - let world = &resolve.worlds[world]; - let ty = world - .exports - .iter() - .find_map(|(name, item)| { - let wit_parser::WorldKey::Name(name) = name else { - return None; - }; - let wit_parser::WorldItem::Interface { id, .. } = item else { - return None; - }; - if name == instance.as_ref() { - Some(id) - } else { - None - } - }) - .with_context(|| format!("UTXO instance `{instance}` not found"))?; - let ty = resolve.interfaces[*ty] + let utxo = resolve.packages[pkg] + .interfaces + .get("utxo") + .context("`utxo` interface not found")?; + let ty = resolve.interfaces[*utxo] .functions - .get(&format!("[method]utxo.{method}")) + .get(&*method) .with_context(|| format!("method `{method}` not found"))?; let ty = wit_parser::Function { - params: ty.params.iter().skip(1).cloned().collect(), + params: ty.params.iter().skip(2).cloned().collect(), ..ty.clone() }; let ty = wasm_wave::value::resolve_wit_func_type(&resolve, &ty) @@ -623,5 +606,13 @@ async fn exec(args: Args) -> anyhow::Result<()> { let rx = client.call_utxo_method(&input, &method, &args).await?; write_results(&ty, rx).await } + Command::Utxo(UtxoCommand::Wit { utxo }) => { + let input = TransactionInput::from(utxo); + let wit = client.get_utxo_wit(&input, None).await?; + stdout() + .write_all(&wit) + .await + .context("failed to write WIT to stdout") + } } } diff --git a/starstream-ledger-cli/tests/cli.rs b/starstream-ledger-cli/tests/cli.rs index 3623c213..6dfa708a 100644 --- a/starstream-ledger-cli/tests/cli.rs +++ b/starstream-ledger-cli/tests/cli.rs @@ -71,23 +71,6 @@ async fn cli() { starstream_ledger::server::configure_wasmtime(&mut engine_config); let engine = wasmtime::Engine::new(&engine_config).unwrap(); - let ledger = Ledger::new( - engine, - 128, - u64::MAX, - NETWORK, - ADMIN.verifying_key(), - [], - CardanoCtx::default(), - ); - let ledger = Arc::new(ledger); - let addr = free_tcp_addr().await.unwrap(); - let (ledger, shutdown) = ledger - .handle_http(addr, Duration::from_secs(30)) - .await - .expect("failed to handle HTTP"); - let ledger = tokio::spawn(ledger); - let score_wasm = NamedTempFile::new().unwrap(); fs::write(&score_wasm, &*SCORE_WASM) .await @@ -103,8 +86,6 @@ async fn cli() { let tx_file = NamedTempFile::new().unwrap(); let stdout = run_cli([ - "--url", - &format!("http://{addr}"), "contract", "script", "call", @@ -130,6 +111,23 @@ async fn cli() { let shown: toml::Value = toml::from_slice(&stdout).expect("failed to decode transaction TOML"); assert_eq!(shown, toml::Value::try_from(&tx).unwrap()); + let ledger = Ledger::new( + engine, + 128, + u64::MAX, + NETWORK, + ADMIN.verifying_key(), + tx.outputs, + CardanoCtx::default(), + ); + let ledger = Arc::new(ledger); + let addr = free_tcp_addr().await.unwrap(); + let (ledger, shutdown) = ledger + .handle_http(addr, Duration::from_secs(30)) + .await + .expect("failed to handle HTTP"); + let ledger = tokio::spawn(ledger); + let publish_envelope = build_publish_envelope( account.clone(), NETWORK, @@ -207,6 +205,42 @@ async fn cli() { let tx = fs::read(&tx_file).await.unwrap(); assert_score_transaction(&tx, score_digest); + let stdout = run_cli(["--url", &format!("http://{addr}"), "utxo", "wit", "0"]) + .await + .unwrap(); + assert_eq!( + str::from_utf8(&stdout).unwrap(), + r#"package starstream:ledger; + +interface utxo { + get-chips: func(transaction: option, index: u32) -> u64; + + get-mult: func(transaction: option, index: u32) -> u64; + + plus-chips: func(transaction: option, index: u32, arg-chips2: u64); + + plus-mult: func(transaction: option, index: u32, arg-mult2: u64); + + mult-mult: func(transaction: option, index: u32, arg-mult-pct: u64); + + finish: func(transaction: option, index: u32); +} + +"# + ); + + let stdout = run_cli([ + "--url", + &format!("http://{addr}"), + "utxo", + "call", + "0", + "get-chips", + ]) + .await + .unwrap(); + assert_eq!(stdout, b"(42)\n"); + let require_hash_preimage_wasm = NamedTempFile::new().unwrap(); fs::write(&require_hash_preimage_wasm, &*REQUIRE_HASH_PREIMAGE_WASM) .await diff --git a/starstream-ledger/Cargo.toml b/starstream-ledger/Cargo.toml index 7af2741c..3cfad314 100644 --- a/starstream-ledger/Cargo.toml +++ b/starstream-ledger/Cargo.toml @@ -17,12 +17,15 @@ client = [ "wrpc-http/client-legacy", ] server = [ + "dep:anyhow", "dep:headers-accept", "dep:headers-core", "dep:http-body", "dep:hyper", "dep:tokio-io-timeout", "dep:tower-http", + "dep:wit-component", + "dep:wit-parser", "dep:wrpc-pack", "hyper-util/server", "hyper-util/server-graceful", @@ -34,6 +37,7 @@ anyhow = { workspace = true } bytes = { workspace = true } coset = { workspace = true, features = ["std"] } ed25519-dalek = { workspace = true, features = ["fast", "std", "zeroize"] } +form_urlencoded = { workspace = true, features = ["std"] } futures = { workspace = true } headers-accept = { workspace = true, optional = true } headers-core = { workspace = true, optional = true } @@ -72,10 +76,17 @@ wasmtime-wizer = { workspace = true, optional = true, features = [ "wasmtime", ] } wit-bindgen-wrpc = { workspace = true } +wit-component = { workspace = true, optional = true } +wit-parser = { workspace = true, optional = true } wrpc-http = { workspace = true, optional = true } wrpc-pack = { workspace = true, optional = true } wrpc-transport = { workspace = true } +[build-dependencies] +anyhow = { workspace = true, optional = true } +wit-component = { workspace = true, optional = true } +wit-parser = { workspace = true, optional = true } + [dev-dependencies] starstream-compiler = { workspace = true } starstream-to-wasm = { workspace = true } diff --git a/starstream-ledger/build.rs b/starstream-ledger/build.rs new file mode 100644 index 00000000..b576a44e --- /dev/null +++ b/starstream-ledger/build.rs @@ -0,0 +1,21 @@ +#[cfg(feature = "server")] +fn main() -> anyhow::Result<()> { + use std::env; + use std::fs; + use std::path::PathBuf; + + use anyhow::Context as _; + + println!("cargo::rerun-if-changed=wit/ledger.wit"); + let mut resolve = wit_parser::Resolve::default(); + let pkg = resolve.push_file("wit/ledger.wit")?; + let wasm = wit_component::encode(&resolve, pkg).context("failed to encode ledger WIT")?; + let out_dir = env::var_os("OUT_DIR").context("`OUT_DIR` not set")?; + let path = PathBuf::from(out_dir).join("ledger.wasm"); + fs::write(&path, wasm).with_context(|| format!("failed to write `{}`", path.display())) +} + +#[cfg(not(feature = "server"))] +fn main() { + println!("cargo::rerun-if-changed=build.rs"); +} diff --git a/starstream-ledger/src/client/http.rs b/starstream-ledger/src/client/http.rs index e8597dcc..6edc59e2 100644 --- a/starstream-ledger/src/client/http.rs +++ b/starstream-ledger/src/client/http.rs @@ -104,14 +104,10 @@ pub fn build_transaction_request( build_envelope_request(base, envelope) } -/// Build a contract get request. -pub fn build_contract_get_request( - base: &Uri, - digest: &[u8; 32], +fn build_get_request( + uri: String, accept: Option, ) -> anyhow::Result>> { - let digest = encode_digest(digest); - let uri = endpoint_uri(base, format!("contracts/{digest}"))?; let req = Request::builder().method(Method::GET).uri(uri); let req = if let Some(accept) = accept { req.header(ACCEPT, accept.to_string()) @@ -121,6 +117,17 @@ pub fn build_contract_get_request( req.body(Full::default()).context("failed to build request") } +/// Build a contract get request. +pub fn build_contract_get_request( + base: &Uri, + digest: &[u8; 32], + accept: Option, +) -> anyhow::Result>> { + let digest = encode_digest(digest); + let uri = endpoint_uri(base, format!("contracts/{digest}"))?; + build_get_request(uri, accept) +} + /// Build a transaction get request. pub fn build_transaction_get_request( base: &Uri, @@ -129,13 +136,23 @@ pub fn build_transaction_get_request( ) -> anyhow::Result>> { let digest = encode_digest(digest); let uri = endpoint_uri(base, format!("transactions/{digest}"))?; - let req = Request::builder().method(Method::GET).uri(uri); - let req = if let Some(accept) = accept { - req.header(ACCEPT, accept.to_string()) - } else { - req - }; - req.body(Full::default()).context("failed to build request") + build_get_request(uri, accept) +} + +/// Build a request for the WIT of the UTXO created by the transaction output `input`. +pub fn build_utxo_wit_request( + base: &Uri, + TransactionInput { transaction, index }: &TransactionInput, + accept: Option, +) -> anyhow::Result>> { + let mut query = form_urlencoded::Serializer::new(String::new()); + if !transaction.is_empty() { + query.append_pair("transaction", transaction); + } + query.append_pair("index", &index.to_string()); + let query = query.finish(); + let uri = endpoint_uri(base, format!("rpc?{query}"))?; + build_get_request(uri, accept) } #[derive(Clone)] @@ -365,6 +382,21 @@ where Ok(rx) } + /// Get the WIT of the UTXO created by the transaction output `input`, + /// describing its methods as `call_utxo_method` invokes them, + /// as WIT text or, if `accept` is `application/wasm`, encoded as Wasm. + #[instrument(skip_all)] + pub async fn get_utxo_wit( + &self, + input: &TransactionInput, + accept: Option>, + ) -> anyhow::Result { + let req = build_utxo_wit_request(&self.api_base, input, accept)?; + let (http::response::Parts { status, .. }, body) = self.request(req).await?; + ensure!(status.is_success(), "{}", String::from_utf8_lossy(&body)); + Ok(body) + } + #[instrument(skip_all)] async fn request( &self, diff --git a/starstream-ledger/src/lib.rs b/starstream-ledger/src/lib.rs index c7828d52..e325fb49 100644 --- a/starstream-ledger/src/lib.rs +++ b/starstream-ledger/src/lib.rs @@ -84,6 +84,22 @@ pub struct TransactionInput { pub index: u32, } +impl TransactionInput { + pub fn genesis(index: u32) -> Self { + Self { + transaction: Box::default(), + index, + } + } + + pub fn new(transaction: [u8; 32], index: u32) -> Self { + Self { + transaction: encode_digest(&transaction).into(), + index, + } + } +} + #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Encode, Decode, Serialize)] pub struct TransactionOutput { #[n(0)] diff --git a/starstream-ledger/src/server/http/error.rs b/starstream-ledger/src/server/http/error.rs index 8b9858d0..71ddaf19 100644 --- a/starstream-ledger/src/server/http/error.rs +++ b/starstream-ledger/src/server/http/error.rs @@ -58,6 +58,8 @@ pub enum PublishPostError { SignerCount(usize), #[error("invalid Wasm: {0}")] Wasm(wasmparser::BinaryReaderError), + #[error("failed to build contract WIT: {0:#}")] + Wit(anyhow::Error), #[error("contract `{}` already exists", encode_digest(.0))] AlreadyExists([u8; 32]), #[error("account ID `{}` not found", hex::encode(.0))] @@ -73,7 +75,7 @@ pub enum PublishPostError { impl PublishPostError { pub fn http_status_code(&self) -> http::StatusCode { match self { - Self::SignerCount(..) | Self::Wasm(..) => http::StatusCode::BAD_REQUEST, + Self::SignerCount(..) | Self::Wasm(..) | Self::Wit(..) => http::StatusCode::BAD_REQUEST, Self::AlreadyExists(..) | Self::NonceTooLow { .. } => http::StatusCode::CONFLICT, Self::AccountNotFound(..) | Self::InsufficientBalance { .. } => { http::StatusCode::PAYMENT_REQUIRED @@ -315,6 +317,54 @@ impl RpcPostError { } } +#[derive(Debug, Error)] +pub enum RpcGetError { + #[error(transparent)] + AcceptHeader(AcceptHeaderError), + #[error("unknown query parameter `{0}`, expected `index` and optionally `transaction`")] + UnknownQueryParameter(Box), + #[error("query parameter `{0}` specified more than once")] + DuplicateQueryParameter(Box), + #[error("`index` query parameter missing")] + UtxoIndexMissing, + #[error("failed to parse transaction digest: {0}")] + TransactionDigestParsing(DigestParseError), + #[error("failed to parse UTXO index: {0}")] + UtxoIndexParsing(core::num::ParseIntError), + #[error("UTXO index does not fit in usize")] + UtxoIndexOverflow, + #[error("UTXO not found")] + UtxoNotFound, + #[error("failed to parse contract digest: {0}")] + ContractDigestParsing(DigestParseError), + #[error("contract not found")] + ContractNotFound, + #[error("UTXO instance `{0}` not found")] + UtxoInstanceNotFound(Box), + #[error(transparent)] + Http(http::Error), +} + +impl RpcGetError { + pub fn http_status_code(&self) -> http::StatusCode { + match self { + Self::AcceptHeader(err) => err.http_status_code(), + Self::UnknownQueryParameter(..) + | Self::DuplicateQueryParameter(..) + | Self::UtxoIndexMissing + | Self::TransactionDigestParsing(..) + | Self::UtxoIndexParsing(..) + | Self::UtxoIndexOverflow => http::StatusCode::BAD_REQUEST, + Self::UtxoNotFound | Self::ContractNotFound | Self::UtxoInstanceNotFound(..) => { + http::StatusCode::NOT_FOUND + } + Self::ContractDigestParsing(..) | Self::Http(..) => { + http::StatusCode::INTERNAL_SERVER_ERROR + } + } + } +} + fn format_media_types(available: &[MediaType<'_>]) -> String { available .iter() diff --git a/starstream-ledger/src/server/http/mod.rs b/starstream-ledger/src/server/http/mod.rs index 80b942bd..97cc2a1e 100644 --- a/starstream-ledger/src/server/http/mod.rs +++ b/starstream-ledger/src/server/http/mod.rs @@ -42,7 +42,7 @@ use wasmtime::component::{ResourceTable, Type, Val}; use crate::cose::read_envelope; use crate::runtime::apply_state; -use crate::server::{Contract, Ctx, Ledger, Transaction, UtxoCtx}; +use crate::server::{Contract, Ctx, Ledger, Transaction, UtxoCtx, wit}; use crate::wrpc::bindings; use crate::wrpc::bindings::starstream::ledger::types::GetError; use crate::wrpc::codec::{ValEncoder, read_value}; @@ -52,7 +52,7 @@ use crate::wrpc::{ }; use crate::{ APPLICATION_COSE, APPLICATION_WASM, APPLICATION_WRPC, Action, Block, Fund, Message, Publish, - TransactionInput, encode_digest, parse_digest, + TransactionInput, TransactionOutput, encode_digest, parse_digest, }; mod error; @@ -60,6 +60,13 @@ use error::*; const MAX_ENVELOPE_SIZE: usize = 1 << 24; +const TEXT_PLAIN_UTF_8: MediaType = MediaType::from_parts( + mediatype::names::TEXT, + mediatype::names::PLAIN, + None, + &[(mediatype::names::CHARSET, mediatype::values::UTF_8)], +); + fn bind_tcp(address: SocketAddr) -> anyhow::Result { debug!("binding TCP socket"); let sock = match address { @@ -103,7 +110,7 @@ where { http::Response::builder() .status(code) - .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .header(CONTENT_TYPE, TEXT_PLAIN_UTF_8.to_string()) .header(X_CONTENT_TYPE_OPTIONS, "nosniff") .body(http_body_util::Full::new(body.into())) } @@ -115,7 +122,7 @@ fn build_method_not_allowed( ) -> http::Result>> { http::Response::builder() .status(http::StatusCode::METHOD_NOT_ALLOWED) - .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .header(CONTENT_TYPE, TEXT_PLAIN_UTF_8.to_string()) .header(X_CONTENT_TYPE_OPTIONS, "nosniff") .header(ALLOW, allow) .body(http_body_util::Full::new( @@ -156,38 +163,7 @@ fn is_timeout(err: &hyper::Error) -> bool { } impl Ledger { - async fn handle_contract_get( - &self, - headers: http::HeaderMap, - digest: &str, - ) -> Result>, ContractGetError> { - const AVAILABLE_TYPES: &[MediaType] = &[APPLICATION_COSE, APPLICATION_WASM]; - - let digest = parse_digest(digest).map_err(ContractGetError::DigestParsing)?; - - let accept = negotiate_accept(&headers, AVAILABLE_TYPES) - .transpose() - .map_err(ContractGetError::AcceptHeader)?; - - let contracts = self.contracts.read().await; - let contract = contracts - .get(&digest) - .ok_or(ContractGetError::ContractNotFound)?; - - let res = http::Response::builder() - .header(VARY, ACCEPT.as_str()) - .header(X_CONTENT_TYPE_OPTIONS, "nosniff"); - if accept == Some(&APPLICATION_WASM) { - res.header(CONTENT_TYPE, APPLICATION_WASM.to_string()) - .body(http_body_util::Full::new(contract.wasm.clone())) - } else { - res.header(CONTENT_TYPE, APPLICATION_COSE.to_string()) - .body(http_body_util::Full::new(contract.envelope.clone())) - } - .map_err(ContractGetError::Http) - } - - async fn handle_contract_head( + async fn handle_contract_get( &self, headers: http::HeaderMap, digest: &str, @@ -211,11 +187,20 @@ impl Ledger { if accept == Some(&APPLICATION_WASM) { res.header(CONTENT_TYPE, APPLICATION_WASM.to_string()) .header(CONTENT_LENGTH, contract.wasm.len()) + .body(if HEAD { + http_body_util::Full::default() + } else { + http_body_util::Full::new(contract.wasm.clone()) + }) } else { res.header(CONTENT_TYPE, APPLICATION_COSE.to_string()) .header(CONTENT_LENGTH, contract.envelope.len()) + .body(if HEAD { + http_body_util::Full::default() + } else { + http_body_util::Full::new(contract.envelope.clone()) + }) } - .body(http_body_util::Full::default()) .map_err(ContractGetError::Http) } @@ -228,11 +213,12 @@ impl Ledger { let [account] = signers[..] else { return Err(PublishPostError::SignerCount(signers.len())); }; - wasmparser::Validator::new_with_features( + let types = wasmparser::Validator::new_with_features( WasmFeatures::default() | WasmFeatures::CM_IMPLEMENTS, ) .validate_all(&wasm) .map_err(PublishPostError::Wasm)?; + let utxo_wits = wit::utxo_wits(&wasm, &types).map_err(PublishPostError::Wit)?; let digest: [u8; 32] = Sha256::digest(&wasm).into(); let mut contracts = self.contracts.write().await; @@ -265,6 +251,7 @@ impl Ledger { entry.insert(Arc::new(Contract { wasm: wasm.into(), envelope: envelope.clone(), + utxo_wits, })); { let mut blocks = self.blocks.write().await; @@ -275,7 +262,7 @@ impl Ledger { let digest = encode_digest(&digest); http::Response::builder() .status(http::StatusCode::CREATED) - .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .header(CONTENT_TYPE, TEXT_PLAIN_UTF_8.to_string()) .header(X_CONTENT_TYPE_OPTIONS, "nosniff") .header(LOCATION, format!("/contracts/{digest}")) .body(http_body_util::Full::new(digest.into())) @@ -324,7 +311,7 @@ impl Ledger { build_text_response(http::StatusCode::OK, "").map_err(FundPostError::Http) } - async fn handle_transaction_get( + async fn handle_transaction_get( &self, headers: http::HeaderMap, digest: &str, @@ -345,35 +332,13 @@ impl Ledger { http::Response::builder() .header(VARY, ACCEPT.as_str()) .header(CONTENT_TYPE, APPLICATION_COSE.to_string()) - .header(X_CONTENT_TYPE_OPTIONS, "nosniff") - .body(http_body_util::Full::new(tx.envelope.clone())) - .map_err(TransactionGetError::Http) - } - - async fn handle_transaction_head( - &self, - headers: http::HeaderMap, - digest: &str, - ) -> Result>, TransactionGetError> { - const AVAILABLE_TYPES: &[MediaType] = &[APPLICATION_COSE]; - - let digest = parse_digest(digest).map_err(TransactionGetError::DigestParsing)?; - - if let Some(Err(err)) = negotiate_accept(&headers, AVAILABLE_TYPES) { - return Err(TransactionGetError::AcceptHeader(err)); - } - - let txs = self.transactions.read().await; - let tx = txs - .get(&digest) - .ok_or(TransactionGetError::TransactionNotFound)?; - - http::Response::builder() - .header(VARY, ACCEPT.as_str()) - .header(CONTENT_TYPE, APPLICATION_COSE.to_string()) - .header(X_CONTENT_TYPE_OPTIONS, "nosniff") .header(CONTENT_LENGTH, tx.envelope.len()) - .body(http_body_util::Full::default()) + .header(X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(if HEAD { + http_body_util::Full::default() + } else { + http_body_util::Full::new(tx.envelope.clone()) + }) .map_err(TransactionGetError::Http) } @@ -467,7 +432,7 @@ impl Ledger { let digest = encode_digest(&digest); http::Response::builder() .status(http::StatusCode::CREATED) - .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .header(CONTENT_TYPE, TEXT_PLAIN_UTF_8.to_string()) .header(X_CONTENT_TYPE_OPTIONS, "nosniff") .header(LOCATION, format!("/transactions/{digest}")) .body(http_body_util::Full::new(digest.into())) @@ -519,6 +484,99 @@ impl Ledger { } } + async fn get_utxo( + &self, + transaction: Option<[u8; 32]>, + index: usize, + ) -> Option> { + let utxo = if let Some(transaction) = transaction { + let txs = self.transactions.read().await; + let Transaction { outputs, .. } = txs.get(&transaction)?; + outputs.get(index).cloned() + } else { + let outputs = self.genesis.outputs.read().await; + outputs.get(index).cloned() + }; + utxo.flatten() + } + + async fn handle_rpc_get( + &self, + headers: http::HeaderMap, + query: Option<&str>, + ) -> Result>, RpcGetError> { + const AVAILABLE_TYPES: &[MediaType] = &[TEXT_PLAIN_UTF_8, APPLICATION_WASM]; + + let accept = negotiate_accept(&headers, AVAILABLE_TYPES) + .transpose() + .map_err(RpcGetError::AcceptHeader)?; + + let mut transaction = None; + let mut index = None; + for (name, value) in form_urlencoded::parse(query.unwrap_or_default().as_bytes()) { + match &*name { + "transaction" if transaction.is_some() => { + return Err(RpcGetError::DuplicateQueryParameter(name.into())); + } + "transaction" => { + let digest = + parse_digest(&value).map_err(RpcGetError::TransactionDigestParsing)?; + transaction = Some(digest); + } + "index" if index.is_some() => { + return Err(RpcGetError::DuplicateQueryParameter(name.into())); + } + "index" => { + let i: u32 = value.parse().map_err(RpcGetError::UtxoIndexParsing)?; + let i = usize::try_from(i).map_err(|_| RpcGetError::UtxoIndexOverflow)?; + index = Some(i); + } + name => return Err(RpcGetError::UnknownQueryParameter(name.into())), + } + } + let wasm = accept == Some(&APPLICATION_WASM); + let (content_type, body) = match (transaction, index) { + (None, None) if wasm => (APPLICATION_WASM, Bytes::from_static(wit::LEDGER_WASM)), + (None, None) => ( + TEXT_PLAIN_UTF_8, + Bytes::from_static(wit::LEDGER_WIT.as_bytes()), + ), + (Some(..), None) => return Err(RpcGetError::UtxoIndexMissing), + (transaction, Some(index)) => { + let utxo = self + .get_utxo(transaction, index) + .await + .ok_or(RpcGetError::UtxoNotFound)?; + let contract = + parse_digest(&utxo.contract).map_err(RpcGetError::ContractDigestParsing)?; + let contracts = self.contracts.read().await; + let contract = contracts + .get(&contract) + .ok_or(RpcGetError::ContractNotFound)?; + let Some((text, utxo_wasm)) = contract.utxo_wits.get(&utxo.instance) else { + return Err(RpcGetError::UtxoInstanceNotFound(utxo.instance.clone())); + }; + if wasm { + (APPLICATION_WASM, utxo_wasm.clone()) + } else { + (TEXT_PLAIN_UTF_8, text.clone()) + } + } + }; + + http::Response::builder() + .header(VARY, ACCEPT.as_str()) + .header(CONTENT_TYPE, content_type.to_string()) + .header(CONTENT_LENGTH, body.len()) + .header(X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(if HEAD { + http_body_util::Full::default() + } else { + http_body_util::Full::new(body) + }) + .map_err(RpcGetError::Http) + } + async fn handle_rpc_post( &self, headers: http::HeaderMap, @@ -648,17 +706,10 @@ impl Ledger { .map_err(RpcPostError::ParameterDecoding)?; let index = usize::try_from(index).map_err(|_| RpcPostError::UtxoIndexOverflow)?; - let utxo = if let Some(transaction) = tx { - let txs = self.transactions.read().await; - let Transaction { outputs, .. } = - txs.get(&transaction).ok_or(RpcPostError::UtxoNotFound)?; - outputs.get(index).cloned() - } else { - let outputs = self.genesis.outputs.read().await; - outputs.get(index).cloned() - }; - let utxo = utxo.ok_or(RpcPostError::UtxoNotFound)?; - let utxo = utxo.as_deref().ok_or(RpcPostError::UtxoNotFound)?; + let utxo = self + .get_utxo(tx, index) + .await + .ok_or(RpcPostError::UtxoNotFound)?; let contract = parse_digest(&utxo.contract).map_err(RpcPostError::ContractDigestParsing)?; let wasm = { @@ -856,14 +907,14 @@ impl Ledger { path.next(), ) { ("GET", Some("contracts"), Some(digest), None, ..) => match ledger - .handle_contract_get(headers, digest) + .handle_contract_get::(headers, digest) .await { Ok(res) => Ok(res), Err(err) => build_text_response(err.http_status_code(), err.to_string()), }, ("HEAD", Some("contracts"), Some(digest), None, ..) => match ledger - .handle_contract_head(headers, digest) + .handle_contract_get::(headers, digest) .await { Ok(res) => Ok(res), @@ -874,14 +925,14 @@ impl Ledger { } ("GET", Some("transactions"), Some(digest), None, ..) => match ledger - .handle_transaction_get(headers, digest) + .handle_transaction_get::(headers, digest) .await { Ok(res) => Ok(res), Err(err) => build_text_response(err.http_status_code(), err.to_string()), }, ("HEAD", Some("transactions"), Some(digest), None, ..) => match ledger - .handle_transaction_head(headers, digest) + .handle_transaction_get::(headers, digest) .await { Ok(res) => Ok(res), @@ -899,8 +950,22 @@ impl Ledger { } } } + ("GET", Some("rpc"), None, ..) => match ledger + .handle_rpc_get::(headers, pq.query()) + .await + { + Ok(res) => Ok(res), + Err(err) => build_text_response(err.http_status_code(), err.to_string()), + }, + ("HEAD", Some("rpc"), None, ..) => match ledger + .handle_rpc_get::(headers, pq.query()) + .await + { + Ok(res) => Ok(res), + Err(err) => build_text_response(err.http_status_code(), err.to_string()), + }, (_, Some("rpc"), None, ..) => { - build_method_not_allowed("POST", &method, pq.path()) + build_method_not_allowed("GET, HEAD, POST", &method, pq.path()) } _ => build_text_response( diff --git a/starstream-ledger/src/server/mod.rs b/starstream-ledger/src/server/mod.rs index 59acfdce..eb8c414c 100644 --- a/starstream-ledger/src/server/mod.rs +++ b/starstream-ledger/src/server/mod.rs @@ -20,6 +20,7 @@ use crate::{Block, CardanoCtx, TransactionOutput, parse_digest}; mod http; mod runtime; +mod wit; /// Configure `engine_config` for the engine of a [Ledger]. pub fn configure_wasmtime(engine_config: &mut wasmtime::Config) -> &mut wasmtime::Config { @@ -62,6 +63,7 @@ struct UtxoCtx { struct Contract { wasm: Bytes, envelope: Bytes, + utxo_wits: HashMap, (Bytes, Bytes)>, } #[derive(Clone, Debug)] diff --git a/starstream-ledger/src/server/wit.rs b/starstream-ledger/src/server/wit.rs new file mode 100644 index 00000000..bd0d1dcc --- /dev/null +++ b/starstream-ledger/src/server/wit.rs @@ -0,0 +1,246 @@ +use std::collections::HashMap; + +use anyhow::{Context as _, bail, ensure}; +use bytes::Bytes; +use wasmparser::component_types::{ + ComponentAnyTypeId, ComponentDefinedType, ComponentEntityType, ComponentValType, +}; +use wasmparser::types::Types; +use wasmparser::{ComponentExternalKind, Encoding, Parser, Payload, PrimitiveValType}; +use wit_parser::{ + Docs, Function, FunctionKind, IndexMap, Interface, Package, PackageName, Param, Resolve, Span, + Stability, Type, TypeDef, TypeDefKind, TypeOwner, +}; + +pub const LEDGER_WIT: &str = include_str!("../../wit/ledger.wit"); +pub const LEDGER_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ledger.wasm")); + +/// Build the WIT of every UTXO instance exported by the component `wasm`, validated into `types`, +/// keyed by instance name, as text and encoded as Wasm. +/// +/// Each is the `starstream:ledger` package containing the `utxo` interface, which exposes the +/// methods of the UTXO exported by the instance the way `/rpc` invokes them: as freestanding +/// functions taking the output reference followed by the method parameters, which are prefixed +/// with `arg-` so they never clash with the output reference parameters. Only the shapes the +/// compiler produces are handled: instances exporting a `utxo` resource, with synchronous methods +/// taking and returning booleans, integers and options, results and tuples of them. Methods +/// taking or returning anything else are rejected. +pub fn utxo_wits(wasm: &[u8], types: &Types) -> anyhow::Result, (Bytes, Bytes)>> { + let mut instances = Vec::new(); + let mut depth = 0usize; + for payload in Parser::new(0).parse_all(wasm) { + let payload = payload?; + match payload { + Payload::Version { + encoding: Encoding::Module, + .. + } if depth == 0 => bail!("Wasm is not a component"), + Payload::ModuleSection { .. } | Payload::ComponentSection { .. } => depth += 1, + Payload::End(..) => depth = depth.saturating_sub(1), + Payload::ComponentExportSection(exports) if depth == 0 => { + for export in exports { + let export = export?; + if export.kind != ComponentExternalKind::Instance { + continue; + } + let item = types + .component_item_for_export(export.name.name) + .with_context(|| format!("export `{}` not found", export.name.name))?; + let ComponentEntityType::Instance(id) = item.ty else { + bail!("export `{}` is not an instance", export.name.name) + }; + let exports = &types[id].exports; + if let Some(utxo) = exports.get("utxo") + && let ComponentEntityType::Type { + created: ComponentAnyTypeId::Resource(..), + .. + } = utxo.ty + { + instances.push((export.name.name, exports)); + } + } + } + _ => {} + } + } + + let mut resolve = Resolve::default(); + let transaction = resolve.types.alloc(TypeDef { + name: None, + kind: TypeDefKind::Option(Type::String), + owner: TypeOwner::None, + docs: Docs::default(), + stability: Stability::Unknown, + span: Span::default(), + external_id: None, + }); + let pkg = resolve.packages.alloc(Package { + name: PackageName { + namespace: "starstream".into(), + name: "ledger".into(), + version: None, + }, + docs: Docs::default(), + interfaces: Default::default(), + worlds: Default::default(), + }); + let utxo = resolve.interfaces.alloc(Interface { + name: Some("utxo".into()), + types: Default::default(), + functions: Default::default(), + docs: Docs::default(), + stability: Stability::Unknown, + package: Some(pkg), + span: Span::default(), + clone_of: None, + }); + resolve.packages[pkg].interfaces.insert("utxo".into(), utxo); + + let mut wits = HashMap::new(); + for (instance, exports) in instances { + let mut functions = IndexMap::default(); + for (name, item) in exports { + let Some(name) = name.strip_prefix("[method]utxo.") else { + continue; + }; + let ComponentEntityType::Func(id) = item.ty else { + bail!( + "export `[method]utxo.{name}` of UTXO instance `{instance}` is not a function" + ) + }; + let func = &types[id]; + ensure!( + !func.async_, + "method `{name}` of UTXO instance `{instance}` is asynchronous", + ); + let mut params = Vec::with_capacity(func.params.len() + 1); + params.extend([ + Param { + name: "transaction".into(), + ty: Type::Id(transaction), + span: Span::default(), + }, + Param { + name: "index".into(), + ty: Type::U32, + span: Span::default(), + }, + ]); + for (param, ty) in func.params.iter().skip(1) { + let ty = convert(&mut resolve, types, ty).with_context(|| { + format!( + "parameter `{param}` of method `{name}` of UTXO instance `{instance}` has an unsupported type" + ) + })?; + params.push(Param { + name: format!("arg-{param}"), + ty, + span: Span::default(), + }); + } + let result = match &func.result { + Some(ty) => { + let ty = convert(&mut resolve, types, ty).with_context(|| { + format!( + "method `{name}` of UTXO instance `{instance}` has an unsupported result type" + ) + })?; + Some(ty) + } + None => None, + }; + functions.insert( + name.to_string(), + Function { + name: name.to_string(), + kind: FunctionKind::Freestanding, + params, + result, + docs: Docs::default(), + stability: Stability::Unknown, + span: Span::default(), + external_id: None, + }, + ); + } + resolve.interfaces[utxo].functions = functions; + + let mut printer = wit_component::WitPrinter::default(); + () = printer + .print(&resolve, pkg, &[]) + .with_context(|| format!("failed to print WIT of instance `{instance}`"))?; + let wasm = wit_component::encode(&resolve, pkg) + .with_context(|| format!("failed to encode WIT of instance `{instance}`"))?; + wits.insert( + instance.into(), + (printer.output.to_string().into(), wasm.into()), + ); + } + Ok(wits) +} + +/// Convert `ty`, if the compiler passes values of its type to or from UTXO methods, all of which +/// `/rpc` can pass, into an anonymous type of `resolve`. +fn convert(resolve: &mut Resolve, types: &Types, ty: &ComponentValType) -> Option { + let id = match *ty { + ComponentValType::Primitive(ty) => return convert_primitive(ty), + ComponentValType::Type(id) => id, + }; + let kind = match &types[id] { + ComponentDefinedType::Primitive(ty) => return convert_primitive(*ty), + ComponentDefinedType::Option { ty, .. } => { + let ty = convert(resolve, types, ty)?; + TypeDefKind::Option(ty) + } + ComponentDefinedType::Result { ok, err, .. } => { + let ok = match ok { + Some(ty) => { + let ty = convert(resolve, types, ty)?; + Some(ty) + } + None => None, + }; + let err = match err { + Some(ty) => { + let ty = convert(resolve, types, ty)?; + Some(ty) + } + None => None, + }; + TypeDefKind::Result(wit_parser::Result_ { ok, err }) + } + ComponentDefinedType::Tuple(tuple) => { + let items = tuple + .types + .iter() + .map(|ty| convert(resolve, types, ty)) + .collect::>()?; + TypeDefKind::Tuple(wit_parser::Tuple { types: items }) + } + _ => return None, + }; + Some(Type::Id(resolve.types.alloc(TypeDef { + name: None, + kind, + owner: TypeOwner::None, + docs: Docs::default(), + stability: Stability::Unknown, + span: Span::default(), + external_id: None, + }))) +} + +fn convert_primitive(ty: PrimitiveValType) -> Option { + match ty { + PrimitiveValType::Bool => Some(Type::Bool), + PrimitiveValType::U8 => Some(Type::U8), + PrimitiveValType::U16 => Some(Type::U16), + PrimitiveValType::U32 => Some(Type::U32), + PrimitiveValType::U64 => Some(Type::U64), + PrimitiveValType::S8 => Some(Type::S8), + PrimitiveValType::S16 => Some(Type::S16), + PrimitiveValType::S32 => Some(Type::S32), + PrimitiveValType::S64 => Some(Type::S64), + _ => None, + } +} diff --git a/starstream-ledger/tests/ledger.rs b/starstream-ledger/tests/ledger.rs index 15503859..d6edb42f 100644 --- a/starstream-ledger/tests/ledger.rs +++ b/starstream-ledger/tests/ledger.rs @@ -414,16 +414,7 @@ async fn http() { .transact( ADMIN.clone(), Transaction { - inputs: vec![ - TransactionInput { - transaction: Box::default(), - index: 0, - }, - TransactionInput { - transaction: Box::default(), - index: 0, - }, - ], + inputs: vec![TransactionInput::genesis(0), TransactionInput::genesis(0)], outputs: outputs.clone(), events: events.clone(), proof: proof.clone(), @@ -434,10 +425,7 @@ async fn http() { assert_eq!(err.to_string(), "duplicate input"); let tx = Transaction { - inputs: vec![TransactionInput { - transaction: Box::default(), - index: 0, - }], + inputs: vec![TransactionInput::genesis(0)], outputs: outputs.clone(), events, proof, @@ -500,15 +488,20 @@ async fn http() { .expect_err("spent inputs must be rejected"); assert_eq!(err.to_string(), "input not found"); + let req = http::Request::builder() + .uri(format!("http://{addr}/rpc")) + .body(Full::default()) + .unwrap(); + let (parts, body) = http_request(&http, req).await.unwrap(); + assert_eq!(parts.status, StatusCode::OK); + assert_eq!( + parts.headers.get(CONTENT_TYPE).unwrap(), + "text/plain; charset=UTF-8" + ); + assert_eq!(body, include_str!("../wit/ledger.wit")); + let mut rx = client - .call_utxo_method( - &TransactionInput { - transaction: Box::default(), - index: 1, - }, - "get-chips", - &[], - ) + .call_utxo_method(&TransactionInput::genesis(1), "get-chips", &[]) .await .expect("genesis output must remain callable"); let mut buf = Vec::default(); @@ -516,14 +509,7 @@ async fn http() { assert_eq!(buf, [42]); let mut rx = client - .call_utxo_method( - &TransactionInput { - transaction: encode_digest(&tx_digest).into(), - index: 0, - }, - "get-chips", - &[], - ) + .call_utxo_method(&TransactionInput::new(tx_digest, 0), "get-chips", &[]) .await .expect("transaction output must be callable"); let mut buf = Vec::default(); @@ -531,10 +517,7 @@ async fn http() { assert_eq!(buf, [42]); let tx = Transaction { - inputs: vec![TransactionInput { - transaction: Box::default(), - index: 1, - }], + inputs: vec![TransactionInput::genesis(1)], outputs: outputs.clone(), events: Vec::default(), proof: Box::default(), @@ -568,6 +551,47 @@ impl Client for NoopClient { } } +/// Serve a ledger with `genesis` outputs and the admin account funded. +async fn serve( + genesis: impl Into>, +) -> ( + starstream_ledger::client::http::Client, + Arc, + tokio::task::JoinHandle<()>, +) { + let mut engine_config = wasmtime::Config::default(); + starstream_ledger::server::configure_wasmtime(&mut engine_config); + let engine = wasmtime::Engine::new(&engine_config).unwrap(); + let ledger = Ledger::new( + engine, + 128, + 1_000_000, + NETWORK, + ADMIN.verifying_key(), + genesis, + CardanoCtx::default(), + ); + let ledger = Arc::new(ledger); + let addr = free_tcp_addr().await.unwrap(); + let (ledger, shutdown) = ledger + .handle_http(addr, Duration::from_secs(30)) + .await + .unwrap(); + let ledger = tokio::spawn(ledger); + + 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(); + client + .fund(ADMIN.clone(), 1, &ADMIN.verifying_key(), 1 << 20) + .await + .unwrap(); + + (client, shutdown, ledger) +} + #[tokio::test] async fn rpc_trap() { let wasm = common::runtime::compile_contract( @@ -630,45 +654,13 @@ script fn create() { ) .await .unwrap(); - - let mut engine_config = wasmtime::Config::default(); - starstream_ledger::server::configure_wasmtime(&mut engine_config); - let engine = wasmtime::Engine::new(&engine_config).unwrap(); - let ledger = Ledger::new( - engine, - 128, - 1_000_000, - NETWORK, - ADMIN.verifying_key(), - tx.outputs, - CardanoCtx::default(), - ); - let ledger = Arc::new(ledger); - let addr = free_tcp_addr().await.unwrap(); - let (ledger, shutdown) = ledger - .handle_http(addr, Duration::from_secs(30)) - .await - .unwrap(); - let ledger = tokio::spawn(ledger); - - 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(); - client - .fund(ADMIN.clone(), 1, &ADMIN.verifying_key(), 1 << 20) - .await - .unwrap(); + let (client, shutdown, ledger) = serve(tx.outputs).await; client .publish_contract(ADMIN.clone(), 2, wasm) .await .unwrap(); - let input = TransactionInput { - transaction: Box::default(), - index: 0, - }; + let input = TransactionInput::genesis(0); let mut rx = client.call_utxo_method(&input, "spin", &[]).await.unwrap(); let mut buf = Vec::default(); let err = rx @@ -782,3 +774,117 @@ async fn stalled_connections_are_closed() { shutdown.notify_one(); ledger.await.expect("ledger task panicked") } + +#[tokio::test] +async fn rpc_get_utxo_wit() { + let wasm = common::runtime::compile_contract( + r#" +utxo Counter { + storage { + let mut count: i64; + } + + main fn new() { + yield(); + } + + pub fn get() -> i64 { + count + } + + pub fn set(index: Option, transaction: Result<(i64, bool), u8>) { } +} +"#, + ) + .unwrap(); + let digest: [u8; 32] = Sha256::digest(&wasm).into(); + let output = TransactionOutput { + contract: encode_digest(&digest).into(), + instance: "counter".into(), + methods: Default::default(), + storage: Box::default(), + state: Vec::default(), + }; + let (client, shutdown, ledger) = serve([output]).await; + client + .publish_contract(ADMIN.clone(), 2, wasm) + .await + .unwrap(); + + let wit = client + .get_utxo_wit(&TransactionInput::genesis(0), None) + .await + .unwrap(); + shutdown.notify_one(); + ledger.await.expect("ledger task panicked"); + + assert_eq!( + str::from_utf8(&wit).unwrap(), + r#"package starstream:ledger; + +interface utxo { + get: func(transaction: option, index: u32) -> s64; + + set: func(transaction: option, index: u32, arg-index: option, arg-transaction: result, u8>); +} + +"# + ); +} + +#[tokio::test] +async fn publish_unsupported_utxo_method() { + let wasm = common::runtime::compile_contract( + r#" +utxo Counter { + storage { + let mut count: i64; + } + + main fn new() { + yield(); + } + + pub fn link(other: Utxo) { } +} +"#, + ) + .unwrap(); + let (client, shutdown, ledger) = serve([]).await; + + let err = client + .publish_contract(ADMIN.clone(), 2, wasm) + .await + .expect_err("publishing a UTXO method taking a UTXO must fail"); + shutdown.notify_one(); + ledger.await.expect("ledger task panicked"); + + assert_eq!( + err.to_string(), + "failed to build contract WIT: parameter `other` of method `link` of UTXO instance `counter` has an unsupported type" + ); +} + +#[tokio::test] +async fn publish_package_cycle() { + let wasm = wat::parse_str( + r#" +(component + (import "a:x/l" (instance $l (export "t" (type (sub resource))))) + (alias export $l "t" (type $t)) + (import "b:y/k" (instance $k (export "t" (type (eq $t))))) + (alias export $k "t" (type $kt)) + (import "a:x/i" (instance (export "u" (type (eq $kt))))) +) +"#, + ) + .unwrap(); + let (client, shutdown, ledger) = serve([]).await; + + client + .publish_contract(ADMIN.clone(), 2, wasm) + .await + .unwrap(); + shutdown.notify_one(); + ledger.await.expect("ledger task panicked"); +}