diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 617ed4e..af4f07e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +pnpm dapp format:move pnpm lint:fix node scripts/strip-move-localnet.js --staged diff --git a/.gitignore b/.gitignore index 7d3cc43..8e49aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,12 @@ coverage deployments/ -packages/dapp/move/**/build/ -packages/dapp/move/**/.trace -packages/dapp/move/**/.coverage* -packages/dapp/move/**/Pub.localnet.toml -packages/dapp/move/**/Move.lock -packages/dapp/move/**/Published.toml +packages/tooling/tests-integration/**/build/ +packages/dapp/contracts/**/build/ +packages/dapp/contracts/**/.trace +packages/dapp/contracts/**/.coverage* +packages/dapp/contracts/**/Pub.localnet.toml +packages/dapp/contracts/**/Published.toml patches/pyth-crosschain/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a2a019..0c9785f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,8 @@ To keep localnet-only Move.lock data out of commits, enable the repo hooks (this git config --local core.hooksPath .githooks ``` -This enables a pre-commit hook that strips the `[env.localnet]` block from any staged `Move.lock`. -Your working tree may still contain the localnet block; only the commit content is cleaned. +This enables a pre-commit hook that strips the `[env.test-publish]` block from any staged `Move.lock`. +Your working tree may still contain the test-publish block; only the commit content is cleaned. To verify hooks are enabled: diff --git a/package.json b/package.json index 2d91239..803493e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "ui": "pnpm --filter ui", "script": "pnpm --filter dapp", "dapp": "pnpm --filter dapp", - "learn": "pnpm --filter learn", "tooling": "pnpm --filter tooling", "typecheck": "tsc -b", "lint": "eslint \"packages/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\"", diff --git a/packages/dapp/move/.prettierrc b/packages/dapp/contracts/.prettierrc similarity index 100% rename from packages/dapp/move/.prettierrc rename to packages/dapp/contracts/.prettierrc diff --git a/packages/dapp/contracts/prop-amm/Move.lock b/packages/dapp/contracts/prop-amm/Move.lock new file mode 100644 index 0000000..9103026 --- /dev/null +++ b/packages/dapp/contracts/prop-amm/Move.lock @@ -0,0 +1,71 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.test-publish.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "test-publish" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.test-publish.PropAmm] +source = { root = true } +use_environment = "test-publish" +manifest_digest = "C66389234060080B839236B8BDEF322DBBFD4F55C944FBDC236E812F170025F0" +deps = { deepbook = "deepbook", pyth = "Pyth", std = "MoveStdlib", sui = "Sui" } + +[pinned.test-publish.Pyth] +source = { local = "../pyth-mock" } +use_environment = "test-publish" +manifest_digest = "2963D6B4689ED068546E86F8453FBCA51DCDF68A1C96D681328D2CDACE14B5CF" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.test-publish.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "test-publish" +manifest_digest = "7B6E5525FC0BFAECA61725298C425266BB21D456122669E3B2D3A4151A705F69" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.test-publish.deepbook] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "4c2f8e22d54e40da9b44e297656ee1d4528612b2" } +use_environment = "test-publish" +manifest_digest = "4CE7EBF2D294B2073FAC96555A5584741C6DEAA89256AA4FD4FED535D3C79C54" +deps = { std = "MoveStdlib", sui = "Sui", token = "token" } + +[pinned.test-publish.token] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "4c2f8e22d54e40da9b44e297656ee1d4528612b2" } +use_environment = "test-publish" +manifest_digest = "2963D6B4689ED068546E86F8453FBCA51DCDF68A1C96D681328D2CDACE14B5CF" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "563c15820b27dec9cbe75f826a3b6243ef44da1a" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.PropAmm] +source = { root = true } +use_environment = "testnet" +manifest_digest = "EF55A39C03806D44F7224057AF3F6B7DC7E067950C63BB52702F3BF5557FC743" +deps = { deepbook = "deepbook", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "563c15820b27dec9cbe75f826a3b6243ef44da1a" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.deepbook] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "4c2f8e22d54e40da9b44e297656ee1d4528612b2" } +use_environment = "testnet" +manifest_digest = "3101923B9428545A4F52FFAD1C4F959F9BFFF84CD09CE4BCC1CB831286999B5A" +deps = { std = "MoveStdlib", sui = "Sui", token = "token" } + +[pinned.testnet.token] +source = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/token", rev = "4c2f8e22d54e40da9b44e297656ee1d4528612b2" } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/packages/dapp/contracts/prop-amm/Move.toml b/packages/dapp/contracts/prop-amm/Move.toml new file mode 100644 index 0000000..c0d26ca --- /dev/null +++ b/packages/dapp/contracts/prop-amm/Move.toml @@ -0,0 +1,13 @@ +[package] +name = "amm" +edition = "2024" +version = "0.0.1" + +[dependencies] +deepbook = { git = "https://github.com/MystenLabs/deepbookv3.git", subdir = "packages/deepbook", rev = "main" } + +[dep-replacements.test-publish] +pyth = { local = "../pyth-mock", rename-from = "Pyth", override = true } + +[environments] +test-publish = "8051a268" diff --git a/packages/dapp/contracts/prop-amm/README.md b/packages/dapp/contracts/prop-amm/README.md new file mode 100644 index 0000000..1c3deb7 --- /dev/null +++ b/packages/dapp/contracts/prop-amm/README.md @@ -0,0 +1,16 @@ +# Prop AMM Move Package + +This package contains the Prop AMM Move modules for configuration and execution. +It is experimental and unaudited. + +## Purpose + +- Define shared configuration for the AMM. +- Provide admin-gated updates and related events. +- Define execution-time state and events for trading. + +## Usage + +- Publish the package to initialize the admin capability. +- Call `create_amm_config_and_share` to create shared config and emit the creation event. +- Call `update_amm_config_and_emit` with an `AMMAdminCap` to change settings and emit the update event. diff --git a/packages/dapp/contracts/prop-amm/sources/executor.move b/packages/dapp/contracts/prop-amm/sources/executor.move new file mode 100644 index 0000000..5b8518b --- /dev/null +++ b/packages/dapp/contracts/prop-amm/sources/executor.move @@ -0,0 +1,40 @@ +/// Execution-time state and events for the AMM. +module amm::executor; + +use sui::table::Table; + +// === Structs === + +/// Per-trader account state. +/// +/// Uses a table to map each pool ID to the trader's active order IDs. +public struct TraderAccount has key { + /// Unique ID for the account object. + id: UID, + /// Account owner. + owner: address, + /// Active order IDs keyed by pool ID (table entries are stored on-chain). + active_orders: Table>, +} + +// === Events === + +/// Emitted when a quote is updated. +public struct QuoteUpdatedEvent has copy, drop { + /// Pool identifier. + pool_id: ID, + /// Quote price. + price: u64, + /// Spread in basis points. + spread_bps: u64, + /// Quote timestamp in milliseconds. + timestamp_ms: u64, +} + +/// Emitted when an order is executed. +public struct OrderExecutedEvent has copy, drop { + /// Order identifier. + order_id: ID, + /// Execution price. + fill_price: u64, +} diff --git a/packages/dapp/contracts/prop-amm/sources/manager.move b/packages/dapp/contracts/prop-amm/sources/manager.move new file mode 100644 index 0000000..6dbd0f0 --- /dev/null +++ b/packages/dapp/contracts/prop-amm/sources/manager.move @@ -0,0 +1,294 @@ +/// AMM configuration and admin controls. +module amm::manager; + +use sui::event; +use sui::object::{Self, UID}; +use sui::package; +use sui::transfer; +use sui::tx_context::TxContext; +// === Constants === + +const PYTH_PRICE_IDENTIFIER_LENGTH: u64 = 32; + +const EInvalidSpread: u64 = 1; +const EEmptyFeedId: u64 = 13; +const EInvalidFeedIdLength: u64 = 34; + +// === Structs === + +/// AMM configuration shared across pools. +public struct AMMConfig has key { + /// Unique ID for the config object. + id: UID, + /// Whether trading is paused. + trading_paused: bool, + /// Base spread in basis points. + base_spread_bps: u64, + /// Volatility multiplier in basis points. + volatility_multiplier_bps: u64, + /// Pyth price feed identifier bytes. + pyth_price_feed_id: vector, + /// Whether LASER pricing is enabled. + use_laser: bool, +} + +/// Capability required to update configuration. +public struct AMMAdminCap has key, store { + /// Unique ID for the admin capability object. + id: UID, +} + +// === Events === + +/// Emitted when a new configuration object is created. +public struct AMMConfigCreatedEvent has copy, drop { + /// ID of the configuration object. + config_id: address, +} + +/// Emitted when a configuration object is updated. +public struct AMMConfigUpdatedEvent has copy, drop { + /// ID of the configuration object. + config_id: address, +} + +// === Init === + +/// One-time publisher witness created at publish time. +public struct MANAGER has drop {} + +/// Initializes the package and transfers the admin capability to the publisher. +/// +/// This is intended to run once at publish time via the one-time witness. +fun init(publisher_witness: MANAGER, ctx: &mut TxContext) { + package::claim_and_keep(publisher_witness, ctx); + + let admin_cap = create_admin_cap(ctx); + transfer::transfer(admin_cap, ctx.sender()); +} + +// === Entry Functions === + +/// Creates, emits, and shares a new AMM configuration. +public fun create_amm_config_and_share( + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + pyth_price_feed_id: vector, + ctx: &mut TxContext, +) { + let config = create_amm_config( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ctx, + ); + event::emit(new_amm_config_created_event(&config)); + share_amm_config(config); +} + +/// Updates a configuration object and emits an update event. +public fun update_amm_config_and_emit( + config: &mut AMMConfig, + admin_cap: &AMMAdminCap, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + trading_paused: bool, + pyth_price_feed_id: vector, +) { + update_amm_config( + config, + admin_cap, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused, + pyth_price_feed_id, + ); + event::emit(new_amm_config_updated_event(config)); +} + +/// Shares a configuration object. +/// +/// Shared configs are readable by anyone; only the admin cap can update. +/// This function does not emit events. +public fun share_amm_config(config: AMMConfig) { + transfer::share_object(config); +} + +// === Public Functions === + +/// Creates a new AMM configuration object with validated inputs. +/// +/// The returned object is owned; call `share_amm_config` to make it shared. +/// Use `create_amm_config_and_share` to emit the creation event. +public fun create_amm_config( + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + pyth_price_feed_id: vector, + ctx: &mut TxContext, +): AMMConfig { + assert_valid_amm_config_inputs!(base_spread_bps, &pyth_price_feed_id); + + let config = create_config( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ctx, + ); + + config +} + +/// Updates a configuration object; requires the admin capability. +/// +/// The admin capability is the authorization proof for config mutations. +/// Use `update_amm_config_and_emit` to emit the update event. +public fun update_amm_config( + config: &mut AMMConfig, + admin_cap: &AMMAdminCap, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + trading_paused: bool, + pyth_price_feed_id: vector, +) { + assert_admin_cap!(admin_cap); + assert_valid_amm_config_inputs!(base_spread_bps, &pyth_price_feed_id); + + apply_amm_config_updates( + config, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused, + pyth_price_feed_id, + ); +} + +// === Private Functions === + +/// Builds a configuration object with default flags. +fun create_config( + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + pyth_price_feed_id: vector, + ctx: &mut TxContext, +): AMMConfig { + AMMConfig { + id: object::new(ctx), + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused: false, + pyth_price_feed_id, + } +} + +/// Creates a new admin capability object. +fun create_admin_cap(ctx: &mut TxContext): AMMAdminCap { + AMMAdminCap { id: object::new(ctx) } +} + +/// Applies updates to the configuration object. +fun apply_amm_config_updates( + config: &mut AMMConfig, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + trading_paused: bool, + pyth_price_feed_id: vector, +) { + config.base_spread_bps = base_spread_bps; + config.volatility_multiplier_bps = volatility_multiplier_bps; + config.use_laser = use_laser; + config.trading_paused = trading_paused; + config.pyth_price_feed_id = pyth_price_feed_id; +} + +/// Ensures the base spread is nonzero. +macro fun assert_valid_base_spread_bps($base_spread_bps: u64) { + assert!($base_spread_bps > 0, EInvalidSpread); +} + +/// Validates all inputs for a new or updated configuration. +macro fun assert_valid_amm_config_inputs($base_spread_bps: u64, $pyth_price_feed_id: &vector) { + assert_valid_base_spread_bps!($base_spread_bps); + assert_valid_feed_id!($pyth_price_feed_id); +} + +/// Verifies the admin capability is valid. +macro fun assert_admin_cap($admin_cap: &AMMAdminCap) { + let admin_cap = $admin_cap; + let _ = admin_cap.id.to_address(); +} + +/// Validates the Pyth price feed identifier. +/// +/// Pyth feed IDs are 32-byte identifiers. +macro fun assert_valid_feed_id($pyth_price_feed_id: &vector) { + let pyth_price_feed_id = $pyth_price_feed_id; + assert!(!pyth_price_feed_id.is_empty(), EEmptyFeedId); + assert!(pyth_price_feed_id.length() == PYTH_PRICE_IDENTIFIER_LENGTH, EInvalidFeedIdLength); +} + +/// Creates an AMMConfigCreatedEvent payload. +fun new_amm_config_created_event(config: &AMMConfig): AMMConfigCreatedEvent { + AMMConfigCreatedEvent { + config_id: config.id.to_address(), + } +} + +/// Creates an AMMConfigUpdatedEvent payload. +fun new_amm_config_updated_event(config: &AMMConfig): AMMConfigUpdatedEvent { + AMMConfigUpdatedEvent { + config_id: config.id.to_address(), + } +} + +// === Test-Only Helpers === + +#[test_only] +/// Creates the package witness and runs init for tests. +public fun init_for_testing(ctx: &mut TxContext) { + let publisher_witness = sui::test_utils::create_one_time_witness(); + init( + publisher_witness, + ctx, + ); +} + +#[test_only] +/// Returns the base spread for tests. +public fun base_spread_bps(config: &AMMConfig): u64 { + config.base_spread_bps +} + +#[test_only] +/// Returns the volatility multiplier for tests. +public fun volatility_multiplier_bps(config: &AMMConfig): u64 { + config.volatility_multiplier_bps +} + +#[test_only] +/// Returns the LASER flag for tests. +public fun use_laser(config: &AMMConfig): bool { + config.use_laser +} + +#[test_only] +/// Returns the trading paused flag for tests. +public fun trading_paused(config: &AMMConfig): bool { + config.trading_paused +} + +#[test_only] +/// Returns the Pyth price feed ID for tests. +public fun pyth_price_feed_id(config: &AMMConfig): &vector { + &config.pyth_price_feed_id +} diff --git a/packages/dapp/contracts/prop-amm/tests/manager_tests.move b/packages/dapp/contracts/prop-amm/tests/manager_tests.move new file mode 100644 index 0000000..07ac1c9 --- /dev/null +++ b/packages/dapp/contracts/prop-amm/tests/manager_tests.move @@ -0,0 +1,478 @@ +/// Tests for AMM manager behavior. +#[test_only] +module amm::manager_tests; + +use amm::manager; +use std::unit_test::{assert_eq, assert_ref_eq}; +use sui::test_scenario; + +// === Constants === + +const PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS: u64 = 32; + +// === Helpers === + +/// Builds a dummy Pyth feed ID with a consistent byte value. +fun build_pyth_price_feed_id_for_tests(length: u64): vector { + build_pyth_price_feed_id_with_byte_for_tests(length, 0) +} + +/// Builds a dummy Pyth feed ID with a caller-provided byte value. +fun build_pyth_price_feed_id_with_byte_for_tests(length: u64, byte_value: u8): vector { + vector::tabulate!(length, |_| byte_value) +} + +/// Runs package init in a scenario and advances to the next transaction. +fun init_and_advance_scenario( + scenario: &mut test_scenario::Scenario, + sender: address, +): test_scenario::TransactionEffects { + manager::init_for_testing(test_scenario::ctx(scenario)); + test_scenario::next_tx(scenario, sender) +} + +/// Creates a config, shares it, and advances to the next transaction. +fun create_and_share_amm_config_and_advance_scenario( + scenario: &mut test_scenario::Scenario, + sender: address, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + pyth_price_feed_id: vector, +): test_scenario::TransactionEffects { + manager::create_amm_config_and_share( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + test_scenario::ctx(scenario), + ); + test_scenario::next_tx(scenario, sender) +} + +/// Updates a config, returns resources to the scenario, and advances. +fun update_amm_config_and_advance_scenario( + config: manager::AMMConfig, + admin_cap: manager::AMMAdminCap, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + trading_paused: bool, + pyth_price_feed_id: vector, + scenario: &mut test_scenario::Scenario, + sender: address, +): test_scenario::TransactionEffects { + let mut config = config; + manager::update_amm_config_and_emit( + &mut config, + &admin_cap, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused, + pyth_price_feed_id, + ); + return_admin_cap_to_scenario(scenario, admin_cap); + return_config_to_scenario(config); + test_scenario::next_tx(scenario, sender) +} + +fun take_admin_cap_from_scenario(scenario: &test_scenario::Scenario): manager::AMMAdminCap { + test_scenario::take_from_sender(scenario) +} + +fun take_config_from_scenario(scenario: &test_scenario::Scenario): manager::AMMConfig { + test_scenario::take_shared(scenario) +} + +fun return_admin_cap_to_scenario( + scenario: &test_scenario::Scenario, + admin_cap: manager::AMMAdminCap, +) { + test_scenario::return_to_sender(scenario, admin_cap); +} + +fun return_config_to_scenario(config: manager::AMMConfig) { + test_scenario::return_shared(config); +} + +fun assert_config_matches_inputs( + config: &manager::AMMConfig, + base_spread_bps: u64, + volatility_multiplier_bps: u64, + use_laser: bool, + trading_paused: bool, + expected_pyth_price_feed_id: &vector, +) { + assert_eq!(manager::base_spread_bps(config), base_spread_bps); + assert_eq!(manager::volatility_multiplier_bps(config), volatility_multiplier_bps); + assert_eq!(manager::use_laser(config), use_laser); + assert_eq!(manager::trading_paused(config), trading_paused); + assert_ref_eq!(manager::pyth_price_feed_id(config), expected_pyth_price_feed_id); +} + +// === Tests === + +#[test] +fun init_transfers_admin_cap() { + let sender = @0xA; + let mut scenario = test_scenario::begin(sender); + + init_and_advance_scenario(&mut scenario, sender); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + return_admin_cap_to_scenario(&scenario, admin_cap); + test_scenario::end(scenario); +} + +#[test] +fun create_amm_config_shares_config_and_emits_event() { + let sender = @0xB; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 25; + let volatility_multiplier_bps = 200; + let use_laser = true; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + let expected_pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + + let effects = create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ); + + assert_eq!(test_scenario::num_user_events(&effects), 1); + + let config = take_config_from_scenario(&scenario); + assert_config_matches_inputs( + &config, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + false, + &expected_pyth_price_feed_id, + ); + + return_config_to_scenario(config); + test_scenario::end(scenario); +} + +#[test] +fun update_amm_config_updates_config_and_emits_event() { + let sender = @0xC; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 25; + let volatility_multiplier_bps = 200; + let use_laser = true; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + + init_and_advance_scenario(&mut scenario, sender); + create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + let config = take_config_from_scenario(&scenario); + let updated_base_spread_bps = 35; + let updated_volatility_multiplier_bps = 300; + let updated_use_laser = false; + let updated_trading_paused = true; + let updated_pyth_price_feed_id = build_pyth_price_feed_id_with_byte_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + 1, + ); + + let effects = update_amm_config_and_advance_scenario( + config, + admin_cap, + updated_base_spread_bps, + updated_volatility_multiplier_bps, + updated_use_laser, + updated_trading_paused, + updated_pyth_price_feed_id, + &mut scenario, + sender, + ); + + assert_eq!(test_scenario::num_user_events(&effects), 1); + + let updated_config = take_config_from_scenario(&scenario); + let expected_pyth_price_feed_id = build_pyth_price_feed_id_with_byte_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + 1, + ); + assert_config_matches_inputs( + &updated_config, + updated_base_spread_bps, + updated_volatility_multiplier_bps, + updated_use_laser, + updated_trading_paused, + &expected_pyth_price_feed_id, + ); + + return_config_to_scenario(updated_config); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + return_admin_cap_to_scenario(&scenario, admin_cap); + test_scenario::end(scenario); +} + +#[test] +fun update_amm_config_supports_multiple_updates() { + let sender = @0xD; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 10; + let volatility_multiplier_bps = 120; + let use_laser = false; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + + init_and_advance_scenario(&mut scenario, sender); + create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ); + + let first_admin_cap = take_admin_cap_from_scenario(&scenario); + let first_config = take_config_from_scenario(&scenario); + let first_update_pyth_price_feed_id = build_pyth_price_feed_id_with_byte_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + 1, + ); + let first_update_effects = update_amm_config_and_advance_scenario( + first_config, + first_admin_cap, + 20, + 150, + true, + true, + first_update_pyth_price_feed_id, + &mut scenario, + sender, + ); + assert_eq!(test_scenario::num_user_events(&first_update_effects), 1); + + let second_admin_cap = take_admin_cap_from_scenario(&scenario); + let second_config = take_config_from_scenario(&scenario); + let second_update_pyth_price_feed_id = build_pyth_price_feed_id_with_byte_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + 2, + ); + let second_update_effects = update_amm_config_and_advance_scenario( + second_config, + second_admin_cap, + 30, + 180, + false, + false, + second_update_pyth_price_feed_id, + &mut scenario, + sender, + ); + assert_eq!(test_scenario::num_user_events(&second_update_effects), 1); + + let updated_config = take_config_from_scenario(&scenario); + let expected_pyth_price_feed_id = build_pyth_price_feed_id_with_byte_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + 2, + ); + assert_config_matches_inputs( + &updated_config, + 30, + 180, + false, + false, + &expected_pyth_price_feed_id, + ); + + return_config_to_scenario(updated_config); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + return_admin_cap_to_scenario(&scenario, admin_cap); + test_scenario::end(scenario); +} + +#[test, expected_failure(abort_code = manager::EInvalidSpread)] +fun create_amm_config_rejects_zero_base_spread_bps() { + let base_spread_bps = 0; + let volatility_multiplier_bps = 1; + let use_laser = false; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + let ctx = &mut sui::tx_context::dummy(); + + let config = manager::create_amm_config( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ctx, + ); + manager::share_amm_config(config); + abort +} + +#[test, expected_failure(abort_code = manager::EInvalidSpread)] +fun update_amm_config_rejects_zero_base_spread_bps() { + let sender = @0xD; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 1; + let volatility_multiplier_bps = 1; + let use_laser = false; + let trading_paused = false; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS, + ); + + init_and_advance_scenario(&mut scenario, sender); + create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + let mut config = take_config_from_scenario(&scenario); + + manager::update_amm_config( + &mut config, + &admin_cap, + 0, + volatility_multiplier_bps, + use_laser, + trading_paused, + build_pyth_price_feed_id_for_tests(PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS), + ); + abort +} + +#[test, expected_failure(abort_code = manager::EEmptyFeedId)] +fun create_amm_config_rejects_empty_feed_id() { + let base_spread_bps = 1; + let volatility_multiplier_bps = 1; + let use_laser = false; + let pyth_price_feed_id = vector[]; + let ctx = &mut sui::tx_context::dummy(); + + let config = manager::create_amm_config( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ctx, + ); + manager::share_amm_config(config); + abort +} + +#[test, expected_failure(abort_code = manager::EEmptyFeedId)] +fun update_amm_config_rejects_empty_feed_id() { + let sender = @0xE; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 1; + let volatility_multiplier_bps = 1; + let use_laser = false; + let trading_paused = false; + + init_and_advance_scenario(&mut scenario, sender); + create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + build_pyth_price_feed_id_for_tests(PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS), + ); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + let mut config = take_config_from_scenario(&scenario); + + manager::update_amm_config( + &mut config, + &admin_cap, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused, + vector[], + ); + abort +} + +#[test, expected_failure(abort_code = manager::EInvalidFeedIdLength)] +fun create_amm_config_rejects_invalid_feed_id_length() { + let base_spread_bps = 1; + let volatility_multiplier_bps = 1; + let use_laser = false; + let pyth_price_feed_id = build_pyth_price_feed_id_for_tests( + PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS - 1, + ); + let ctx = &mut sui::tx_context::dummy(); + + let config = manager::create_amm_config( + base_spread_bps, + volatility_multiplier_bps, + use_laser, + pyth_price_feed_id, + ctx, + ); + manager::share_amm_config(config); + abort +} + +#[test, expected_failure(abort_code = manager::EInvalidFeedIdLength)] +fun update_amm_config_rejects_invalid_feed_id_length() { + let sender = @0xF; + let mut scenario = test_scenario::begin(sender); + let base_spread_bps = 1; + let volatility_multiplier_bps = 1; + let use_laser = false; + let trading_paused = false; + + init_and_advance_scenario(&mut scenario, sender); + create_and_share_amm_config_and_advance_scenario( + &mut scenario, + sender, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + build_pyth_price_feed_id_for_tests(PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS), + ); + + let admin_cap = take_admin_cap_from_scenario(&scenario); + let mut config = take_config_from_scenario(&scenario); + + manager::update_amm_config( + &mut config, + &admin_cap, + base_spread_bps, + volatility_multiplier_bps, + use_laser, + trading_paused, + build_pyth_price_feed_id_for_tests(PYTH_PRICE_FEED_ID_LENGTH_FOR_TESTS - 1), + ); + abort +} diff --git a/packages/dapp/move/amm/executor.move b/packages/dapp/move/amm/executor.move deleted file mode 100644 index e69de29..0000000 diff --git a/packages/dapp/move/amm/manager.move b/packages/dapp/move/amm/manager.move deleted file mode 100644 index e69de29..0000000 diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 7aa4a3c..9c48c9f 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -14,8 +14,8 @@ "move:publish": "TS_NODE_SKIP_IGNORE=1 NODE_OPTIONS=\"--no-warnings\" ../../node_modules/.bin/ts-node-esm --transpile-only src/scripts/move/publish.ts", "move:test": "TS_NODE_SKIP_IGNORE=1 NODE_OPTIONS=\"--no-warnings\" ../../node_modules/.bin/ts-node-esm --transpile-only src/scripts/move/test.ts", "lint": "../../node_modules/.bin/eslint .", - "test:integration": "vitest --config ./vitest.integration.config.ts", - "test:unit": "vitest --config ./vitest.config.ts", + "test:integration": "vitest --config ./vitest.integration.config.ts --passWithNoTests", + "test:unit": "vitest --config ./vitest.config.ts --passWithNoTests", "lint:fix": "../../node_modules/.bin/eslint . --fix", "format:move": "prettier --write 'move/**/*.move' '!move/**/**-patched/**/*.move'" }, @@ -38,4 +38,4 @@ "engines": { "node": ">=22.19.2" } -} \ No newline at end of file +} diff --git a/packages/dapp/src/scripts/move/publish.ts b/packages/dapp/src/scripts/move/publish.ts index b191016..062f29f 100644 --- a/packages/dapp/src/scripts/move/publish.ts +++ b/packages/dapp/src/scripts/move/publish.ts @@ -1,6 +1,6 @@ /** * Publishes a Move package and records deployment artifacts (package ID, UpgradeCap, Publisher). - * Uses localnet dep-replacements when configured and can skip if already deployed. + * Uses test-publish dep-replacements for localnet when configured and can skip if already deployed. */ import fs from "node:fs/promises" import os from "node:os" @@ -231,7 +231,7 @@ runSuiScript( clearStaleLocks: cliArguments.clearStaleMoveLocks }) - // Resolve the absolute Move package path (relative to repo root or move/). + // Resolve the absolute Move package path (relative to repo root or contracts/). const fullPackagePath = resolveFullPackagePath( path.resolve(tooling.suiConfig.paths.move), cliArguments.packagePath @@ -277,7 +277,7 @@ runSuiScript( .option("packagePath", { alias: "package-path", type: "string", - description: `The path of the package to publish in "move" directory`, + description: `The path of the package to publish in "contracts" directory`, demandOption: true }) .option("withUnpublishedDependencies", { diff --git a/packages/dapp/src/scripts/move/test.ts b/packages/dapp/src/scripts/move/test.ts index b6098a1..b09f025 100644 --- a/packages/dapp/src/scripts/move/test.ts +++ b/packages/dapp/src/scripts/move/test.ts @@ -49,13 +49,13 @@ const syncMoveEnvironmentForTests = async ( if (didAttempt && !chainId) { logWarning( - "Unable to resolve localnet chain id; Move.toml environments were not updated." + "Unable to resolve localnet chain id; Move.toml test-publish environments were not updated." ) } if (updatedFiles.length) { logKeyValueBlue("Move.toml")( - `updated ${updatedFiles.length} localnet environment entries` + `updated ${updatedFiles.length} test-publish environment entries` ) } } diff --git a/packages/dapp/src/scripts/owner/amm-create.ts b/packages/dapp/src/scripts/owner/amm-create.ts new file mode 100644 index 0000000..4683831 --- /dev/null +++ b/packages/dapp/src/scripts/owner/amm-create.ts @@ -0,0 +1,163 @@ +/** + * Creates a new shared AMM config for the target network. + */ +import yargs from "yargs" + +import { + DEFAULT_BASE_SPREAD_BPS, + DEFAULT_VOLATILITY_MULTIPLIER_BPS, + getAmmConfigOverview, + resolveAmmConfigInputs +} from "@sui-amm/domain-core/models/amm" +import { buildCreateAmmConfigTransaction } from "@sui-amm/domain-core/ptb/amm" +import { resolveAmmPackageId } from "@sui-amm/domain-node/amm" +import { emitJsonOutput } from "@sui-amm/tooling-node/json" +import { runSuiScript } from "@sui-amm/tooling-node/process" +import { findCreatedArtifactBySuffix } from "@sui-amm/tooling-node/transactions" +import { + logAmmConfigOverview, + resolvePythPriceFeedIdHex +} from "../../utils/amm.ts" + +type CreateAmmArguments = { + baseSpreadBps?: string + volatilityMultiplierBps?: string + useLaser?: boolean + pythPriceFeedId?: string + pythPriceFeedLabel?: string + ammPackageId?: string + devInspect?: boolean + dryRun?: boolean + json?: boolean +} + +runSuiScript( + async (tooling, cliArguments: CreateAmmArguments) => { + const ammPackageId = await resolveAmmPackageId({ + networkName: tooling.network.networkName, + ammPackageId: cliArguments.ammPackageId + }) + + const ammConfigInputs = await resolveAmmConfigInputs({ + pythPriceFeedIdHex: await resolvePythPriceFeedIdHex({ + networkName: tooling.network.networkName, + pythPriceFeedId: cliArguments.pythPriceFeedId, + pythPriceFeedLabel: cliArguments.pythPriceFeedLabel + }), + volatilityMultiplierBps: cliArguments.volatilityMultiplierBps, + baseSpreadBps: cliArguments.baseSpreadBps, + useLaser: cliArguments.useLaser + }) + + const createAmmTransaction = buildCreateAmmConfigTransaction({ + packageId: ammPackageId, + baseSpreadBps: ammConfigInputs.baseSpreadBps, + volatilityMultiplierBps: ammConfigInputs.volatilityMultiplierBps, + useLaser: ammConfigInputs.useLaser, + pythPriceFeedIdBytes: ammConfigInputs.pythPriceFeedIdBytes + }) + + const { execution, summary } = await tooling.executeTransactionWithSummary({ + transaction: createAmmTransaction, + signer: tooling.loadedEd25519KeyPair, + summaryLabel: "create-amm", + devInspect: cliArguments.devInspect, + dryRun: cliArguments.dryRun + }) + + if (!execution) return + + const createdArtifacts = execution.objectArtifacts.created + const createdAmmConfig = findCreatedArtifactBySuffix( + createdArtifacts, + "::manager::AMMConfig" + ) + + if (!createdAmmConfig) + throw new Error( + "Expected an AMM config object to be created, but it was not found in transaction artifacts." + ) + + const ammConfigOverview = await getAmmConfigOverview( + createdAmmConfig.objectId, + tooling.suiClient + ) + + if ( + emitJsonOutput( + { + ammConfig: ammConfigOverview, + digest: createdAmmConfig.digest, + initialSharedVersion: createdAmmConfig.initialSharedVersion, + pythPriceFeedIdHex: ammConfigInputs.pythPriceFeedIdHex, + transactionSummary: summary + }, + cliArguments.json + ) + ) + return + + logAmmConfigOverview(ammConfigOverview, { + initialSharedVersion: createdAmmConfig.initialSharedVersion + }) + }, + yargs() + .option("baseSpreadBps", { + alias: ["base-spread-bps"], + type: "string", + description: "Base spread in basis points (u64).", + default: DEFAULT_BASE_SPREAD_BPS, + demandOption: false + }) + .option("volatilityMultiplierBps", { + alias: ["volatility-multiplier-bps"], + type: "string", + description: "Volatility multiplier in basis points (u64).", + default: DEFAULT_VOLATILITY_MULTIPLIER_BPS, + demandOption: false + }) + .option("useLaser", { + alias: ["use-laser"], + type: "boolean", + default: false, + description: "Enable the laser pricing path for the AMM." + }) + .option("pythPriceFeedId", { + alias: ["pyth-price-feed-id", "pyth-feed-id"], + type: "string", + description: "Pyth price feed id (32 bytes hex).", + demandOption: false + }) + .option("pythPriceFeedLabel", { + alias: ["pyth-price-feed-label", "pyth-feed-label"], + type: "string", + description: + "Localnet mock feed label to resolve the feed id when --pyth-price-feed-id is omitted.", + demandOption: false + }) + .option("ammPackageId", { + alias: ["amm-package-id"], + type: "string", + description: + "Package ID for the amm Move package; inferred from the latest publish entry in deployments/deployment..json when omitted.", + demandOption: false + }) + .option("devInspect", { + alias: ["dev-inspect", "debug"], + type: "boolean", + default: false, + description: "Run a dev-inspect and log VM error details." + }) + .option("dryRun", { + alias: ["dry-run"], + type: "boolean", + default: false, + description: "Run dev-inspect and exit without executing the transaction." + }) + .option("json", { + type: "boolean", + default: false, + description: "Output results as JSON." + }) + .strict() +) diff --git a/packages/dapp/src/scripts/owner/test-integration/amm-create.test.ts b/packages/dapp/src/scripts/owner/test-integration/amm-create.test.ts new file mode 100644 index 0000000..1f8e2e4 --- /dev/null +++ b/packages/dapp/src/scripts/owner/test-integration/amm-create.test.ts @@ -0,0 +1,167 @@ +import { existsSync } from "node:fs" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { describe, expect, it } from "vitest" + +import { + AMM_CONFIG_TYPE_SUFFIX, + type AmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { DEFAULT_MOCK_PRICE_FEED } from "@sui-amm/domain-core/models/pyth" +import { normalizeHex } from "@sui-amm/tooling-core/hex" +import { extractInitialSharedVersion } from "@sui-amm/tooling-core/shared-object" +import { pickRootNonDependencyArtifact } from "@sui-amm/tooling-node/artifacts" +import { createSuiLocalnetTestEnv } from "@sui-amm/tooling-node/testing/env" +import { + resolveDappMoveRoot, + resolveDappRoot +} from "@sui-amm/tooling-node/testing/paths" +import { + createSuiScriptRunner, + parseJsonFromScriptOutput +} from "@sui-amm/tooling-node/testing/scripts" + +type AmmCreateOutput = { + ammConfig?: AmmConfigOverview + digest?: string + initialSharedVersion?: string + pythPriceFeedIdHex?: string + transactionSummary?: { label?: string } +} + +type ObjectArtifact = { + objectId?: string + objectType?: string + initialSharedVersion?: string +} + +const resolveKeepTemp = () => process.env.SUI_IT_KEEP_TEMP === "1" + +const resolveWithFaucet = () => process.env.SUI_IT_WITH_FAUCET !== "0" + +const resolveOwnerScriptPath = (scriptName: string) => + path.join( + resolveDappRoot(), + "src", + "scripts", + "owner", + scriptName.endsWith(".ts") ? scriptName : `${scriptName}.ts` + ) + +const resolveObjectArtifactsPath = (artifactsDir: string) => + path.join(artifactsDir, "objects.localnet.json") + +const resolvePythMockPath = () => path.join(resolveDappMoveRoot(), "pyth-mock") + +const isPythMockAvailable = () => existsSync(resolvePythMockPath()) + +const readObjectArtifacts = async (artifactsDir: string) => { + const contents = await readFile( + resolveObjectArtifactsPath(artifactsDir), + "utf8" + ) + return JSON.parse(contents) as ObjectArtifact[] +} + +const findObjectArtifactById = ( + artifacts: ObjectArtifact[], + objectId: string +) => artifacts.find((artifact) => artifact.objectId === objectId) + +const testEnv = createSuiLocalnetTestEnv({ + mode: "test", + keepTemp: resolveKeepTemp(), + withFaucet: resolveWithFaucet(), + moveSourceRootPath: resolveDappMoveRoot() +}) + +;(isPythMockAvailable() ? describe : describe.skip)( + "owner amm-create integration", + () => { + it("creates a shared AMM config and records artifacts", async () => { + await testEnv.withTestContext("owner-amm-create", async (context) => { + const publisher = context.createAccount("publisher") + await context.fundAccount(publisher, { minimumCoinObjects: 2 }) + + const publishArtifacts = await context.publishPackage( + "prop-amm", + publisher, + { withUnpublishedDependencies: true } + ) + pickRootNonDependencyArtifact(publishArtifacts) + + const baseSpreadBps = "37" + const volatilityMultiplierBps = "420" + const useLaser = true + const pythPriceFeedId = DEFAULT_MOCK_PRICE_FEED.feedIdHex + + const scriptRunner = createSuiScriptRunner(context) + const result = await scriptRunner.runScript( + resolveOwnerScriptPath("amm-create"), + { + account: publisher, + args: { + json: true, + baseSpreadBps, + volatilityMultiplierBps, + useLaser, + pythPriceFeedId + } + } + ) + + expect(result.exitCode).toBe(0) + + const output = parseJsonFromScriptOutput( + result.stdout, + "amm-create output" + ) + if (!output.ammConfig) + throw new Error("amm-create output did not include ammConfig.") + if (!output.initialSharedVersion) + throw new Error("amm-create output did not include shared version.") + + expect(output.digest).toBeTruthy() + expect(output.transactionSummary?.label).toBe("create-amm") + expect(output.ammConfig.baseSpreadBps).toBe(baseSpreadBps) + expect(output.ammConfig.volatilityMultiplierBps).toBe( + volatilityMultiplierBps + ) + expect(output.ammConfig.useLaser).toBe(useLaser) + expect(output.ammConfig.tradingPaused).toBe(false) + expect(normalizeHex(output.ammConfig.pythPriceFeedIdHex)).toBe( + normalizeHex(pythPriceFeedId) + ) + expect(normalizeHex(output.pythPriceFeedIdHex ?? "")).toBe( + normalizeHex(pythPriceFeedId) + ) + + const objectResponse = await context.suiClient.getObject({ + id: output.ammConfig.configId, + options: { showOwner: true } + }) + if (!objectResponse.data) + throw new Error( + "AMM config object could not be loaded from localnet." + ) + const onChainSharedVersion = extractInitialSharedVersion( + objectResponse.data + ) + + expect(onChainSharedVersion).toBe(output.initialSharedVersion) + + const objectArtifacts = await readObjectArtifacts(context.artifactsDir) + const createdArtifact = findObjectArtifactById( + objectArtifacts, + output.ammConfig.configId + ) + expect( + createdArtifact?.objectType?.endsWith(AMM_CONFIG_TYPE_SUFFIX) + ).toBe(true) + expect(createdArtifact?.initialSharedVersion).toBe( + output.initialSharedVersion + ) + }) + }) + } +) diff --git a/packages/dapp/src/scripts/user/amm-view.ts b/packages/dapp/src/scripts/user/amm-view.ts new file mode 100644 index 0000000..b32de0a --- /dev/null +++ b/packages/dapp/src/scripts/user/amm-view.ts @@ -0,0 +1,96 @@ +/** + * Displays the current AMM config snapshot for the target network. + * Resolves the config id from artifacts when omitted. + */ +import yargs from "yargs" + +import { + collectAmmConfigSnapshot, + resolveAmmConfigId +} from "@sui-amm/domain-node/amm" +import { emitJsonOutput } from "@sui-amm/tooling-node/json" +import { logKeyValueBlue } from "@sui-amm/tooling-node/log" +import { runSuiScript } from "@sui-amm/tooling-node/process" +import { logAmmConfigOverview } from "../../utils/amm.ts" + +type AmmViewArguments = { + ammConfigId?: string + json?: boolean +} + +type AmmViewContext = { + networkName: string + rpcUrl: string + ammConfigId: string +} + +const resolveAmmConfigIdToView = async ({ + networkName, + cliArguments +}: { + networkName: string + cliArguments: AmmViewArguments +}): Promise => + resolveAmmConfigId({ + networkName, + ammConfigId: cliArguments.ammConfigId + }) + +const logAmmViewContext = ({ + networkName, + rpcUrl, + ammConfigId +}: AmmViewContext) => { + logKeyValueBlue("Network")(networkName) + logKeyValueBlue("RPC")(rpcUrl) + logKeyValueBlue("Config")(ammConfigId) + console.log("") +} + +runSuiScript( + async (tooling, cliArguments: AmmViewArguments) => { + const ammConfigId = await resolveAmmConfigIdToView({ + networkName: tooling.network.networkName, + cliArguments + }) + + const { ammConfigOverview, initialSharedVersion } = + await collectAmmConfigSnapshot({ + tooling, + ammConfigId + }) + + if ( + emitJsonOutput( + { + ammConfig: ammConfigOverview, + initialSharedVersion + }, + cliArguments.json + ) + ) + return + + logAmmViewContext({ + networkName: tooling.network.networkName, + rpcUrl: tooling.network.url, + ammConfigId + }) + + logAmmConfigOverview(ammConfigOverview, { initialSharedVersion }) + }, + yargs() + .option("ammConfigId", { + alias: ["amm-config-id", "config-id"], + type: "string", + description: + "AMM config object id; inferred from the latest objects artifact when omitted.", + demandOption: false + }) + .option("json", { + type: "boolean", + default: false, + description: "Output results as JSON." + }) + .strict() +) diff --git a/packages/dapp/src/scripts/user/test-integration/amm-view.test.ts b/packages/dapp/src/scripts/user/test-integration/amm-view.test.ts new file mode 100644 index 0000000..9a00fea --- /dev/null +++ b/packages/dapp/src/scripts/user/test-integration/amm-view.test.ts @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +import { + AMM_CONFIG_TYPE_SUFFIX, + type AmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { DEFAULT_MOCK_PRICE_FEED } from "@sui-amm/domain-core/models/pyth" +import { + buildCreateAmmConfigTransaction, + parsePythPriceFeedIdBytes +} from "@sui-amm/domain-core/ptb/amm" +import { normalizeHex } from "@sui-amm/tooling-core/hex" +import { extractInitialSharedVersion } from "@sui-amm/tooling-core/shared-object" +import { ensureCreatedObject } from "@sui-amm/tooling-core/transactions" +import { pickRootNonDependencyArtifact } from "@sui-amm/tooling-node/artifacts" +import { createSuiLocalnetTestEnv } from "@sui-amm/tooling-node/testing/env" +import { + resolveDappMoveRoot, + resolveDappRoot +} from "@sui-amm/tooling-node/testing/paths" +import { + createSuiScriptRunner, + parseJsonFromScriptOutput +} from "@sui-amm/tooling-node/testing/scripts" + +type AmmViewOutput = { + ammConfig?: AmmConfigOverview + initialSharedVersion?: string +} + +const resolveKeepTemp = () => process.env.SUI_IT_KEEP_TEMP === "1" + +const resolveWithFaucet = () => process.env.SUI_IT_WITH_FAUCET !== "0" + +const resolveUserScriptPath = (scriptName: string) => + path.join( + resolveDappRoot(), + "src", + "scripts", + "user", + scriptName.endsWith(".ts") ? scriptName : `${scriptName}.ts` + ) + +const resolvePythMockPath = () => path.join(resolveDappMoveRoot(), "pyth-mock") + +const isPythMockAvailable = () => existsSync(resolvePythMockPath()) + +const testEnv = createSuiLocalnetTestEnv({ + mode: "test", + keepTemp: resolveKeepTemp(), + withFaucet: resolveWithFaucet(), + moveSourceRootPath: resolveDappMoveRoot() +}) + +;(isPythMockAvailable() ? describe : describe.skip)("amm-view script", () => { + it("renders the latest AMM config snapshot when no id is provided", async () => { + await testEnv.withTestContext("user-amm-view", async (context) => { + const publisher = context.createAccount("publisher") + await context.fundAccount(publisher, { minimumCoinObjects: 2 }) + + const publishArtifacts = await context.publishPackage( + "prop-amm", + publisher, + { withUnpublishedDependencies: true } + ) + const rootArtifact = pickRootNonDependencyArtifact(publishArtifacts) + + const baseSpreadBps = 37n + const volatilityMultiplierBps = 420n + const useLaser = true + const pythPriceFeedIdHex = DEFAULT_MOCK_PRICE_FEED.feedIdHex + const createTransaction = buildCreateAmmConfigTransaction({ + packageId: rootArtifact.packageId, + baseSpreadBps, + volatilityMultiplierBps, + useLaser, + pythPriceFeedIdBytes: parsePythPriceFeedIdBytes(pythPriceFeedIdHex) + }) + + const createResult = await context.signAndExecuteTransaction( + createTransaction, + publisher + ) + await context.waitForFinality(createResult.digest) + + const createdConfig = ensureCreatedObject( + AMM_CONFIG_TYPE_SUFFIX, + createResult + ) + const ammConfigId = createdConfig.objectId + const initialSharedVersion = extractInitialSharedVersion(createdConfig) + if (!initialSharedVersion) + throw new Error( + "Expected AMM config to include shared version metadata." + ) + + const scriptRunner = createSuiScriptRunner(context) + const result = await scriptRunner.runScript( + resolveUserScriptPath("amm-view"), + { + account: publisher, + args: { json: true } + } + ) + + expect(result.exitCode).toBe(0) + + const parsed = parseJsonFromScriptOutput( + result.stdout, + "amm-view output" + ) + if (!parsed.ammConfig) + throw new Error("amm-view output did not include ammConfig.") + if (!parsed.initialSharedVersion) + throw new Error("amm-view output did not include shared version.") + + expect(parsed.ammConfig.configId).toBe(ammConfigId) + expect(parsed.ammConfig.baseSpreadBps).toBe(baseSpreadBps.toString()) + expect(parsed.ammConfig.volatilityMultiplierBps).toBe( + volatilityMultiplierBps.toString() + ) + expect(parsed.ammConfig.useLaser).toBe(useLaser) + expect(parsed.ammConfig.tradingPaused).toBe(false) + expect(normalizeHex(parsed.ammConfig.pythPriceFeedIdHex)).toBe( + normalizeHex(pythPriceFeedIdHex) + ) + expect(parsed.initialSharedVersion).toBe(initialSharedVersion) + }) + }) +}) diff --git a/packages/dapp/src/utils/amm.ts b/packages/dapp/src/utils/amm.ts new file mode 100644 index 0000000..e3307c8 --- /dev/null +++ b/packages/dapp/src/utils/amm.ts @@ -0,0 +1,141 @@ +import type { SuiClient } from "@mysten/sui/client" +import { + AMM_ADMIN_CAP_TYPE_SUFFIX, + type AmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { findMockPriceFeedConfig } from "@sui-amm/domain-core/models/pyth" +import type { PublishArtifact } from "@sui-amm/tooling-core/types" +import { ensureCreatedObject } from "@sui-amm/tooling-core/transactions" +import { + findLatestArtifactThat, + loadDeploymentArtifacts, + readArtifact +} from "@sui-amm/tooling-node/artifacts" +import type { Tooling } from "@sui-amm/tooling-node/factory" +import { logKeyValueGreen, logWarning } from "@sui-amm/tooling-node/log" +import { resolveFullPackagePath } from "@sui-amm/tooling-node/move" +import type { MockArtifact } from "./mocks.ts" +import { mockArtifactPath } from "./mocks.ts" + +export const DEFAULT_PYTH_PRICE_FEED_LABEL = "MOCK_SUI_FEED" + +const AMM_PACKAGE_FOLDER_NAME = "prop-amm" + +export const resolveAmmPackagePath = (tooling: Tooling) => + resolveFullPackagePath(tooling.suiConfig.paths.move, AMM_PACKAGE_FOLDER_NAME) + +const resolveAmmPublishArtifact = async ({ + networkName, + ammPackageId +}: { + networkName: string + ammPackageId: string +}): Promise => { + const deploymentArtifacts = await loadDeploymentArtifacts(networkName) + + return findLatestArtifactThat( + (artifact) => artifact.packageId === ammPackageId, + deploymentArtifacts + ) +} + +export const resolveAmmAdminCapIdFromPublishDigest = async ({ + publishDigest, + suiClient +}: { + publishDigest: string + suiClient: SuiClient +}): Promise => { + const publishTransaction = await suiClient.getTransactionBlock({ + digest: publishDigest, + options: { showObjectChanges: true } + }) + + return ensureCreatedObject(AMM_ADMIN_CAP_TYPE_SUFFIX, publishTransaction) + .objectId +} + +export const resolveAmmAdminCapIdFromArtifacts = async ({ + tooling, + ammPackageId +}: { + tooling: Pick + ammPackageId: string +}): Promise => { + const publishArtifact = await resolveAmmPublishArtifact({ + networkName: tooling.network.networkName, + ammPackageId + }) + + if (!publishArtifact?.digest) + throw new Error( + "Unable to locate the latest AMM publish artifact; provide --admin-cap-id or re-run publish to refresh deployments." + ) + + return resolveAmmAdminCapIdFromPublishDigest({ + publishDigest: publishArtifact.digest, + suiClient: tooling.suiClient + }) +} + +const findPriceFeedIdFromMockArtifact = ( + mockArtifact: MockArtifact, + label: string +): string | undefined => + mockArtifact.priceFeeds?.find((feed) => feed.label === label)?.feedIdHex + +export const resolvePythPriceFeedIdHex = async ({ + networkName, + pythPriceFeedId, + pythPriceFeedLabel +}: { + networkName: string + pythPriceFeedId?: string + pythPriceFeedLabel?: string +}): Promise => { + const trimmedFeedId = pythPriceFeedId?.trim() + if (trimmedFeedId) return trimmedFeedId + + if (networkName !== "localnet") + throw new Error( + "Pyth price feed id is required; provide --pyth-price-feed-id when targeting shared networks." + ) + + const desiredLabel = pythPriceFeedLabel ?? DEFAULT_PYTH_PRICE_FEED_LABEL + const mockArtifact = await readArtifact(mockArtifactPath, {}) + + const artifactFeedId = findPriceFeedIdFromMockArtifact( + mockArtifact, + desiredLabel + ) + if (artifactFeedId) return artifactFeedId + + const fallbackFeed = findMockPriceFeedConfig({ label: desiredLabel }) + if (fallbackFeed) { + logWarning( + `No localnet mock feed artifacts found for ${desiredLabel}; using default mock feed id.` + ) + return fallbackFeed.feedIdHex + } + + throw new Error( + "Unable to resolve a Pyth price feed id. Run the mock setup script or provide --pyth-price-feed-id." + ) +} + +export const logAmmConfigOverview = ( + overview: AmmConfigOverview, + options?: { + initialSharedVersion?: string + } +) => { + logKeyValueGreen("Config")(overview.configId) + logKeyValueGreen("Spread-bps")(overview.baseSpreadBps) + logKeyValueGreen("Vol-bps")(overview.volatilityMultiplierBps) + logKeyValueGreen("Use-laser")(overview.useLaser ? "Yes" : "No") + logKeyValueGreen("Paused")(overview.tradingPaused ? "Yes" : "No") + logKeyValueGreen("Feed-id")(overview.pythPriceFeedIdHex) + if (options?.initialSharedVersion) + logKeyValueGreen("Shared-ver")(options.initialSharedVersion) + console.log("") +} diff --git a/packages/dapp/src/utils/mocks.ts b/packages/dapp/src/utils/mocks.ts new file mode 100644 index 0000000..c8a9e2e --- /dev/null +++ b/packages/dapp/src/utils/mocks.ts @@ -0,0 +1,42 @@ +import { getArtifactPath, writeArtifact } from "@sui-amm/tooling-node/artifacts" +import path from "node:path" + +export type MockArtifact = Partial<{ + pythPackageId: string + coinPackageId: string + priceFeeds: { + label: string + feedIdHex: string + priceInfoObjectId: string + }[] + coins: { + label: string + coinType: string + currencyObjectId: string + treasuryCapId?: string + metadataObjectId?: string + mintedCoinObjectId?: string + }[] +}> + +export type CoinArtifact = NonNullable[number] +export type PriceFeedArtifact = NonNullable[number] + +/** + * Persists mock deployment state (packages, coins, price feeds) to disk. + * This lets repeated localnet runs reuse published mocks instead of republishing every time. + */ +export const writeMockArtifact = writeArtifact({}) + +export const mockArtifactPath = getArtifactPath("mock")("localnet") + +export const DEFAULT_PYTH_CONTRACT_PATH = path.join( + process.cwd(), + "contracts", + "pyth-mock" +) +export const DEFAULT_COIN_CONTRACT_PATH = path.join( + process.cwd(), + "contracts", + "coin-mock" +) diff --git a/packages/dapp/src/utils/test/amm.test.ts b/packages/dapp/src/utils/test/amm.test.ts new file mode 100644 index 0000000..58624cc --- /dev/null +++ b/packages/dapp/src/utils/test/amm.test.ts @@ -0,0 +1,112 @@ +import type * as ArtifactsModule from "@sui-amm/tooling-node/artifacts" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const artifactMocks = vi.hoisted(() => ({ + readArtifact: vi.fn() +})) + +const pythMocks = vi.hoisted(() => ({ + findMockPriceFeedConfig: vi.fn() +})) + +const logMocks = vi.hoisted(() => ({ + logWarning: vi.fn(), + logKeyValueGreen: vi.fn(() => vi.fn()) +})) + +vi.mock("@sui-amm/tooling-node/artifacts", async (importOriginal) => ({ + ...(await importOriginal()), + readArtifact: artifactMocks.readArtifact +})) + +vi.mock("@sui-amm/domain-core/models/pyth", () => ({ + findMockPriceFeedConfig: pythMocks.findMockPriceFeedConfig +})) + +vi.mock("@sui-amm/tooling-node/log", () => ({ + logWarning: logMocks.logWarning, + logKeyValueGreen: logMocks.logKeyValueGreen +})) + +import { resolvePythPriceFeedIdHex } from "../amm.ts" + +describe("resolvePythPriceFeedIdHex", () => { + beforeEach(() => { + artifactMocks.readArtifact.mockReset() + pythMocks.findMockPriceFeedConfig.mockReset() + logMocks.logWarning.mockReset() + }) + + it("returns a trimmed explicit feed id", async () => { + const resolved = await resolvePythPriceFeedIdHex({ + networkName: "testnet", + pythPriceFeedId: " 0xabc " + }) + + expect(resolved).toBe("0xabc") + expect(artifactMocks.readArtifact).not.toHaveBeenCalled() + expect(pythMocks.findMockPriceFeedConfig).not.toHaveBeenCalled() + }) + + it("throws on shared networks without an explicit feed id", async () => { + await expect( + resolvePythPriceFeedIdHex({ networkName: "devnet" }) + ).rejects.toThrow( + "Pyth price feed id is required; provide --pyth-price-feed-id when targeting shared networks." + ) + + expect(artifactMocks.readArtifact).not.toHaveBeenCalled() + }) + + it("prefers mock artifact feed ids on localnet", async () => { + artifactMocks.readArtifact.mockResolvedValue({ + priceFeeds: [ + { + label: "CUSTOM_FEED", + feedIdHex: "0xfeed", + priceInfoObjectId: "0xprice" + } + ] + }) + + const resolved = await resolvePythPriceFeedIdHex({ + networkName: "localnet", + pythPriceFeedLabel: "CUSTOM_FEED" + }) + + expect(resolved).toBe("0xfeed") + expect(pythMocks.findMockPriceFeedConfig).not.toHaveBeenCalled() + expect(logMocks.logWarning).not.toHaveBeenCalled() + }) + + it("falls back to default mocks when artifacts are missing", async () => { + artifactMocks.readArtifact.mockResolvedValue({ priceFeeds: [] }) + pythMocks.findMockPriceFeedConfig.mockReturnValue({ + label: "MOCK_SUI_FEED", + feedIdHex: "0xfallback", + price: 1n, + confidence: 1n, + exponent: 0 + }) + + const resolved = await resolvePythPriceFeedIdHex({ + networkName: "localnet" + }) + + expect(resolved).toBe("0xfallback") + expect(logMocks.logWarning).toHaveBeenCalledWith( + "No localnet mock feed artifacts found for MOCK_SUI_FEED; using default mock feed id." + ) + }) + + it("throws when no feed id can be resolved", async () => { + artifactMocks.readArtifact.mockResolvedValue({ priceFeeds: [] }) + pythMocks.findMockPriceFeedConfig.mockReturnValue(undefined) + + await expect( + resolvePythPriceFeedIdHex({ networkName: "localnet" }) + ).rejects.toThrow( + "Unable to resolve a Pyth price feed id. Run the mock setup script or provide --pyth-price-feed-id." + ) + }) +}) diff --git a/packages/dapp/sui.config.ts b/packages/dapp/sui.config.ts index f9208b7..3553410 100644 --- a/packages/dapp/sui.config.ts +++ b/packages/dapp/sui.config.ts @@ -33,7 +33,7 @@ export default defineSuiConfig({ } }, paths: { - move: "move", + move: "contracts", deployments: "deployments", artifacts: "deployments", objects: "deployments" diff --git a/packages/domain/core/src/index.ts b/packages/domain/core/src/index.ts new file mode 100644 index 0000000..ec335cc --- /dev/null +++ b/packages/domain/core/src/index.ts @@ -0,0 +1,2 @@ +// Placeholder entrypoint until domain-core model modules are introduced. +export {} diff --git a/packages/domain/core/src/models/amm.ts b/packages/domain/core/src/models/amm.ts new file mode 100644 index 0000000..2073079 --- /dev/null +++ b/packages/domain/core/src/models/amm.ts @@ -0,0 +1,130 @@ +import type { SuiClient, SuiObjectData } from "@mysten/sui/client" + +import { + getSuiObject, + unwrapMoveObjectFields +} from "@sui-amm/tooling-core/object" +import { + formatOptionalNumericValue, + formatVectorBytesAsHex +} from "@sui-amm/tooling-core/utils/formatters" +import { + parseNonNegativeU64, + parsePositiveU64 +} from "@sui-amm/tooling-core/utils/utility" +import { parsePythPriceFeedIdBytes } from "../ptb/amm.ts" + +export const AMM_CONFIG_TYPE_SUFFIX = "::manager::AMMConfig" +export const AMM_ADMIN_CAP_TYPE_SUFFIX = "::manager::AMMAdminCap" + +export type AmmConfigOverview = { + configId: string + baseSpreadBps: string + volatilityMultiplierBps: string + useLaser: boolean + tradingPaused: boolean + pythPriceFeedIdHex: string +} + +type AmmConfigFields = { + base_spread_bps?: unknown + volatility_multiplier_bps?: unknown + use_laser?: unknown + trading_paused?: unknown + pyth_price_feed_id?: unknown +} + +const requireNumericField = (value: unknown, label: string): string => { + const formatted = formatOptionalNumericValue(value) + if (formatted === undefined) throw new Error(`${label} is required.`) + return formatted +} + +const requireBooleanField = (value: unknown, label: string): boolean => { + if (typeof value === "boolean") return value + throw new Error(`${label} is required.`) +} + +const requireFeedIdHex = (value: unknown): string => { + const formatted = formatVectorBytesAsHex(value) + if (formatted === "Unknown") + throw new Error("Pyth price feed id is required.") + return formatted +} + +const buildAmmConfigOverviewFromObject = ({ + configId, + object +}: { + configId: string + object: SuiObjectData +}): AmmConfigOverview => { + const fields = unwrapMoveObjectFields(object) + + return { + configId, + baseSpreadBps: requireNumericField( + fields.base_spread_bps, + "Base spread bps" + ), + volatilityMultiplierBps: requireNumericField( + fields.volatility_multiplier_bps, + "Volatility multiplier bps" + ), + useLaser: requireBooleanField(fields.use_laser, "Use laser flag"), + tradingPaused: requireBooleanField(fields.trading_paused, "Trading paused"), + pythPriceFeedIdHex: requireFeedIdHex(fields.pyth_price_feed_id) + } +} + +export const getAmmConfigOverview = async ( + configId: string, + suiClient: SuiClient +): Promise => { + const { object } = await getSuiObject( + { objectId: configId, options: { showContent: true, showType: true } }, + { suiClient } + ) + + return buildAmmConfigOverviewFromObject({ configId, object }) +} + +export const DEFAULT_BASE_SPREAD_BPS = "25" +export const DEFAULT_VOLATILITY_MULTIPLIER_BPS = "200" + +const resolveBaseSpreadBps = (rawValue?: string): bigint => + parsePositiveU64(rawValue ?? DEFAULT_BASE_SPREAD_BPS, "Base spread bps") + +const resolveVolatilityMultiplierBps = (rawValue?: string): bigint => + parseNonNegativeU64( + rawValue ?? DEFAULT_VOLATILITY_MULTIPLIER_BPS, + "Volatility multiplier bps" + ) + +const resolveUseLaserFlag = (rawValue?: boolean): boolean => rawValue ?? false + +export const resolveAmmConfigInputs = async ({ + volatilityMultiplierBps, + baseSpreadBps, + useLaser, + pythPriceFeedIdHex +}: { + volatilityMultiplierBps?: string + baseSpreadBps?: string + useLaser?: boolean + pythPriceFeedIdHex: string +}): Promise<{ + baseSpreadBps: bigint + volatilityMultiplierBps: bigint + useLaser: boolean + pythPriceFeedIdHex: string + pythPriceFeedIdBytes: number[] +}> => ({ + baseSpreadBps: resolveBaseSpreadBps(baseSpreadBps), + volatilityMultiplierBps: resolveVolatilityMultiplierBps( + volatilityMultiplierBps + ), + useLaser: resolveUseLaserFlag(useLaser), + pythPriceFeedIdHex, + pythPriceFeedIdBytes: parsePythPriceFeedIdBytes(pythPriceFeedIdHex) +}) diff --git a/packages/domain/core/src/models/pyth.ts b/packages/domain/core/src/models/pyth.ts new file mode 100644 index 0000000..940a3d6 --- /dev/null +++ b/packages/domain/core/src/models/pyth.ts @@ -0,0 +1,102 @@ +import type { Transaction, TransactionArgument } from "@mysten/sui/transactions" +import { normalizeSuiObjectId } from "@mysten/sui/utils" + +import { SUI_CLOCK_ID } from "@sui-amm/tooling-core/constants" +import { + assertBytesLength, + hexToBytes, + normalizeHex +} from "@sui-amm/tooling-core/hex" + +export type MockPriceFeedConfig = { + feedIdHex: string + price: bigint + confidence: bigint + exponent: number +} + +export type LabeledMockPriceFeedConfig = MockPriceFeedConfig & { + label: string +} + +export const DEFAULT_MOCK_PRICE_FEED: LabeledMockPriceFeedConfig = { + label: "MOCK_SUI_FEED", + feedIdHex: + "0x202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + // Approx SUI/USD. $1.84 with exponent -2. + price: 184n, + confidence: 2n, + exponent: -2 +} + +type MockFeedMatcher = { + feedIdHex?: string + label?: string +} + +export const isMatchingMockPriceFeedConfig = ( + config: LabeledMockPriceFeedConfig, + candidate: MockFeedMatcher +) => { + const feedIdMatch = candidate.feedIdHex + ? normalizeHex(candidate.feedIdHex) === normalizeHex(config.feedIdHex) + : false + + const labelMatch = candidate.label ? candidate.label === config.label : false + + return feedIdMatch || labelMatch +} + +export const findMockPriceFeedConfig = ( + candidate: MockFeedMatcher, + configs: LabeledMockPriceFeedConfig[] = [DEFAULT_MOCK_PRICE_FEED] +) => configs.find((config) => isMatchingMockPriceFeedConfig(config, candidate)) + +const PYTH_PRICE_INFO_TYPE = "price_info::PriceInfoObject" + +export const getPythPriceInfoType = (pythPackageId: string) => + `${normalizeSuiObjectId(pythPackageId)}::${PYTH_PRICE_INFO_TYPE}` + +export const deriveMockPriceComponents = (config: MockPriceFeedConfig) => { + const priceMagnitude = config.price >= 0n ? config.price : -config.price + const priceIsNegative = config.price < 0n + const exponentMagnitude = + config.exponent >= 0 ? config.exponent : -config.exponent + const exponentIsNegative = config.exponent < 0 + + return { + priceMagnitude, + priceIsNegative, + exponentMagnitude, + exponentIsNegative + } +} + +export const publishMockPriceFeed = ( + transaction: Transaction, + pythPackageId: string, + config: MockPriceFeedConfig, + clockObject?: TransactionArgument +) => { + const feedIdBytes = assertBytesLength(hexToBytes(config.feedIdHex), 32) + const { + priceMagnitude, + priceIsNegative, + exponentMagnitude, + exponentIsNegative + } = deriveMockPriceComponents(config) + + return transaction.moveCall({ + target: `${pythPackageId}::price_info::publish_price_feed`, + arguments: [ + // BCS-encode as vector; passing raw bytes would skip the length prefix and fail deserialization. + transaction.pure.vector("u8", feedIdBytes), + transaction.pure.u64(priceMagnitude), + transaction.pure.bool(priceIsNegative), + transaction.pure.u64(config.confidence), + transaction.pure.u64(exponentMagnitude), + transaction.pure.bool(exponentIsNegative), + clockObject ?? transaction.object(SUI_CLOCK_ID) + ] + }) +} diff --git a/packages/domain/core/src/ptb/amm.ts b/packages/domain/core/src/ptb/amm.ts new file mode 100644 index 0000000..2254450 --- /dev/null +++ b/packages/domain/core/src/ptb/amm.ts @@ -0,0 +1,91 @@ +import { assertBytesLength, hexToBytes } from "@sui-amm/tooling-core/hex" +import type { WrappedSuiSharedObject } from "@sui-amm/tooling-core/shared-object" +import { newTransaction } from "@sui-amm/tooling-core/transactions" +import { validateRequiredHexBytes } from "@sui-amm/tooling-core/utils/validation" + +const PYTH_PRICE_FEED_ID_BYTES = 32 + +export const parsePythPriceFeedIdBytes = ( + pythPriceFeedIdHex: string +): number[] => { + const trimmed = pythPriceFeedIdHex.trim() + const validationError = validateRequiredHexBytes({ + value: trimmed, + expectedBytes: PYTH_PRICE_FEED_ID_BYTES, + label: "Pyth price feed id" + }) + if (validationError) throw new Error(validationError) + + return assertBytesLength(hexToBytes(trimmed), PYTH_PRICE_FEED_ID_BYTES) +} + +export const buildCreateAmmConfigTransaction = ({ + packageId, + baseSpreadBps, + volatilityMultiplierBps, + useLaser, + pythPriceFeedIdBytes +}: { + packageId: string + baseSpreadBps: bigint | number + volatilityMultiplierBps: bigint | number + useLaser: boolean + pythPriceFeedIdBytes: number[] +}) => { + const transaction = newTransaction() + + const config = transaction.moveCall({ + target: `${packageId}::manager::create_amm_config`, + arguments: [ + transaction.pure.u64(baseSpreadBps), + transaction.pure.u64(volatilityMultiplierBps), + transaction.pure.bool(useLaser), + transaction.pure.vector("u8", pythPriceFeedIdBytes) + ] + }) + + transaction.moveCall({ + target: `${packageId}::manager::share_amm_config`, + arguments: [config] + }) + + return transaction +} + +export const buildUpdateAmmConfigTransaction = ({ + packageId, + adminCapId, + config, + baseSpreadBps, + volatilityMultiplierBps, + useLaser, + tradingPaused, + pythPriceFeedIdBytes +}: { + packageId: string + adminCapId: string + config: WrappedSuiSharedObject + baseSpreadBps: bigint | number + volatilityMultiplierBps: bigint | number + useLaser: boolean + tradingPaused: boolean + pythPriceFeedIdBytes: number[] +}) => { + const transaction = newTransaction() + const configArgument = transaction.sharedObjectRef(config.sharedRef) + + transaction.moveCall({ + target: `${packageId}::manager::update_amm_config`, + arguments: [ + configArgument, + transaction.object(adminCapId), + transaction.pure.u64(baseSpreadBps), + transaction.pure.u64(volatilityMultiplierBps), + transaction.pure.bool(useLaser), + transaction.pure.bool(tradingPaused), + transaction.pure.vector("u8", pythPriceFeedIdBytes) + ] + }) + + return transaction +} diff --git a/packages/domain/node/src/amm.ts b/packages/domain/node/src/amm.ts new file mode 100644 index 0000000..e9785d7 --- /dev/null +++ b/packages/domain/node/src/amm.ts @@ -0,0 +1,96 @@ +import type { AmmConfigOverview } from "@sui-amm/domain-core/models/amm" +import { + AMM_ADMIN_CAP_TYPE_SUFFIX, + AMM_CONFIG_TYPE_SUFFIX, + getAmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { normalizeIdOrThrow } from "@sui-amm/tooling-core/object" +import type { PublishArtifact } from "@sui-amm/tooling-core/types" +import { + findLatestArtifactThat, + getLatestObjectFromArtifact, + isPublishArtifactNamed, + loadDeploymentArtifacts +} from "@sui-amm/tooling-node/artifacts" +import type { Tooling } from "@sui-amm/tooling-node/factory" + +const AMM_PACKAGE_NAME = "amm" + +export const isAmmPublishArtifact = (artifact: PublishArtifact) => + isPublishArtifactNamed(AMM_PACKAGE_NAME)(artifact) + +export const resolveAmmPackageId = async ({ + networkName, + ammPackageId +}: { + networkName: string + ammPackageId?: string +}): Promise => { + const deploymentArtifacts = await loadDeploymentArtifacts(networkName) + const latestAmmPublishArtifact = findLatestArtifactThat( + isAmmPublishArtifact, + deploymentArtifacts + ) + + return normalizeIdOrThrow( + ammPackageId ?? latestAmmPublishArtifact?.packageId, + "An AMM package id is required; publish the package or provide --amm-package-id." + ) +} + +export const resolveAmmConfigId = async ({ + networkName, + ammConfigId +}: { + networkName: string + ammConfigId?: string +}): Promise => { + const latestConfigArtifact = await getLatestObjectFromArtifact( + AMM_CONFIG_TYPE_SUFFIX + )(networkName) + + return normalizeIdOrThrow( + ammConfigId ?? latestConfigArtifact?.objectId, + "An AMM config id is required; create an AMM config first or provide --amm-config-id." + ) +} + +export const resolveAmmAdminCapId = async ({ + networkName, + adminCapId +}: { + networkName: string + adminCapId?: string +}): Promise => { + const latestAdminCapArtifact = await getLatestObjectFromArtifact( + AMM_ADMIN_CAP_TYPE_SUFFIX + )(networkName) + + return normalizeIdOrThrow( + adminCapId ?? latestAdminCapArtifact?.objectId, + "An AMM admin cap id is required; publish the package or provide --admin-cap-id." + ) +} + +export type AmmConfigSnapshot = { + ammConfigOverview: AmmConfigOverview + initialSharedVersion: string +} + +export const collectAmmConfigSnapshot = async ({ + tooling, + ammConfigId +}: { + tooling: Pick + ammConfigId: string +}): Promise => { + const [ammConfigOverview, sharedObject] = await Promise.all([ + getAmmConfigOverview(ammConfigId, tooling.suiClient), + tooling.getImmutableSharedObject({ objectId: ammConfigId }) + ]) + + return { + ammConfigOverview, + initialSharedVersion: sharedObject.sharedRef.initialSharedVersion + } +} diff --git a/packages/domain/node/src/index.ts b/packages/domain/node/src/index.ts new file mode 100644 index 0000000..f413bd8 --- /dev/null +++ b/packages/domain/node/src/index.ts @@ -0,0 +1,2 @@ +// Placeholder entrypoint until domain-node modules are introduced. +export {} diff --git a/packages/tooling/README.md b/packages/tooling/README.md new file mode 100644 index 0000000..f346e11 --- /dev/null +++ b/packages/tooling/README.md @@ -0,0 +1,545 @@ +# Tooling package + +This package provides shared tooling for Sui Move development in this repository. It includes: + +- **tooling-core**: environment-agnostic helpers (browser + Node) for building transactions, reading on-chain objects, and normalizing Sui data. +- **tooling-node**: Node-only helpers for Move builds, publishing, localnet orchestration, artifacts, and script execution. + +The tooling is designed for: + +- publishing Move packages with artifact capture across networks +- managing deployment/object changes with artifacts across networks +- executing scripts with a consistent CLI environment +- integrating with by environment flows in CI or integration tests + +--- + +## Package layout + +``` +packages/tooling/ + core/ (Node + browser) + node/ (Node only) +``` + +### When to use which package + +- Use **tooling-core** in any shared logic, UI-safe utilities, or libraries that must run in both browser and Node environments. +- Use **tooling-node** for scripting, filesystem access, running the Sui CLI, Move package builds, publishing, and test harness utilities. + +--- + +## Installation and usage + +This repository is a PNPM workspace. Import from the workspace package names directly: + +```ts +import { newTransaction } from "_root_package__/tooling-core/transactions" +import { publishPackageWithLog } from "_root_package__/tooling-node/publish" +``` + +--- + +## Configuration + +Tooling reads configuration from `sui.config.*` in the current working directory and merges environment overrides. + +### Config file shape + +```ts +import { defineSuiConfig } from "_root_package__/tooling-node/config" + +export default defineSuiConfig({ + defaultNetwork: "localnet", + networks: { + localnet: { + url: "http://127.0.0.1:9000", + gasBudget: 100000000, + account: { + // see account config below + } + } + }, + paths: { + move: "contracts", + deployments: "deployments", + objects: "deployments", + artifacts: "deployments" + } +}) +``` + +### Environment overrides + +- `SUI_CONFIG_PATH`: path to an explicit config file +- `SUI_NETWORK`: override the selected network +- `SUI_RPC_URL` or `SUI_NETWORK_URL`: override the RPC endpoint +- `SUI_KEYSTORE_PATH`: override keystore location +- `SUI_ACCOUNT_INDEX`: keystore account index to use +- `SUI_ACCOUNT_ADDRESS`: explicit account address +- `SUI_ACCOUNT_PRIVATE_KEY`: bech32 or base64 private key +- `SUI_ACCOUNT_MNEMONIC`: BIP-39 mnemonic +- `SUI_ARTIFACTS_DIR`: where artifacts are written (default: `deployments/`) +- `SUI_CONFIG_DIR` / `SUI_LOCALNET_CONFIG_DIR`: localnet config directory +- `SUI_SKIP_MOVE_CHAIN_ID_SYNC`: skip Move.toml test-publish environment sync for localnet + +### Account config + +`SuiAccountConfig` allows the following sources for signer material: + +- keystore (`keystorePath` + `accountIndex` or `accountAddress`) +- `accountPrivateKey` (bech32 or base64) +- `accountMnemonic` + +The tooling resolves the effective signer in the following order: +1. explicit private key +2. mnemonic +3. keystore entry by address +4. keystore entry by index + +--- + +## Core concepts + +### Tooling context + +Most APIs accept a context object to avoid global state: + +- `ToolingCoreContext` (core) contains a `suiClient` and optional `networkName`/`rpcUrl`. +- `ToolingContext` (node) extends the above with `suiConfig` and a `SuiClient` instance. + +`createTooling(...)` binds the context into a `Tooling` facade with helper methods so scripts can be written as “one-liners” without re-plumbing dependencies. + +### Artifacts + +The tooling persists artifacts to JSON files for reuse across scripts and tests: + +- `deployment..json` → package IDs, UpgradeCaps, dependencies +- `objects..json` → created or updated objects from transactions + +Artifacts are merged/deduped by `objectId`/`packageId` where possible. The root directory is resolved using: + +1. `withArtifactsRoot(artifactsDir, ...)` when explicitly scoped +2. `SUI_ARTIFACTS_DIR` if set +3. `deployments/` in the current working directory (default) + +### Move environment chain ID sync + +For localnet, the tooling keeps `Move.toml` test-publish environments aligned to the current chain ID. This prevents `dep-replacements` drift or publish failures when localnet is reset. + +- `syncLocalnetMoveEnvironmentChainId(...)` updates `Move.toml` if needed. +- `SUI_SKIP_MOVE_CHAIN_ID_SYNC=1` disables this behavior (useful in test harnesses). + +### Publishing + +Publishing is a multi-step flow: + +1. `buildMovePackage(...)` compiles modules and resolves dependency addresses +2. `publishPackage(...)` executes the publish transaction +3. artifacts are persisted with module bytecode, dependencies, and explorer links + +The publish flow enforces: + +- **unpublished dependencies are allowed only on localnet** +- **Move.toml environments are the source of dependency linkage** for shared networks +- **automatic CLI fallback** when SDK publish exceeds size limits + +### Transaction execution and object artifacts + +`signAndExecute(...)` wraps Sui execution with: + +- explicit gas budget enforcement +- fresh gas coin selection (avoid stale object errors) +- one retry for stale/locked gas objects +- object artifact persistence based on `objectChanges` + +`executeTransactionWithSummary(...)` adds a readable summary (gas + object changes). + +--- + +## Common workflows + +### Execute a script with a standard CLI flow + +```ts +import yargs from "yargs" +import { runSuiScript } from "_root_package__/tooling-node/process" + +runSuiScript(async (tooling, args) => { + // use tooling.signAndExecute, publishPackageWithLog, etc +}, yargs.option("json", { type: "boolean" })) +``` + +### Load the most recent deployment artifact + +```ts +import { loadDeploymentArtifacts, getLatestArtifact } from "_root_package__/tooling-node/artifacts" + +const artifacts = await loadDeploymentArtifacts("testnet") +const latest = getLatestArtifact(artifacts) +``` + +### Run transactions with summaries + +```ts +import { newTransaction } from "_root_package__/tooling-core/transactions" +import { executeTransactionWithSummary } from "_root_package__/tooling-node/transactions-execution" + +const tx = newTransaction() +// build PTB... +const result = await executeTransactionWithSummary({ + transaction: tx, + signer: tooling.loadedEd25519KeyPair, + summaryLabel: "Create pool" +}, tooling) +``` + +--- + +## Integration testing with tooling-node/testing + +The testing helpers in `packages/tooling/node/src/testing` provide a consistent localnet harness, per-test isolation, and script execution utilities. They are designed to make integration tests deterministic and easy to reason about by: + +- creating isolated localnet instances (or a shared suite instance) +- providing a `TestContext` with ready-to-use helpers +- wiring scripts to a controlled config and artifacts directory + +### Key entry points + +- `createSuiLocalnetTestEnv(...)` — high-level localnet environment factory +- `withTestContext(...)` / `createTestContext(...)` — per-test context with cleanup +- `createSuiScriptRunner(context)` — run TS scripts with a scoped config +- `parseJsonFromScriptOutput(stdout)` — parse JSON output from scripts + +### Typical setup (Vitest) + +```ts +import { describe, it, expect } from "vitest" +import { createSuiLocalnetTestEnv } from "_root_package__/tooling-node/testing/env" + +const testEnv = createSuiLocalnetTestEnv({ + mode: "test", // or "suite" to reuse one localnet per test file + withFaucet: true, + keepTemp: false, + moveSourceRootPath: "path/to/contracts/sources" +}) + +describe("integration flow", () => { + it("builds and publishes a Move package", async () => { + await testEnv.withTestContext("publish-simple-contract", async (context) => { + const publisher = context.createAccount("publisher") + await context.fundAccount(publisher, { minimumCoinObjects: 2 }) + + const buildOutput = await context.buildMovePackage("simple-contract") + expect(buildOutput.modules.length).toBeGreaterThan(0) + + const artifacts = await context.publishPackage( + "simple-contract", + publisher, + { withUnpublishedDependencies: true } + ) + + expect(artifacts.length).toBeGreaterThan(0) + }) + }) +}) +``` + +### Running scripts inside tests + +Use `createSuiScriptRunner(...)` to execute scripts from the `packages/dapp/src/scripts` tree against the localnet instance. The runner injects a temporary config file, RPC URL, and artifacts directory, so scripts behave the same way as when run manually. + +```ts +import { describe, it, expect } from "vitest" +import { pickRootNonDependencyArtifact } from "_root_package__/tooling-node/artifacts" +import { + createSuiScriptRunner, + parseJsonFromScriptOutput +} from "_root_package__/tooling-node/testing/scripts" +import { createSuiLocalnetTestEnv } from "_root_package__/tooling-node/testing/env" + +const testEnv = createSuiLocalnetTestEnv({ mode: "test", withFaucet: true }) + +describe("owner scripts", () => { + it("runs amm-create and parses JSON output", async () => { + await testEnv.withTestContext("owner-amm-create", async (context) => { + const publisher = context.createAccount("publisher") + await context.fundAccount(publisher, { minimumCoinObjects: 2 }) + + const artifacts = await context.publishPackage( + "pro-amm", + publisher, + { withUnpublishedDependencies: true } + ) + const rootArtifact = pickRootNonDependencyArtifact(artifacts) + + const scriptRunner = createSuiScriptRunner(context) + const result = await scriptRunner.runOwnerScript("amm-create", { + account: publisher, + args: { + json: true, + ammPackageId: rootArtifact.packageId, + pythPriceFeedLabel: "MOCK_SUI_FEED" + } + }) + + expect(result.exitCode).toBe(0) + const parsed = parseJsonFromScriptOutput<{ ammConfig?: { configId?: string } }>( + result.stdout, + "amm-create output" + ) + expect(parsed.ammConfig?.configId).toBeTruthy() + }) + }) +}) +``` + +### What `TestContext` gives you + +The context created by `withTestContext(...)` exposes helpers that simplify tests: + +- `createAccount(label)` — create deterministic test accounts +- `fundAccount(account, options)` — request faucet funds or transfer from treasury +- `buildMovePackage(relativePath)` — build Move packages inside the test move root +- `publishPackage(relativePath, account, options)` — publish and record artifacts +- `signAndExecuteTransaction(tx, account, options)` — run a transaction block +- `waitForFinality(digest, options)` — await checkpoint finality +- `queryEventsByTransaction(digest)` / `queryEventsByType(eventType)` — event lookup + +### Recommended patterns + +- Use `mode: "suite"` when you want to reuse one localnet across many tests in the same file. +- Use `mode: "test"` (default) for maximum isolation and clean artifact directories. +- Prefer `withTestContext(...)` to guarantee cleanup even when tests fail. +- Always pass `withUnpublishedDependencies: true` when publishing localnet-only packages. + +--- + +# API reference + +This section documents the public API surface by module. Function signatures are simplified to keep the docs readable; refer to the TypeScript definitions for exact types. + +## tooling-core + +### address + +- `parseAddressList({ rawAddresses, label }): string[]` — parse and normalize comma-delimited addresses. +- `getSuiBalance({ address }, context): Promise` — fetch total SUI balance. +- `getCoinBalanceSummary({ address, coinType }, context): Promise` — get balance for a specific coin type. +- `getCoinBalances({ address }, context): Promise` — get all coin balances for an address. +- `asMinimumBalanceOf({ address, minimumBalance }, context): Promise` — check minimum SUI balance. + +### coin + +- `buildCoinTransferTransaction({ coinObjectId, amount, recipientAddress }): Transaction` — build a PTB that splits a coin and transfers the split. +- `normalizeCoinType(coinType): string` — normalize `Coin` type names. +- `resolveCoinOwnership({ coinObjectId }, context): Promise` — resolve coin type and owner. +- `planSuiPaymentSplitTransaction(...)` — determine if a split is needed to pay and cover gas; may return a split transaction. + +### coin-registry + +- `deriveCurrencyObjectId(coinType, registryId): string` — deterministic registry object ID. +- `listCurrencyRegistryEntries({ registryId, includeMetadata, chunkSize }, context): Promise` +- `resolveCurrencyObjectId({ coinType, registryId, fallbackRegistryScan }, context): Promise` + +### context + +- `createToolingCoreContext(context): ToolingCoreContext` — identity helper for context creation. + +### dynamic-fields + +- `getAllDynamicFields({ parentObjectId, objectTypeFilter }, context): Promise` +- `getAllDynamicFieldObjects({ parentObjectId, objectTypeFilter }, context): Promise` +- `getSuiDynamicFieldObject({ childObjectId, parentObjectId }, context): Promise` +- `getObjectWithDynamicFieldFallback({ objectId, parentObjectId, options }, context): Promise` +- `getObjectIdFromDynamicFieldObject(object): string | undefined` +- `isDynamicFieldObject(objectType?): boolean` + +### network + +- `resolveCommonRpcUrl(network): string | undefined` +- `resolveRpcUrl(network, override?): string` +- `buildExplorerUrl(digest, network): string` +- `assertLocalnetNetwork(networkName): void` + +### object + +- `getSuiObject({ objectId, options }, context): Promise<{ object, owner?, error? }>` +- `getAllOwnedObjectsByFilter({ ownerAddress, filter, options }, context): Promise` +- `buildSuiObjectRef(object): SuiObjectRef` +- `unwrapMoveObjectFields(object): TFields` — extract Move fields from `moveObject` content. +- `normalizeObjectArtifact(artifact): ObjectArtifact` +- `deriveRelevantPackageId(typeTag): string` +- `normalizeIdOrThrow(id, errorMessage): string` +- `normalizeOptionalIdFromValue(value): string | undefined` + +### shared-object + +- `extractInitialSharedVersion(created): string | undefined` +- `getSuiSharedObject({ objectId, mutable }, context): Promise` + +### transactions + +- `newTransaction(gasBudget?): Transaction` — create a PTB. +- `resolveSplitCoinResult(splitResult, index): TransactionObjectArgument` +- `assertTransactionSuccess(result): void` +- `isStaleObjectVersionError(error): boolean` +- `findCreatedObjectIds(result, typeSuffix): string[]` +- `findCreatedByType(result, matcher): string[]` +- `findObjectMatching(result, matcher): SuiObjectChange | undefined` +- `findCreatedObjectBySuffix(result, typeSuffix): SuiObjectChange | undefined` +- `ensureCreatedObject(objectToFind, result): SuiObjectChangeCreated` +- `summarizeObjectChanges(objectChanges): ObjectChangeDetail[]` +- `summarizeGasUsed(gasUsed): GasSummary | undefined` + +### types + +- `BuildOutput` — compiled module bytecode + dependency IDs. +- `PublishResult`, `PublishArtifact`, `PublishedPackage` — publish outputs and artifact shape. +- `NetworkName`, `ENetwork` — network identifiers. + +--- + +## tooling-node + +### account + +- `resolveOwnerAddress(providedAddress, networkConfig): Promise` — resolve address from input/config/keystore. + +### artifacts + +- `withArtifactsRoot(artifactsDir, action): Promise` — scope artifact directory for nested operations. +- `writeDeploymentArtifact(filePath, artifacts): Promise` +- `writeObjectArtifact(filePath, artifacts): Promise` +- `readArtifact(filePath, default?): Promise` +- `loadDeploymentArtifacts(networkName): Promise` +- `loadObjectArtifacts(networkName): Promise` +- `getDeploymentArtifactPath(networkName): string` +- `getObjectArtifactPath(networkName): string` +- `getLatestArtifact(artifacts): T | undefined` +- `getLatestDeploymentFromArtifact(packageName)(networkName): Promise` +- `getLatestObjectFromArtifact(typeSuffix)(networkName): Promise` + +### config + +- `defineSuiConfig(config): config` — typed config helper. +- `loadSuiConfig(): Promise` — load/merge config + env overrides. +- `getNetworkConfig(networkName, config): SuiNetworkConfig` +- `getAccountConfig(networkConfig, accountName?): SuiAccountConfig` + +### dev-inspect + +- `maybeLogDevInspect({ transaction, enabled, senderAddress }, toolingContext)` — run `devInspectTransactionBlock` and log details. + +### factory + +- `createTooling({ suiClient, suiConfig }): Promise` — creates a script-friendly facade. + +### json + +- `emitJsonOutput(payload, enabled?): boolean` — optional JSON output +- `parseJsonFromOutput(output): T | undefined` +- `collectJsonCandidates(output): string[]` + +### localnet + +- `resolveLocalnetConfigDir(candidate?): string` +- `deriveFaucetUrl(rpcUrl): string` +- `probeRpcHealth(rpcUrl): Promise` +- `getRpcSnapshot(rpcUrl): Promise` + +### move + +- `buildMovePackage(packagePath, buildArgs?, { stripTestModules? }): Promise` +- `runMoveBuild(args, options?): Promise<{ stdout, stderr, exitCode }>` +- `buildMoveTestArguments({ packagePath, environmentName? }): string[]` +- `buildMoveTestPublishArguments({ packagePath, buildEnvironmentName?, publicationFilePath?, withUnpublishedDependencies? }): string[]` +- `runMoveTest(args, options?): Promise<{ stdout, stderr, exitCode }>` +- `runClientTestPublish(args, options?): Promise<{ stdout, stderr, exitCode }>` +- `syncLocalnetMoveEnvironmentChainId({ moveRootPath, environmentName, dryRun? }, toolingContext): Promise` +- `clearPublishedEntryForNetwork({ packagePath, networkName }): Promise<{ didUpdate: boolean }>` + +### move-lock + +- `extractSuiFrameworkRevisionsFromMoveLock({ lockContents, environmentName? }): Set` +- `extractSuiFrameworkPinnedEntriesFromMoveLock({ lockContents, environmentName? }): SuiFrameworkPinnedEntry[]` +- `extractSingleSuiFrameworkRevisionFromMoveLock({ lockContents, environmentName? }): string | undefined` + +### publish + +- `publishPackageWithLog({ packagePath, keypair, gasBudget?, withUnpublishedDependencies?, useCliPublish?, allowAutoUnpublishedDependencies? }, context): Promise` +- `publishPackage(publishPlan, context): Promise` +- `doPublishPackage(publishPlan, buildOutput, context): Promise` +- `runClientPublish(args, options?): Promise<{ stdout, stderr, exitCode }>` +- `publishMovePackageWithFunding({ packagePath, gasBudget?, withUnpublishedDependencies?, allowAutoUnpublishedDependencies?, useCliPublish?, clearPublishedEntry? }, context): Promise` + +### process + +- `addBaseOptions(scriptName, yargs): Promise` — add `--network` and parse args. +- `runSuiScript(script, yargs?)` — standard CLI runner with logging, Sui CLI checks, and network selection. + +### suiCli + +- `ensureSuiCli(): Promise` +- `runSuiCli(baseArgs)(args, options?): Promise<{ stdout, stderr, exitCode }>` +- `getSuiCliVersion(): Promise` +- `getActiveSuiCliEnvironment(): Promise` +- `listSuiCliEnvironments(): Promise` +- `getSuiCliEnvironmentChainId(environmentName?): Promise` +- `getSuiCliEnvironmentRpc(environmentName?): Promise` + +### sui-client + +- `createSuiClient(rpcUrl): SuiClient` + +### transactions + +- `signAndExecute({ transaction, signer, requestType?, retryOnGasStale?, assertSuccess? }, context): Promise<{ transactionResult, objectArtifacts }>` +- `executeTransactionOnce({ transaction, signer, requestType, assertSuccess }, context)` +- `findCreatedArtifactBySuffix(createdArtifacts, suffix): ObjectArtifact | undefined` +- `findCreatedArtifactIdBySuffix(createdArtifacts, suffix): string | undefined` +- `requireCreatedArtifactIdBySuffix({ createdArtifacts, suffix, label }): string` + +### transactions-execution + +- `executeTransactionWithSummary({ transaction, signer, summaryLabel?, devInspect?, dryRun?, senderAddress? }, context)` + +### transactions-summary + +- `buildTransactionSummary(result, label?): TransactionSummary` +- `formatTransactionSummary(summary): string` +- `formatTransactionResult(result, label?): string` +- `resolveTransactionDigest(result): string | undefined` +- `requireTransactionDigest(result, label?): string` + +### testing (node-only) + +High-level helpers for localnet + scripts. These are used by the integration test harness and are safe to reuse: + +- `createSuiLocalnetTestEnv(options?)` — start a localnet in suite or per-test mode +- `createSuiScriptRunner(context)` — run TS scripts against a localnet test context +- `parseJsonFromScriptOutput(stdout)` — parse JSON from script output +- `createLocalnetHarness()` / `createTestContext(...)` — lower-level localnet management +- `runOwnerScript(...)` / `runBuyerScript(...)` — run dapp scripts in tests +- `parseJsonFromScriptOutput(stdout)` — parse JSON from script output + +--- + +## How the publishing flow works + +1. **Build** — `buildMovePackage` calls the Sui CLI to compile the package. It parses JSON output or reads `build/` artifacts as a fallback and strips test modules when requested. +2. **Plan** — `publishPackageWithLog` creates a `PublishPlan` that decides whether unpublished dependencies are allowed (localnet only). +3. **Publish** — `publishPackage` uses SDK publish by default, falling back to CLI publish on transaction size errors. +4. **Persist** — deployment artifacts are stored under `deployments/deployment..json`. +5. **Log** — publish output includes explorer links and metadata like `suiCliVersion`. + +--- + +## Error handling conventions + +- Methods throw `Error` with context-rich messages. +- JSON parsing helpers tolerate noisy CLI output and scan for trailing JSON blocks. +- Transaction helpers normalize ID/owner formats to avoid cross-run diffs. + +--- + diff --git a/packages/tooling/core/src/constants.ts b/packages/tooling/core/src/constants.ts index 3e438ba..2736ce8 100644 --- a/packages/tooling/core/src/constants.ts +++ b/packages/tooling/core/src/constants.ts @@ -5,7 +5,7 @@ export const MINIMUM_GAS_COIN_BALANCE = ONE_SUI export const MINIMUM_GAS_COIN_OBJECTS = 3 export const MINIMUM_ACCOUNT_BALANCE = MINIMUM_GAS_COIN_BALANCE * 5n export const DEFAULT_TX_GAS_BUDGET = 100_000_000 -export const DEFAULT_PUBLISH_GAS_BUDGET = 500_000_000 +export const DEFAULT_PUBLISH_GAS_BUDGET = 700_000_000 export const SUI_CLOCK_ID = normalizeSuiObjectId( "0x0000000000000000000000000000000000000000000000000000000000000006" diff --git a/packages/tooling/node/package.json b/packages/tooling/node/package.json index 6b4bc80..7d8429a 100644 --- a/packages/tooling/node/package.json +++ b/packages/tooling/node/package.json @@ -24,6 +24,7 @@ "./testing/scripts": "./src/testing/scripts.ts", "./testing/objects": "./src/testing/objects.ts", "./testing/observability": "./src/testing/observability.ts", + "./testing/paths": "./src/testing/paths.ts", "./testing/vitest-plugin": "./src/testing/vitest-plugin.ts", "./move": "./src/move.ts", "./move-lock": "./src/move-lock.ts", @@ -45,4 +46,4 @@ "@types/node": "^22.19.2", "@types/yargs": "^17.0.35" } -} \ No newline at end of file +} diff --git a/packages/tooling/node/src/artifacts.ts b/packages/tooling/node/src/artifacts.ts index 6e172de..7fb447e 100644 --- a/packages/tooling/node/src/artifacts.ts +++ b/packages/tooling/node/src/artifacts.ts @@ -153,6 +153,7 @@ export const readArtifact = async ( */ const resolveArtifactsRoot = () => { const scoped = artifactsRootStore.getStore() + if (scoped) return scoped const override = process.env.SUI_ARTIFACTS_DIR?.trim() @@ -265,7 +266,7 @@ export const isPublishArtifactNamed = (artifactName: string) => (artifact: PublishArtifact): boolean => { const normalizedPackageName = artifact.packageName?.trim().toLowerCase() - if (normalizedPackageName === artifactName) return true + if (normalizedPackageName === artifactName.toLowerCase()) return true return false } diff --git a/packages/tooling/node/src/config.ts b/packages/tooling/node/src/config.ts index ddadb76..8c4cac2 100644 --- a/packages/tooling/node/src/config.ts +++ b/packages/tooling/node/src/config.ts @@ -80,7 +80,7 @@ const resolveRpcUrlOverride = () => const resolvePaths = ( pathsConfig?: SuiPathsUserConfig ): Required => ({ - move: path.resolve(process.cwd(), pathsConfig?.move ?? "move"), + move: path.resolve(process.cwd(), pathsConfig?.move ?? "contracts"), deployments: path.resolve( process.cwd(), pathsConfig?.deployments ?? "deployments" diff --git a/packages/tooling/node/src/move-toml.ts b/packages/tooling/node/src/move-toml.ts index 34819f2..f01ae1d 100644 --- a/packages/tooling/node/src/move-toml.ts +++ b/packages/tooling/node/src/move-toml.ts @@ -4,6 +4,7 @@ import path from "node:path" import { formatErrorMessage } from "@sui-amm/tooling-core/utils/errors" import type { ToolingContext } from "./factory.ts" import { logWarning } from "./log.ts" +import { resolveMoveCliEnvironmentName } from "./move.ts" import { getSuiCliEnvironmentChainId } from "./suiCli.ts" import { isErrnoWithCode } from "./utils/fs.ts" import { escapeRegExp } from "./utils/regex.ts" @@ -309,9 +310,13 @@ export const syncLocalnetMoveEnvironmentChainId = async ( if (!chainId) return { updatedFiles: [], chainId, didAttempt: true } + const resolvedEnvironmentName = resolveMoveCliEnvironmentName(environmentName) + if (!resolvedEnvironmentName) + return { updatedFiles: [], chainId, didAttempt: true } + const { updatedFiles } = await syncMoveEnvironmentChainId({ moveRootPath, - environmentName, + environmentName: resolvedEnvironmentName, chainId, dryRun }) diff --git a/packages/tooling/node/src/move.ts b/packages/tooling/node/src/move.ts index 22b1f77..53ebc8a 100644 --- a/packages/tooling/node/src/move.ts +++ b/packages/tooling/node/src/move.ts @@ -62,13 +62,26 @@ export type MoveTestPublishOptions = { withUnpublishedDependencies?: boolean } +/** + * Normalizes Move CLI environment names when they differ from Sui network names. + */ +export const resolveMoveCliEnvironmentName = ( + environmentName?: string +): string | undefined => + environmentName === "localnet" ? "test-publish" : environmentName + /** * Builds CLI flags for Move commands that accept an environment. */ export const buildMoveEnvironmentFlags = ({ environmentName -}: MoveEnvironmentOptions): string[] => - environmentName ? ["--environment", environmentName] : [] +}: MoveEnvironmentOptions): string[] => { + const resolvedEnvironmentName = resolveMoveCliEnvironmentName(environmentName) + + return resolvedEnvironmentName + ? ["--environment", resolvedEnvironmentName] + : [] +} /** * Builds CLI flags for `sui move test`. @@ -97,7 +110,11 @@ const buildMoveTestPublishFlags = ({ }: MoveTestPublishOptions): string[] => { const flags: string[] = [] - if (buildEnvironmentName) flags.push("--build-env", buildEnvironmentName) + const resolvedBuildEnvironmentName = + resolveMoveCliEnvironmentName(buildEnvironmentName) + if (resolvedBuildEnvironmentName) { + flags.push("--build-env", resolvedBuildEnvironmentName) + } if (publicationFilePath) flags.push("--pubfile-path", publicationFilePath) if (withUnpublishedDependencies) flags.push("--with-unpublished-dependencies") diff --git a/packages/tooling/node/src/process.ts b/packages/tooling/node/src/process.ts index cad699d..afd96e0 100644 --- a/packages/tooling/node/src/process.ts +++ b/packages/tooling/node/src/process.ts @@ -276,13 +276,13 @@ const syncLocalnetMoveEnvironmentChainIdForTooling = async ( if (didAttempt && !chainId) { logWarning( - "Unable to resolve localnet chain id; Move.toml environments were not updated." + "Unable to resolve localnet chain id; Move.toml test-publish environments were not updated." ) } if (updatedFiles.length) { logKeyValueBlue("Move.toml")( - `updated ${updatedFiles.length} localnet environment entries` + `updated ${updatedFiles.length} test-publish environment entries` ) } } diff --git a/packages/tooling/node/src/publish.ts b/packages/tooling/node/src/publish.ts index e56d40d..677338f 100644 --- a/packages/tooling/node/src/publish.ts +++ b/packages/tooling/node/src/publish.ts @@ -108,13 +108,13 @@ const syncLocalnetMoveEnvironmentChainIdForPublish = async ( if (didAttempt && !chainId) { logWarning( - "Unable to resolve localnet chain id; Move.toml environments were not updated." + "Unable to resolve localnet chain id; Move.toml test-publish environments were not updated." ) } if (updatedFiles.length) { logKeyValueBlue("Move.toml")( - `updated ${updatedFiles.length} localnet environment entries` + `updated ${updatedFiles.length} test-publish environment entries` ) } } @@ -723,14 +723,16 @@ const assertFrameworkRevisionConsistency = async (packagePath: string) => { const frameworkRevisions = await readFrameworkRevisionsForPackage(packagePath) if (frameworkRevisions.size === 0) return - if (frameworkRevisions.size > 1) - throw new Error( + if (frameworkRevisions.size > 1) { + logWarning( await buildMultipleFrameworkRevisionsMessage({ packagePath, frameworkRevisions, - severity: "error" + severity: "warning" }) ) + return + } const [rootFrameworkRevision] = [...frameworkRevisions] diff --git a/packages/tooling/node/src/testing/localnet.ts b/packages/tooling/node/src/testing/localnet.ts index ff7e9e2..fa12ce4 100644 --- a/packages/tooling/node/src/testing/localnet.ts +++ b/packages/tooling/node/src/testing/localnet.ts @@ -45,7 +45,10 @@ import { } from "../artifacts.ts" import type { SuiResolvedConfig } from "../config.ts" import { loadSuiConfig } from "../config.ts" -import { DEFAULT_TX_GAS_BUDGET } from "../constants.ts" +import { + DEFAULT_PUBLISH_GAS_BUDGET, + DEFAULT_TX_GAS_BUDGET +} from "../constants.ts" import { buildKeystoreEntry, loadKeypair, @@ -56,7 +59,8 @@ import { resolveChainIdentifier } from "../move-toml.ts" import { buildMoveEnvironmentFlags, buildMovePackage, - clearPublishedEntryForNetwork + clearPublishedEntryForNetwork, + resolveMoveCliEnvironmentName } from "../move.ts" import { publishPackageWithLog } from "../publish.ts" import { createSuiClient } from "../sui-client.ts" @@ -145,7 +149,7 @@ const DEFAULT_RPC_PORT = 9000 const DEFAULT_WEBSOCKET_PORT = 9001 const DEFAULT_FAUCET_PORT = 9123 const DEFAULT_MINIMUM_COIN_OBJECTS = 2 -const DEFAULT_MINIMUM_GAS_COIN_BALANCE = 500_000_000n +const DEFAULT_MINIMUM_GAS_COIN_BALANCE = BigInt(DEFAULT_PUBLISH_GAS_BUDGET) const DEFAULT_FAUCET_REQUEST_ATTEMPTS = 1 const DEFAULT_FAUCET_REQUEST_DELAY_MS = 50 @@ -285,12 +289,12 @@ const logMovePackageDebug = async (label: string, packagePath: string) => { try { const moveTomlContents = await readFile(moveTomlPath, "utf8") const environmentBlock = extractMoveEnvironmentBlock(moveTomlContents) - const hasLocalnetEnvironment = /^\s*localnet\s*=\s*"[^"]*"/m.test( + const hasTestPublishEnvironment = /^\s*test-publish\s*=\s*"[^"]*"/m.test( moveTomlContents ) logMoveDebug(`${label} Move.toml environments:\n${environmentBlock}`) logMoveDebug( - `${label} Move.toml localnet entry=${hasLocalnetEnvironment ? "present" : "missing"}` + `${label} Move.toml test-publish entry=${hasTestPublishEnvironment ? "present" : "missing"}` ) } catch (error) { logMoveDebug( @@ -300,9 +304,11 @@ const logMovePackageDebug = async (label: string, packagePath: string) => { try { const moveLockContents = await readFile(moveLockPath, "utf8") - const hasLocalnetPinned = /\[pinned\.localnet\./.test(moveLockContents) + const hasTestPublishPinned = /\[pinned\.test-publish\./.test( + moveLockContents + ) logMoveDebug( - `${label} Move.lock localnet pinned sections=${hasLocalnetPinned ? "present" : "missing"}` + `${label} Move.lock test-publish pinned sections=${hasTestPublishPinned ? "present" : "missing"}` ) } catch (error) { logMoveDebug( @@ -1240,49 +1246,34 @@ const listMoveTomlFiles = async (rootDir: string): Promise => { return files } -const ensureLocalnetEnvironmentEntry = async ( - moveRootPath: string, - chainId: string -) => { - const moveTomlFiles = await listMoveTomlFiles(moveRootPath) +const resolveLocalnetMoveEnvironmentName = () => + resolveMoveCliEnvironmentName("localnet") ?? "test-publish" - await Promise.all( - moveTomlFiles.map(async (moveTomlPath) => { - const contents = await readFile(moveTomlPath, "utf8") - if (/^\s*\[environments\]\s*$/m.test(contents)) { - if (/^\s*localnet\s*=\s*"[^"]*"/m.test(contents)) { - return - } - const updated = contents.replace( - /^\s*\[environments\]\s*$/m, - `[environments]\nlocalnet = "${chainId}"` - ) - if (updated !== contents) { - await writeFile(moveTomlPath, updated, "utf8") - } - return - } +const buildEnvironmentEntryLine = (environmentName: string, chainId: string) => + `${environmentName} = "${chainId}"` - const suffix = contents.endsWith("\n") ? "" : "\n" - const updated = `${contents}${suffix}\n[environments]\nlocalnet = "${chainId}"\n` - await writeFile(moveTomlPath, updated, "utf8") - }) - ) -} +const buildEnvironmentEntryRegex = (environmentName: string) => + new RegExp(`^\\s*${environmentName}\\s*=\\s*"[^"]*"`, "m") -const ensureLocalnetEnvironmentEntryForPackage = async ( - packagePath: string, +const ensureMoveTomlEnvironmentEntry = async ({ + moveTomlPath, + environmentName, + chainId +}: { + moveTomlPath: string + environmentName: string chainId: string -) => { - const moveTomlPath = path.join(packagePath, "Move.toml") +}) => { const contents = await readFile(moveTomlPath, "utf8") + const entryRegex = buildEnvironmentEntryRegex(environmentName) + if (entryRegex.test(contents)) return - if (/^\s*localnet\s*=\s*"[^"]*"/m.test(contents)) return + const entryLine = buildEnvironmentEntryLine(environmentName, chainId) if (/^\s*\[environments\]\s*$/m.test(contents)) { const updated = contents.replace( /^\s*\[environments\]\s*$/m, - `[environments]\nlocalnet = "${chainId}"` + `[environments]\n${entryLine}` ) if (updated !== contents) { await writeFile(moveTomlPath, updated, "utf8") @@ -1291,10 +1282,41 @@ const ensureLocalnetEnvironmentEntryForPackage = async ( } const suffix = contents.endsWith("\n") ? "" : "\n" - const updated = `${contents}${suffix}\n[environments]\nlocalnet = "${chainId}"\n` + const updated = `${contents}${suffix}\n[environments]\n${entryLine}\n` await writeFile(moveTomlPath, updated, "utf8") } +const ensureLocalnetEnvironmentEntry = async ( + moveRootPath: string, + chainId: string +) => { + const moveEnvironmentName = resolveLocalnetMoveEnvironmentName() + const moveTomlFiles = await listMoveTomlFiles(moveRootPath) + + await Promise.all( + moveTomlFiles.map(async (moveTomlPath) => { + await ensureMoveTomlEnvironmentEntry({ + moveTomlPath, + environmentName: moveEnvironmentName, + chainId + }) + }) + ) +} + +const ensureLocalnetEnvironmentEntryForPackage = async ( + packagePath: string, + chainId: string +) => { + const moveEnvironmentName = resolveLocalnetMoveEnvironmentName() + const moveTomlPath = path.join(packagePath, "Move.toml") + await ensureMoveTomlEnvironmentEntry({ + moveTomlPath, + environmentName: moveEnvironmentName, + chainId + }) +} + const removeMoveBuildArtifacts = async (rootDir: string) => { const entries = await readdir(rootDir, { withFileTypes: true }) @@ -1704,9 +1726,8 @@ export const createTestContext = async ( options?: TestContextOptions ): Promise => { const tempDir = await createTempDir(buildTempPrefix(testId)) - const moveRootPath = path.join(tempDir, "move") + const moveRootPath = path.join(tempDir, "contracts") const artifactsDir = path.join(tempDir, "artifacts") - await ensureDirectory(artifactsDir) await copyMoveSources(moveRootPath, options?.moveSourceRootPath) diff --git a/packages/tooling/node/src/testing/paths.ts b/packages/tooling/node/src/testing/paths.ts index 00d1efc..97b6ac7 100644 --- a/packages/tooling/node/src/testing/paths.ts +++ b/packages/tooling/node/src/testing/paths.ts @@ -17,7 +17,8 @@ export const resolveDappRoot = () => export const resolveDappConfigPath = () => path.join(resolveDappRoot(), "sui.config.ts") -export const resolveDappMoveRoot = () => path.join(resolveDappRoot(), "move") +export const resolveDappMoveRoot = () => + path.join(resolveDappRoot(), "contracts") export const resolveTsNodeEsmPath = () => { const binaryName = diff --git a/packages/tooling/node/test-unit/unit/artifacts.test.ts b/packages/tooling/node/test-unit/unit/artifacts.test.ts index e5518f6..754149d 100644 --- a/packages/tooling/node/test-unit/unit/artifacts.test.ts +++ b/packages/tooling/node/test-unit/unit/artifacts.test.ts @@ -128,7 +128,7 @@ describe("artifact path helpers", () => { { packageId: "0x5", packageName: "oracle-market", - packagePath: "/tmp/move/oracle-market", + packagePath: "/tmp/contracts/oracle-market", publishedAt: "2024-01-01T00:00:00Z" } ] @@ -165,7 +165,7 @@ describe("isPublishArtifactNamed", () => { expect( matcher({ packageName: "Oracle-Market", - packagePath: "/tmp/move/oracle-market" + packagePath: "/tmp/contracts/oracle-market" } as PublishArtifact) ).toBe(true) }) diff --git a/packages/tooling/node/test-unit/unit/config.test.ts b/packages/tooling/node/test-unit/unit/config.test.ts index b2106ea..e9454d0 100644 --- a/packages/tooling/node/test-unit/unit/config.test.ts +++ b/packages/tooling/node/test-unit/unit/config.test.ts @@ -32,7 +32,7 @@ describe("loadSuiConfig", () => { expect(config.currentNetwork).toBe("localnet") expect(config.network.networkName).toBe("localnet") const resolvedDir = await resolveRealPath(dir) - expect(config.paths.move).toBe(path.join(resolvedDir, "move")) + expect(config.paths.move).toBe(path.join(resolvedDir, "contracts")) expect(config.paths.deployments).toBe( path.join(resolvedDir, "deployments") ) diff --git a/packages/tooling/node/test-unit/unit/move-build.test.ts b/packages/tooling/node/test-unit/unit/move-build.test.ts index 226956c..c21770c 100644 --- a/packages/tooling/node/test-unit/unit/move-build.test.ts +++ b/packages/tooling/node/test-unit/unit/move-build.test.ts @@ -116,7 +116,7 @@ describe("syncLocalnetMoveEnvironmentChainId", () => { }) await withTempDir(async (dir) => { - const moveToml = `[package]\nname = "fixture"\nversion = "0.0.1"\n\n[dep-replacements.localnet]\nSui = { local = "../sui" }\n` + const moveToml = `[package]\nname = "fixture"\nversion = "0.0.1"\n\n[dep-replacements.test-publish]\nSui = { local = "../sui" }\n` await writeFileTree(dir, { "Move.toml": moveToml }) const result = await syncLocalnetMoveEnvironmentChainId( @@ -132,7 +132,7 @@ describe("syncLocalnetMoveEnvironmentChainId", () => { const updated = await readTextFile(path.join(dir, "Move.toml")) expect(updated).toContain("[environments]") - expect(updated).toContain('localnet = "0xabc"') + expect(updated).toContain('test-publish = "0xabc"') }) }) }) diff --git a/packages/tooling/node/test-unit/unit/move.test.ts b/packages/tooling/node/test-unit/unit/move.test.ts index 5a15c22..235ff2e 100644 --- a/packages/tooling/node/test-unit/unit/move.test.ts +++ b/packages/tooling/node/test-unit/unit/move.test.ts @@ -24,7 +24,7 @@ describe("move helpers", () => { ): PublishArtifact => ({ network: "localnet", rpcUrl: "http://localhost:9000", - packagePath: "/tmp/contracts/../move/oracle-market", + packagePath: "/tmp/contracts/../contracts/oracle-market", packageId: "0x1", sender: "0x2", digest: "digest", @@ -38,7 +38,7 @@ describe("move helpers", () => { expect(buildMoveEnvironmentFlags({})).toEqual([]) expect(buildMoveEnvironmentFlags({ environmentName: "localnet" })).toEqual([ "--environment", - "localnet" + "test-publish" ]) }) @@ -60,7 +60,7 @@ describe("move helpers", () => { expect(args).toEqual([ "/tmp/pkg", "--build-env", - "localnet", + "test-publish", "--pubfile-path", "/tmp/publish.json", "--with-unpublished-dependencies" @@ -81,12 +81,12 @@ describe("move helpers", () => { it("matches deployments by canonicalized path", () => { const artifacts = [ buildPublishArtifact({ - packagePath: "/tmp/contracts/../move/oracle-market" + packagePath: "/tmp/contracts/../contracts/oracle-market" }) ] - expect(hasDeploymentForPackage(artifacts, "/tmp/move/oracle-market")).toBe( - true - ) + expect( + hasDeploymentForPackage(artifacts, "/tmp/contracts/oracle-market") + ).toBe(true) }) }) @@ -99,7 +99,7 @@ describe("syncMoveEnvironmentChainId", () => { const result = await syncMoveEnvironmentChainId({ moveRootPath: dir, - environmentName: "localnet", + environmentName: "test-publish", chainId: "0xabc" }) @@ -107,7 +107,7 @@ describe("syncMoveEnvironmentChainId", () => { const updated = await readTextFile(path.join(dir, "Move.toml")) expect(updated).toContain("[environments]") - expect(updated).toContain('localnet = "0xabc"') + expect(updated).toContain('test-publish = "0xabc"') }) }) @@ -119,7 +119,7 @@ describe("syncMoveEnvironmentChainId", () => { const result = await syncMoveEnvironmentChainId({ moveRootPath: dir, - environmentName: "localnet", + environmentName: "test-publish", chainId: "0x123" }) diff --git a/packages/tooling/node/test-unit/unit/publish.test.ts b/packages/tooling/node/test-unit/unit/publish.test.ts index 51760ec..7d50939 100644 --- a/packages/tooling/node/test-unit/unit/publish.test.ts +++ b/packages/tooling/node/test-unit/unit/publish.test.ts @@ -75,7 +75,7 @@ const buildPublishPlan = ( url: "http://localhost:9000", account: { accountIndex: 0 } }, - packagePath: "/tmp/move/oracle-market", + packagePath: "/tmp/contracts/oracle-market", fullNodeUrl: "http://localhost:9000", keypair: Ed25519Keypair.generate(), gasBudget: 1000, @@ -125,7 +125,7 @@ const buildResolvedConfig = ({ defaultNetwork: networkName, networks: { [networkName]: network }, paths: { - move: path.join(packageRoot, "move"), + move: path.join(packageRoot, "contracts"), deployments: path.join(packageRoot, "deployments"), objects: path.join(packageRoot, "deployments"), artifacts: path.join(packageRoot, "deployments") @@ -482,7 +482,19 @@ describe("publishPackageWithLog", () => { ) }) - it("errors when Move.lock contains multiple framework revisions on shared networks", async () => { + it("warns when Move.lock contains multiple framework revisions on shared networks", async () => { + moveMocks.buildMovePackage.mockResolvedValue({ + modules: ["module"], + dependencies: ["dep"], + dependencyAddresses: {} + }) + + runSuiCliMock.mockResolvedValueOnce({ + stdout: JSON.stringify(buildCliPublishResponse()), + stderr: "", + exitCode: 0 + }) + const { client } = createSuiClientMock() await withTempDir(async (dir) => { @@ -498,17 +510,23 @@ describe("publishPackageWithLog", () => { url: "https://example.invalid" }) - await expect( - publishPackageWithLog( - { - packagePath, - keypair: Ed25519Keypair.generate() - }, - { suiClient: client, suiConfig: config } - ) - ).rejects.toThrow( - "Multiple Sui framework revisions detected in Move.lock" + const artifacts = await publishPackageWithLog( + { + packagePath, + keypair: Ed25519Keypair.generate() + }, + { suiClient: client, suiConfig: config } ) + + expect(artifacts[0]?.packageId).toBe("0x1") + + expect( + logMocks.logWarning.mock.calls.some(([message]) => + String(message).includes( + "Multiple Sui framework revisions detected in Move.lock" + ) + ) + ).toBe(true) }) }) diff --git a/packages/tooling/node/test-unit/unit/testing-scripts.test.ts b/packages/tooling/node/test-unit/unit/testing-scripts.test.ts index 3c24e15..339c35a 100644 --- a/packages/tooling/node/test-unit/unit/testing-scripts.test.ts +++ b/packages/tooling/node/test-unit/unit/testing-scripts.test.ts @@ -41,7 +41,7 @@ const runWithRunnerContext = async ( }, suiConfig: { network: { - gasBudget: 500_000_000 + gasBudget: 800_000_000 } } } as never diff --git a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.lock b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.lock index 7fbddc7..bb225e3 100644 --- a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.lock +++ b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.lock @@ -4,24 +4,24 @@ [move] version = 4 -[pinned.localnet.MoveStdlib] +[pinned.test-publish.MoveStdlib] source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } -use_environment = "localnet" +use_environment = "test-publish" manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" deps = {} -[pinned.localnet.Sui] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } -use_environment = "localnet" -manifest_digest = "CC15CB146CA1113370B03476BFF694B88F43F77D8DEE7C23CC0C78AB0634420D" -deps = { MoveStdlib = "MoveStdlib" } - -[pinned.localnet.simple_contract] +[pinned.test-publish.SimpleContract] source = { root = true } -use_environment = "localnet" -manifest_digest = "EAE83E4C2C7D5B4EF605145EE3703A5AF265593E686986BA5E58EE3F5D5E884F" +use_environment = "test-publish" +manifest_digest = "2963D6B4689ED068546E86F8453FBCA51DCDF68A1C96D681328D2CDACE14B5CF" deps = { std = "MoveStdlib", sui = "Sui" } +[pinned.test-publish.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "test-publish" +manifest_digest = "7B6E5525FC0BFAECA61725298C425266BB21D456122669E3B2D3A4151A705F69" +deps = { MoveStdlib = "MoveStdlib" } + [pinned.testnet.MoveStdlib] source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } use_environment = "testnet" @@ -34,7 +34,7 @@ use_environment = "testnet" manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" deps = { MoveStdlib = "MoveStdlib" } -[pinned.testnet.simple_contract] +[pinned.testnet.SimpleContract] source = { root = true } use_environment = "testnet" manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" diff --git a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.toml b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.toml index 13c987a..9ca7145 100644 --- a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.toml +++ b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/Move.toml @@ -1,6 +1,6 @@ [package] -name = "simple_contract" +name = "SimpleContract" edition = "2024" [environments] -localnet = "58157b31" +test-publish = "58157b31" diff --git a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/build/simple_contract/BuildInfo.yaml b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/build/simple_contract/BuildInfo.yaml deleted file mode 100644 index 006aac6..0000000 --- a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/build/simple_contract/BuildInfo.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -compiled_package_info: - package_name: simple_contract - build_flags: - test_mode: true - generate_docs: false - save_disassembly: false - install_dir: ~ - force_recompilation: false - allow_dirty: true - default_flavor: sui - default_edition: ~ - silence_warnings: false - warnings_are_errors: false - json_errors: false - additional_named_addresses: {} - lint_flag: - no_lint: false - lint: false - modes: [] - force_lock_file: false - root_as_zero: false - environment: ~ - set_unpublished_deps_to_zero: false -dependencies: [] diff --git a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/counter.move b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/counter.move index f07f8c9..5c50de9 100644 --- a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/counter.move +++ b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/counter.move @@ -1,49 +1,64 @@ -module simple_contract::counter; +module SimpleContract::counter; use std::string; use sui::event; +// === Constants === + const EInvalidOwnerCap: u64 = 1; +// === Structs === + public struct Counter has key { + /// Unique ID for the counter object. id: UID, + /// Human-readable label. label: string::String, + /// Current owner address. owner: address, + /// Whether the counter is disabled. disabled: bool, } public struct CounterOwnerCap has key { + /// Unique ID for the capability object. id: UID, + /// Address of the associated counter. counter_id: address, } -public struct CounterCreated has copy, drop { +// === Events === + +public struct CounterCreatedEvent has copy, drop { + /// Address of the newly created counter. counter_id: address, + /// Address of the new owner capability. counter_owner_cap_id: address, + /// Owner address. owner: address, + /// Raw label bytes. label: vector, } -public struct CounterOwnerUpdated has copy, drop { +public struct CounterOwnerUpdatedEvent has copy, drop { + /// Address of the counter. counter_id: address, + /// New owner address. new_owner: address, } +// === Public Functions === + entry fun create_counter(label: vector, ctx: &mut TxContext) { let owner = tx_context::sender(ctx); - let counter = Counter { - id: object::new(ctx), - label: string::utf8(label), - owner, - disabled: false, - }; + let counter = build_counter(label, owner, ctx); let counter_id = object::uid_to_address(&counter.id); let owner_cap = CounterOwnerCap { id: object::new(ctx), counter_id }; let owner_cap_id = object::uid_to_address(&owner_cap.id); - transfer::share_object(counter); + share_counter(counter); transfer::transfer(owner_cap, owner); - event::emit(CounterCreated { + event::emit(CounterCreatedEvent { counter_id, counter_owner_cap_id: owner_cap_id, owner, @@ -60,5 +75,20 @@ entry fun update_counter_owner( assert!(owner_cap.counter_id == counter_id, EInvalidOwnerCap); counter.owner = new_owner; transfer::transfer(owner_cap, new_owner); - event::emit(CounterOwnerUpdated { counter_id, new_owner }); + event::emit(CounterOwnerUpdatedEvent { counter_id, new_owner }); +} + +// === Private Functions === + +fun build_counter(label: vector, owner: address, ctx: &mut TxContext): Counter { + Counter { + id: object::new(ctx), + label: string::utf8(label), + owner, + disabled: false, + } +} + +fun share_counter(counter: Counter) { + transfer::share_object(counter); } diff --git a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/pool.move b/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/pool.move deleted file mode 100644 index e5f3cba..0000000 --- a/packages/tooling/tests-integration/fixtures/localnet-move/simple-contract/sources/pool.move +++ /dev/null @@ -1 +0,0 @@ -// Deprecated Move source. The counter module now lives in counter.move. diff --git a/packages/tooling/tests-integration/fixtures/move/Move.lock b/packages/tooling/tests-integration/fixtures/move/Move.lock new file mode 100644 index 0000000..81da3be --- /dev/null +++ b/packages/tooling/tests-integration/fixtures/move/Move.lock @@ -0,0 +1,41 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.test-publish.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "test-publish" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.test-publish.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "test-publish" +manifest_digest = "CC15CB146CA1113370B03476BFF694B88F43F77D8DEE7C23CC0C78AB0634420D" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.test-publish.Fixture] +source = { root = true } +use_environment = "test-publish" +manifest_digest = "EAE83E4C2C7D5B4EF605145EE3703A5AF265593E686986BA5E58EE3F5D5E884F" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.Fixture] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/packages/tooling/tests-integration/fixtures/move/Move.lock.pinned b/packages/tooling/tests-integration/fixtures/move/Move.lock.pinned index 3ed9400..2c0858b 100644 --- a/packages/tooling/tests-integration/fixtures/move/Move.lock.pinned +++ b/packages/tooling/tests-integration/fixtures/move/Move.lock.pinned @@ -1,7 +1,7 @@ [move] version = 1 -[pinned.localnet.Sui] +[pinned.test-publish.Sui] source = { git = "https://github.com/MystenLabs/sui.git", rev = "1111111", subdir = "crates/sui-framework" } [pinned.testnet.Sui_1] @@ -10,5 +10,5 @@ source = { git = "https://github.com/MystenLabs/sui.git", rev = "2222222", subdi [pinned.testnet.MoveStdlib_1] source = { git = "https://github.com/MystenLabs/sui.git", rev = "3333333", subdir = "crates/sui-framework" } -[pinned.localnet.Custom] +[pinned.test-publish.Custom] source = { git = "https://example.com/custom.git", rev = "abcd" } diff --git a/packages/tooling/tests-integration/fixtures/move/Move.toml b/packages/tooling/tests-integration/fixtures/move/Move.toml index 881b4e4..c930229 100644 --- a/packages/tooling/tests-integration/fixtures/move/Move.toml +++ b/packages/tooling/tests-integration/fixtures/move/Move.toml @@ -1,15 +1,12 @@ [package] -name = "fixture" +name = "Fixture" version = "0.0.1" [dependencies] Sui = { local = "../sui" } -[dep-replacements.localnet] +[dep-replacements.test-publish] Sui = { local = "../sui" } -[addresses] -fixture = "0x0" - [dev-dependencies] Sui = { local = "../sui" } diff --git a/packages/tooling/tests-integration/fixtures/move/sources/fixture.move b/packages/tooling/tests-integration/fixtures/move/sources/fixture.move new file mode 100644 index 0000000..bf7c204 --- /dev/null +++ b/packages/tooling/tests-integration/fixtures/move/sources/fixture.move @@ -0,0 +1,6 @@ +module Fixture::fixture; + +// === Structs === + +/// Placeholder struct to keep the fixture package structurally valid. +public struct FixtureMarker has drop {} diff --git a/packages/tooling/tests-integration/integration/transactions-events.test.ts b/packages/tooling/tests-integration/integration/transactions-events.test.ts index 0e86424..618fc2d 100644 --- a/packages/tooling/tests-integration/integration/transactions-events.test.ts +++ b/packages/tooling/tests-integration/integration/transactions-events.test.ts @@ -101,8 +101,8 @@ describe("transactions and events", () => { suiClient: context.suiClient, digest: createResult.digest, predicate: (event) => - eventTypeEndsWith(event.type, "::counter::CounterCreated"), - label: "CounterCreated" + eventTypeEndsWith(event.type, "::counter::CounterCreatedEvent"), + label: "CounterCreatedEvent" }) const newOwner = context.createAccount("new-owner") @@ -127,8 +127,11 @@ describe("transactions and events", () => { suiClient: context.suiClient, digest: updateResult.digest, predicate: (event) => - eventTypeEndsWith(event.type, "::counter::CounterOwnerUpdated"), - label: "CounterOwnerUpdated" + eventTypeEndsWith( + event.type, + "::counter::CounterOwnerUpdatedEvent" + ), + label: "CounterOwnerUpdatedEvent" }) } ) diff --git a/scripts/strip-move-localnet.js b/scripts/strip-move-localnet.js index c17ee6c..b657d2d 100755 --- a/scripts/strip-move-localnet.js +++ b/scripts/strip-move-localnet.js @@ -8,7 +8,7 @@ const isStaged = args.has("--staged") const isAll = args.has("--all") const restoreAfterStage = isStaged && !args.has("--no-restore") -const LOCALNET_SECTION = /\n?\[env\.localnet\][\s\S]*?(?=\n\[|$)/ +const LOCALNET_SECTION = /\n?\[env\.test-publish\][\s\S]*?(?=\n\[|$)/ const getRepoRoot = () => { try { @@ -157,6 +157,6 @@ const main = async () => { } main().catch((error) => { - console.error("Failed to strip [env.localnet] from Move.lock:", error) + console.error("Failed to strip [env.test-publish] from Move.lock:", error) process.exit(1) })