Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ target/
node_modules/
.direnv
wasm/pkg/
wasm/.generated/
.claude/settings.local.json
.claude/worktrees/
.DS_Store
10 changes: 10 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ build-wasm:
# Copy JS error class used by Rust wasm-bindgen imports and package exports
cp wasm/js/bullet-sdk-error.js wasm/pkg/bullet-sdk-error.js
cp wasm/js/bullet-sdk-error.d.ts wasm/pkg/bullet-sdk-error.d.ts
cp wasm/.generated/startup-shared.js wasm/pkg/startup-shared.js
cp wasm/.generated/startup-shared.d.ts wasm/pkg/startup-shared.d.ts
cp wasm/.generated/calls.js wasm/pkg/calls.js
cp wasm/.generated/calls.d.ts wasm/pkg/calls.d.ts
cp wasm/.generated/topics.js wasm/pkg/topics.js
cp wasm/.generated/topics.d.ts wasm/pkg/topics.d.ts
cp wasm/.generated/errors.js wasm/pkg/errors.js
cp wasm/.generated/errors.d.ts wasm/pkg/errors.d.ts
cp wasm/.generated/primitives.js wasm/pkg/primitives.js
cp wasm/.generated/primitives.d.ts wasm/pkg/primitives.d.ts
# Copy the real README into pkg/ (wasm-pack generates a stub from Cargo.toml description)
cp wasm/README.md wasm/pkg/README.md
# Generate Node.js auto-init wrapper (uses web target's initSync)
Expand Down
45 changes: 44 additions & 1 deletion wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,38 @@ const response = await Transaction.builder()
.send(client);
```

## Startup-safe imports

Use startup-safe subpaths in React components or other startup paths that only
need to define transaction callbacks, websocket topics, enum values, or SDK
error guards. These entrypoints are side-effect-free and do not import the
generated WASM glue or `.wasm` binary.

```typescript
import { User } from '@bulletxyz/sdk-wasm/calls';
import { NewOrderArgs, Side, OrderType } from '@bulletxyz/sdk-wasm/primitives';
import { Topic, OrderbookDepth } from '@bulletxyz/sdk-wasm/topics';
import { isBulletSdkError } from '@bulletxyz/sdk-wasm/errors';

const order = new NewOrderArgs('50000.0', '0.1', Side.Bid, OrderType.Limit, false);
const placeOrderCall = User.placeOrders(0, [order], false);
const depthTopic = Topic.depth('BTC-USD', OrderbookDepth.D10);

// Later, only when you are ready to initialize the runtime/client:
const { Client, RuntimeCall, Transaction } = await import('@bulletxyz/sdk-wasm');
const client = await Client.builder().network('mainnet').build();

const tx = Transaction.builder()
.call(RuntimeCall.fromCall(placeOrderCall))
.signer(keypair)
.build(client);
```

Call builders return plain runtime-call data. Final parsing, schema validation,
signing, and submission still happen in the full WASM runtime. Existing root
imports such as `User.placeOrders(...)` and `.callMessage(...)` remain
supported.

## API Reference

### Client
Expand Down Expand Up @@ -62,6 +94,7 @@ client.hasKeypair() // whether a default keypair is set
// Submission
await client.sendTransaction(signedTx) // SubmitTxResponse
await client.sendOffChainTransaction(offchainTx) // SubmitTxResponse
await client.sendCall(call) // SubmitTxResponse
```
By default the client validates every exchange `CallMessage` group (`User`,
`Vault`, `Keeper`, `Public`, and `Admin`) against the server schema when it
Expand All @@ -76,11 +109,12 @@ parseable SDK metadata.

