Skip to content

Commit

Permalink
feat(ts-bindings): Client.deploy with constructor args
Browse files Browse the repository at this point in the history
- allow `stellar contract bindings typescript` to accept only a `--wasm`
  file, rather than requiring `--contract-id`
- add types for `Client.deploy` to the generated TS
  • Loading branch information
chadoh committed Nov 20, 2024
1 parent 11cde0c commit b549bb3
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 48 deletions.
47 changes: 32 additions & 15 deletions cmd/crates/soroban-spec-typescript/src/boilerplate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ impl Project {
pub fn init(
&self,
contract_name: &str,
contract_id: &str,
rpc_url: &str,
network_passphrase: &str,
contract_id: Option<&str>,
rpc_url: Option<&str>,
network_passphrase: Option<&str>,
spec: &[ScSpecEntry],
) -> std::io::Result<()> {
self.replace_placeholder_patterns(contract_name, contract_id, rpc_url, network_passphrase)?;
Expand All @@ -59,9 +59,9 @@ impl Project {
fn replace_placeholder_patterns(
&self,
contract_name: &str,
contract_id: &str,
rpc_url: &str,
network_passphrase: &str,
contract_id: Option<&str>,
rpc_url: Option<&str>,
network_passphrase: Option<&str>,
) -> std::io::Result<()> {
let replacement_strings = &[
("INSERT_CONTRACT_NAME_HERE", contract_name),
Expand All @@ -73,9 +73,18 @@ impl Project {
"INSERT_CAMEL_CASE_CONTRACT_NAME_HERE",
&contract_name.to_lower_camel_case(),
),
("INSERT_CONTRACT_ID_HERE", contract_id),
("INSERT_NETWORK_PASSPHRASE_HERE", network_passphrase),
("INSERT_RPC_URL_HERE", rpc_url),
(
"INSERT_CONTRACT_ID_HERE",
contract_id.unwrap_or("INSERT_CONTRACT_ID_HERE"),
),
(
"INSERT_RPC_URL_HERE",
rpc_url.unwrap_or("INSERT_RPC_URL_HERE"),
),
(
"INSERT_NETWORK_PASSPHRASE_HERE",
network_passphrase.unwrap_or("INSERT_NETWORK_PASSPHRASE_HERE"),
),
];
let root: &Path = self.as_ref();
["package.json", "README.md", "src/index.ts"]
Expand All @@ -93,8 +102,8 @@ impl Project {
fn append_index_ts(
&self,
spec: &[ScSpecEntry],
contract_id: &str,
network_passphrase: &str,
contract_id: Option<&str>,
network_passphrase: Option<&str>,
) -> std::io::Result<()> {
let networks = Project::format_networks_object(contract_id, network_passphrase);
let types_and_fns = generate(spec);
Expand All @@ -104,7 +113,15 @@ impl Project {
.write_all(format!("\n\n{networks}\n\n{types_and_fns}").as_bytes())
}

fn format_networks_object(contract_id: &str, network_passphrase: &str) -> String {
fn format_networks_object(
contract_id: Option<&str>,
network_passphrase: Option<&str>,
) -> String {
if contract_id.is_none() || network_passphrase.is_none() {
return String::new();
}
let contract_id = contract_id.unwrap();
let network_passphrase = network_passphrase.unwrap();
let network = match network_passphrase {
NETWORK_PASSPHRASE_TESTNET => "testnet",
NETWORK_PASSPHRASE_FUTURENET => "futurenet",
Expand Down Expand Up @@ -138,9 +155,9 @@ mod test {
let p: Project = root.as_ref().to_path_buf().try_into()?;
p.init(
"test_custom_types",
"CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE",
"https://rpc-futurenet.stellar.org:443",
"Test SDF Future Network ; October 2022",
Some("CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"),
Some("https://rpc-futurenet.stellar.org:443"),
Some("Test SDF Future Network ; October 2022"),
&spec,
)
.unwrap();
Expand Down
36 changes: 34 additions & 2 deletions cmd/crates/soroban-spec-typescript/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@ pub fn generate_from_wasm(wasm: &[u8]) -> Result<String, FromWasmError> {
Ok(json)
}

fn generate_class(fns: &[Entry], spec: &[ScSpecEntry]) -> String {
fn generate_class(
fns: &[Entry],
constructor_args: Option<Vec<types::FunctionInput>>,
spec: &[ScSpecEntry],
) -> String {
let constructor_args = if let Some(inputs) = constructor_args {
format!("{{{}}}", inputs.iter().map(func_input_to_ts).join(", "))
} else {
"null".to_string()
};
let method_types = fns.iter().map(entry_to_method_type).join("");
let from_jsons = fns
.iter()
Expand All @@ -74,6 +83,22 @@ fn generate_class(fns: &[Entry], spec: &[ScSpecEntry]) -> String {
r#"export interface Client {{{method_types}
}}
export class Client extends ContractClient {{
static async deploy<T = Client>(
/** Constructor/Initialization Args for the contract's `__constructor` method */
args: {constructor_args},
/** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */
options: MethodOptions &
Omit<ContractClientOptions, "contractId"> & {{
/** The hash of the Wasm blob, which must already be installed on-chain. */
wasmHash: Buffer | string;
/** Salt used to generate the contract's ID. Passed through to {{@link Operation.createCustomContract}}. Default: random. */
salt?: Buffer | Uint8Array;
/** The format used to decode `wasmHash`, if it's provided as a string. */
format?: "hex" | "base64";
}}
): Promise<AssembledTransaction<T>> {{
return ContractClient.deploy(args, options)
}}
constructor(public readonly options: ContractClientOptions) {{
super(
new ContractSpec([ {spec} ]),
Expand All @@ -96,13 +121,20 @@ pub fn generate(spec: &[ScSpecEntry]) -> String {
cases: vec![],
});
}
let mut constructor_args: Option<Vec<types::FunctionInput>> = None;
// Filter out function entries with names that start with "__" and partition the results
collected.iter().for_each(|entry| match entry {
Entry::Function { name, inputs, .. } if name == "__constructor" => {
constructor_args = Some(inputs.clone());
}
_ => {}
});
let (fns, other): (Vec<_>, Vec<_>) = collected
.into_iter()
.filter(|entry| !matches!(entry, Entry::Function { name, .. } if name.starts_with("__")))
.partition(|entry| matches!(entry, Entry::Function { .. }));
let top = other.iter().map(entry_to_method_type).join("\n");
let bottom = generate_class(&fns, spec);
let bottom = generate_class(&fns, constructor_args, spec);
format!("{top}\n\n{bottom}")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AssembledTransaction,
Client as ContractClient,
ClientOptions as ContractClientOptions,
MethodOptions,
Result,
Spec as ContractSpec,
} from '@stellar/stellar-sdk/contract';
Expand Down
15 changes: 9 additions & 6 deletions cmd/crates/soroban-spec-typescript/ts-tests/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,21 @@ function fund_all() {
exe eval "./soroban keys generate root"
exe eval "./soroban keys fund root"
}
function deploy() {
exe eval "(./soroban contract deploy --quiet --source root --wasm $1 --ignore-checks) > $2"
function upload() {
exe eval "(./soroban contract $1 --quiet --source root --wasm $2 --ignore-checks) > $3"
}
function deploy_all() {
deploy ../../../../target/wasm32-unknown-unknown/test-wasms/test_custom_types.wasm contract-id-custom-types.txt
upload deploy ../../../../target/wasm32-unknown-unknown/test-wasms/test_custom_types.wasm contract-id-custom-types.txt
# TODO: support `--wasm-hash` with `contract bindings`
upload install ../../../../target/wasm32-unknown-unknown/test-wasms/test_constructor.wasm contract-wasm-hash-constructor.txt
}
function bind() {
exe eval "./soroban contract bindings typescript --contract-id $(cat $1) --output-dir ./node_modules/$2 --overwrite"
exe eval "sh -c \"cd ./node_modules/$2 && npm install && npm run build\""
exe eval "./soroban contract bindings typescript $1 $2 --output-dir ./node_modules/$3 --overwrite"
exe eval "sh -c \"cd ./node_modules/$3 && npm install && npm run build\""
}
function bind_all() {
bind contract-id-custom-types.txt test-custom-types
bind --contract-id $(cat contract-id-custom-types.txt) test-custom-types
bind --wasm ../../../../target/wasm32-unknown-unknown/test-wasms/test_constructor.wasm test-constructor
}

fund_all
Expand Down
6 changes: 3 additions & 3 deletions cmd/crates/soroban-spec-typescript/ts-tests/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import test from "ava"
import { networkPassphrase, rpcUrl, root, signer } from "./util.js"
import { Client } from "test-constructor"

const wasmHash = readFileSync(
join(import.meta.dirname, "..", "contract-wasm-hash-constructor.txt"),
{ encoding: "utf8" }
);

const INIT_VALUE = 42;

test("has correctly-typed result", async (t) => {
const deploy = await Client.deploy(
{ counter: INIT_VALUE },
{
networkPassphrase,
rpcUrl,
allowHttp: true,
wasmHash,
publicKey: root.keypair.publicKey(),
...signer,
},
);
const { result: client } = await deploy.signAndSend();
const { result } = await client.counter();
t.is(result, INIT_VALUE);
});
58 changes: 36 additions & 22 deletions cmd/soroban-cli/src/commands/contract/bindings/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub struct Cmd {
pub overwrite: bool,
/// The contract ID/address on the network
#[arg(long, visible_alias = "id")]
pub contract_id: String,
pub contract_id: Option<String>,
#[command(flatten)]
pub locator: locator::Args,
#[command(flatten)]
Expand All @@ -51,6 +51,9 @@ pub enum Error {
#[error("--output-dir filepath not representable as utf-8: {0:?}")]
NotUtf8(OsString),

#[error("must include either --wasm or --contract-id")]
MissingWasmOrContractId,

#[error(transparent)]
Network(#[from] network::Error),

Expand Down Expand Up @@ -90,28 +93,38 @@ impl NetworkRunnable for Cmd {
.expect("no network specified and testnet network not found")
.into()
});
print.infoln(format!("Network: {}", network.network_passphrase));

let contract_id = self
.locator
.resolve_contract_id(&self.contract_id, &network.network_passphrase)?
.0;
let contract_address = ScAddress::Contract(Hash(contract_id));

let spec = if let Some(wasm) = &self.wasm {
let (spec, contract_address) = if let Some(wasm) = &self.wasm {
print.infoln("Loading contract spec from file...");
let wasm: wasm::Args = wasm.into();
wasm.parse()?.spec
(wasm.parse()?.spec, None)
} else {
let contract_id = self
.contract_id
.as_ref()
.ok_or(Error::MissingWasmOrContractId)?;

let contract_id = self
.locator
.resolve_contract_id(contract_id, &network.network_passphrase)?
.0;

let contract_address = ScAddress::Contract(Hash(contract_id)).to_string();
print.globeln(format!("Downloading contract spec: {contract_address}"));
get_remote_contract_spec(
&contract_id,
&self.locator,
&self.network,
global_args,
config,

(
get_remote_contract_spec(
&contract_id,
&self.locator,
&self.network,
global_args,
config,
)
.await
.map_err(Error::from)?,
Some(contract_address),
)
.await
.map_err(Error::from)?
};
if self.output_dir.is_file() {
return Err(Error::IsFile(self.output_dir.clone()));
Expand All @@ -125,20 +138,21 @@ impl NetworkRunnable for Cmd {
}
std::fs::create_dir_all(&self.output_dir)?;
let p: Project = self.output_dir.clone().try_into()?;
print.infoln(format!("Network: {}", network.network_passphrase));
let absolute_path = self.output_dir.canonicalize()?;
let file_name = absolute_path
.file_name()
.ok_or_else(|| Error::FailedToGetFileName(absolute_path.clone()))?;
let contract_name = &file_name
.to_str()
.ok_or_else(|| Error::NotUtf8(file_name.to_os_string()))?;
print.infoln(format!("Embedding contract address: {contract_address}"));
if let Some(contract_address) = contract_address.clone() {
print.infoln(format!("Embedding contract address: {contract_address}"));
}
p.init(
contract_name,
&contract_address.to_string(),
&network.rpc_url,
&network.network_passphrase,
contract_address.as_deref(),
Some(&network.rpc_url),
Some(&network.network_passphrase),
&spec,
)?;
print.checkln("Generated!");
Expand Down

0 comments on commit b549bb3

Please sign in to comment.