```typescript
import { BulletSdkError } from '@bulletxyz/sdk-wasm';
import { isBulletSdkError } from '@bulletxyz/sdk-wasm/errors';

try {
await client.accountBalance(address);
} catch (err) {
if (err instanceof BulletSdkError) {
if (isBulletSdkError(err) || err instanceof BulletSdkError) {
err.kind // 'api' | 'http' | 'websocket' | 'validation' | ...
err.status // HTTP status when the API returned one
err.details // structured JSON details when available
Expand All @@ -102,6 +136,15 @@ const response = await Transaction.builder()
.signer(keypair)
.send(client);

// Or from startup-safe call data imported from @bulletxyz/sdk-wasm/calls
import { User as CallUser } from '@bulletxyz/sdk-wasm/calls';
const depositCall = CallUser.deposit(0, '1000.0');

const responseFromCall = await Transaction.builder()
.call(RuntimeCall.fromCall(depositCall))
.signer(keypair)
.send(client);

// Build without sending
const tx = Transaction.builder()
.callMessage(msg)
Expand Down
22 changes: 22 additions & 0 deletions wasm/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ fn main() {
let code = codegen::emit::emit_all(&info);
fs::write(&call_msg_path, &code).expect("failed to write generated code");

let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
let generated_dir = Path::new(&manifest_dir).join(".generated");
if generated_dir.exists() {
fs::remove_dir_all(&generated_dir).expect("failed to clear generated output dir");
}
fs::create_dir_all(&generated_dir).expect("failed to create generated output dir");
for (file_name, contents) in [
("startup-shared.js", codegen::emit::startup_subpaths::emit_shared_js().to_string()),
("startup-shared.d.ts", codegen::emit::startup_subpaths::emit_shared_dts().to_string()),
("calls.js", codegen::emit::startup_subpaths::emit_calls_js(&info)),
("calls.d.ts", codegen::emit::startup_subpaths::emit_calls_dts(&info)),
("primitives.js", codegen::emit::startup_subpaths::emit_primitives_js(&info)),
("primitives.d.ts", codegen::emit::startup_subpaths::emit_primitives_dts(&info)),
("topics.js", codegen::emit::startup_subpaths::emit_topics_js().to_string()),
("topics.d.ts", codegen::emit::startup_subpaths::emit_topics_dts().to_string()),
("errors.js", codegen::emit::startup_subpaths::emit_errors_js().to_string()),
("errors.d.ts", codegen::emit::startup_subpaths::emit_errors_dts().to_string()),
] {
fs::write(generated_dir.join(file_name), contents)
.unwrap_or_else(|err| panic!("failed to write generated {file_name}: {err}"));
}

let total_variants: usize = info.action_groups.iter().map(|g| g.variants.len()).sum();
println!(
"cargo::warning=Generated {} factory methods across {} namespaces, {} struct wrappers, {} enums",
Expand Down
1 change: 1 addition & 0 deletions wasm/codegen/bullet_schema/emit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod enums;
pub mod namespaces;
pub mod startup_subpaths;
pub mod structs;

use proc_macro2::TokenStream;
Expand Down
186 changes: 186 additions & 0 deletions wasm/codegen/bullet_schema/emit/startup_subpaths/calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use heck::{ToLowerCamelCase, ToSnakeCase};

use super::super::super::{ActionGroup, SchemaInfo};
use super::helpers::{
field_names_array, js_params, js_string, js_values, ordered_fields, ts_param,
};

pub fn emit_calls_js(info: &SchemaInfo) -> String {
let mut out = String::new();
out.push_str(CALLS_JS_HEADER);
out.push('\n');

for group in &info.action_groups {
emit_namespace_js(&mut out, group);
out.push('\n');
}

out.push_str(WARP_JS);
out
}

pub fn emit_calls_dts(info: &SchemaInfo) -> String {
let mut out = calls_dts_header(info);

for group in &info.action_groups {
emit_namespace_dts(&mut out, group);
out.push('\n');
}

out.push_str(WARP_DTS);
out
}

fn calls_dts_header(info: &SchemaInfo) -> String {
let imports = info
.enums
.iter()
.map(|e| e.type_name.clone())
.chain(info.structs.iter().map(|s| s.type_name.clone()))
.collect::<Vec<_>>()
.join(", ");

format!(
r#"// Auto-generated by wasm/build.rs - do not edit manually.
import type {{ RuntimeCallData }} from "./startup-shared.js";
import type {{ {imports} }} from "./primitives.js";
export type {{ JsonObject, JsonPrimitive, JsonValue, RuntimeCallData }} from "./startup-shared.js";
export {{ isRuntimeCallData, toRuntimeCallJson }} from "./startup-shared.js";

"#
)
}

fn emit_namespace_js(out: &mut String, group: &ActionGroup) {
out.push_str(&format!("export class {} {{\n", group.call_message_variant));
for variant in &group.variants {
let fields = ordered_fields(&variant.fields);
let method = variant.variant_name.to_lower_camel_case();
let action = variant.variant_name.to_snake_case();
let group_name = group.call_message_variant.to_snake_case();
let params = js_params(&fields);
let names = field_names_array(&fields);
let values = js_values(&fields);

out.push_str(&format!(" static {method}({params}) {{\n"));
out.push_str(&format!(
" return makeExchangeCall({}, {}, {names}, [{values}]);\n",
js_string(&group_name),
js_string(&action)
));
out.push_str(" }\n");
}
out.push_str("}\n");
}

fn emit_namespace_dts(out: &mut String, group: &ActionGroup) {
out.push_str(&format!("export declare class {} {{\n", group.call_message_variant));
for variant in &group.variants {
let method = variant.variant_name.to_lower_camel_case();
let params =
ordered_fields(&variant.fields).iter().map(ts_param).collect::<Vec<_>>().join(", ");

out.push_str(&format!(" static {method}({params}): RuntimeCallData;\n"));
}
out.push_str("}\n");
}

const CALLS_JS_HEADER: &str = r#"// Auto-generated by wasm/build.rs - do not edit manually.
import { brandCall, isObject, makeFields } from "./startup-shared.js";

export { isRuntimeCallData, toRuntimeCallJson } from "./startup-shared.js";

function makeExchangeCall(group, action, names, values) {
return brandCall({
exchange: {
[group]: {
[action]: makeFields(names, values),
},
},
});
}
"#;

const WARP_JS: &str = r#"function normalizeWarpAmount(value, fieldName) {
if (typeof value === "bigint") {
if (value < 0n) {
throw new Error(`${fieldName} must be a non-negative integer`);
}
return value.toString();
}
if (typeof value === "number") {
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
throw new Error(`${fieldName} number must be a non-negative integer`);
}
if (!Number.isSafeInteger(value)) {
throw new Error(`${fieldName} number exceeds JavaScript safe integer range; pass a decimal string`);
}
return String(value);
}
if (typeof value === "string") {
return value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Warp amount normalization accepts any string without integer validation. Invalid amount strings bypass startup checks and fail later when converting to runtime calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At wasm/codegen/bullet_schema/emit/startup_subpaths/calls.rs, line 121:

<comment>Warp amount normalization accepts any string without integer validation. Invalid amount strings bypass startup checks and fail later when converting to runtime calls.</comment>

<file context>
@@ -0,0 +1,186 @@
+    return String(value);
+  }
+  if (typeof value === "string") {
+    return value;
+  }
+  throw new Error(`${fieldName} must be a decimal string, bigint, or safe integer number`);
</file context>

}
throw new Error(`${fieldName} must be a decimal string, bigint, or safe integer number`);
}

function normalizeRelayer(value) {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (isObject(value)) {
const standard = value.Standard ?? value.standard;
const vm = value.Vm ?? value.vm;
if (standard !== undefined && vm !== undefined) {
throw new Error("relayer object must include either Standard or Vm, not both");
}
if (standard !== undefined) {
return standard;
}
if (vm !== undefined) {
return vm;
}
}
throw new Error("relayer must be a string, {Standard: string}, {Vm: string}, null, or undefined");
}

export class Warp {
static transferRemote(args) {
const relayer = normalizeRelayer(args.relayer);
return brandCall({
warp: {
transfer_remote: makeFields(
["warp_route", "destination_domain", "recipient", "amount", "relayer", "gas_payment_limit"],
[
args.warpRoute ?? args.warp_route,
args.destinationDomain ?? args.destination_domain,
args.recipient,
normalizeWarpAmount(args.amount, "amount"),
relayer,
normalizeWarpAmount(args.gasPaymentLimit ?? args.gas_payment_limit, "gasPaymentLimit"),
],
),
},
});
}
}
"#;

const WARP_DTS: &str = r#"export interface WarpTransferRemoteArgs {
warpRoute?: string;
warp_route?: string;
amount: string | number | bigint;
destinationDomain?: number;
destination_domain?: number;
gasPaymentLimit?: string | number | bigint;
gas_payment_limit?: string | number | bigint;
recipient: string;
relayer?: { Standard: string } | { standard: string } | { Vm: string } | { vm: string } | string | null;
}

export declare class Warp {
static transferRemote(args: WarpTransferRemoteArgs): RuntimeCallData;
}
"#;
45 changes: 45 additions & 0 deletions wasm/codegen/bullet_schema/emit/startup_subpaths/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
pub fn emit_errors_js() -> &'static str {
ERRORS_JS
}

pub fn emit_errors_dts() -> &'static str {
ERRORS_DTS
}

const ERRORS_JS: &str = r#"// Auto-generated by wasm/build.rs - do not edit manually.
export { BulletSdkError } from "./bullet-sdk-error.js";

function isObject(value) {
return typeof value === "object" && value !== null;
}

export function isBulletSdkError(error) {
return isObject(error)
&& error.name === "BulletSdkError"
&& typeof error.message === "string"
&& typeof error.kind === "string"
&& typeof error.retryable === "boolean";
}

export function asBulletSdkError(error) {
return isBulletSdkError(error) ? error : undefined;
}
"#;

const ERRORS_DTS: &str = r#"// Auto-generated by wasm/build.rs - do not edit manually.
export {
BulletSdkError,
} from "./bullet-sdk-error.js";
export type {
BulletSdkErrorDetails,
BulletSdkErrorDetailsByKind,
BulletSdkErrorKind,
BulletSdkErrorOptions,
BulletSdkErrorStatus,
JsonValue as BulletSdkJsonValue,
} from "./bullet-sdk-error.js";
import type { BulletSdkError } from "./bullet-sdk-error.js";

export declare function isBulletSdkError(error: unknown): error is BulletSdkError;
export declare function asBulletSdkError(error: unknown): BulletSdkError | undefined;
"#;
Loading