diff --git a/packages/dapp/src/scripts/owner/amm-update.ts b/packages/dapp/src/scripts/owner/amm-update.ts new file mode 100644 index 0000000..4752c67 --- /dev/null +++ b/packages/dapp/src/scripts/owner/amm-update.ts @@ -0,0 +1,301 @@ +/** + * Updates an existing shared AMM config for the target network. + */ +import yargs from "yargs" + +import { + type AmmConfigOverview, + getAmmConfigOverview, + resolveAmmConfigInputs +} from "@sui-amm/domain-core/models/amm" +import { buildUpdateAmmConfigTransaction } from "@sui-amm/domain-core/ptb/amm" +import { + resolveAmmConfigId, + resolveAmmPackageId +} from "@sui-amm/domain-node/amm" +import { normalizeIdOrThrow } from "@sui-amm/tooling-core/object" +import type { Tooling } from "@sui-amm/tooling-node/factory" +import { emitJsonOutput } from "@sui-amm/tooling-node/json" +import { runSuiScript } from "@sui-amm/tooling-node/process" +import { + logAmmConfigOverview, + resolveAmmAdminCapIdFromArtifacts, + resolvePythPriceFeedIdHex +} from "../../utils/amm.ts" + +type UpdateAmmArguments = { + ammConfigId?: string + adminCapId?: string + ammPackageId?: string + baseSpreadBps?: string + volatilityMultiplierBps?: string + useLaser?: boolean + tradingPaused?: boolean + pythPriceFeedId?: string + pythPriceFeedLabel?: string + devInspect?: boolean + dryRun?: boolean + json?: boolean +} + +type ResolvedAmmUpdateInputs = { + baseSpreadBps: bigint + volatilityMultiplierBps: bigint + useLaser: boolean + tradingPaused: boolean + pythPriceFeedIdHex: string + pythPriceFeedIdBytes: number[] +} + +const resolveExplicitAdminCapId = (adminCapId?: string): string | undefined => { + const trimmedAdminCapId = adminCapId?.trim() + if (!trimmedAdminCapId) { + return undefined + } + + return normalizeIdOrThrow( + trimmedAdminCapId, + "An AMM admin cap id is required; publish the package or provide --admin-cap-id." + ) +} + +const resolveAdminCapId = async ({ + tooling, + cliArguments, + ammPackageId +}: { + tooling: Pick + cliArguments: UpdateAmmArguments + ammPackageId: string +}): Promise => { + const explicitAdminCapId = resolveExplicitAdminCapId(cliArguments.adminCapId) + if (explicitAdminCapId) { + return explicitAdminCapId + } + + return resolveAmmAdminCapIdFromArtifacts({ + tooling, + ammPackageId + }) +} + +const shouldResolveNewPythPriceFeedId = (cliArguments: UpdateAmmArguments) => + Boolean(cliArguments.pythPriceFeedId?.trim()) || + Boolean(cliArguments.pythPriceFeedLabel?.trim()) + +const resolvePythPriceFeedIdHexForUpdate = async ({ + networkName, + cliArguments, + currentOverview +}: { + networkName: string + cliArguments: UpdateAmmArguments + currentOverview: AmmConfigOverview +}) => { + if (!shouldResolveNewPythPriceFeedId(cliArguments)) { + return currentOverview.pythPriceFeedIdHex + } + + return resolvePythPriceFeedIdHex({ + networkName, + pythPriceFeedId: cliArguments.pythPriceFeedId, + pythPriceFeedLabel: cliArguments.pythPriceFeedLabel + }) +} + +const resolveTradingPausedForUpdate = ({ + cliArguments, + currentOverview +}: { + cliArguments: UpdateAmmArguments + currentOverview: AmmConfigOverview +}) => cliArguments.tradingPaused ?? currentOverview.tradingPaused + +const resolveAmmUpdateInputs = async ({ + networkName, + cliArguments, + currentOverview +}: { + networkName: string + cliArguments: UpdateAmmArguments + currentOverview: AmmConfigOverview +}): Promise => { + const pythPriceFeedIdHex = await resolvePythPriceFeedIdHexForUpdate({ + networkName, + cliArguments, + currentOverview + }) + + const resolvedAmmConfigInputs = resolveAmmConfigInputs({ + baseSpreadBps: cliArguments.baseSpreadBps ?? currentOverview.baseSpreadBps, + volatilityMultiplierBps: + cliArguments.volatilityMultiplierBps ?? + currentOverview.volatilityMultiplierBps, + useLaser: cliArguments.useLaser ?? currentOverview.useLaser, + pythPriceFeedIdHex + }) + + return { + ...resolvedAmmConfigInputs, + tradingPaused: resolveTradingPausedForUpdate({ + cliArguments, + currentOverview + }) + } +} + +runSuiScript( + async (tooling, cliArguments: UpdateAmmArguments) => { + const ammPackageId = await resolveAmmPackageId({ + networkName: tooling.network.networkName, + ammPackageId: cliArguments.ammPackageId + }) + const ammConfigId = await resolveAmmConfigId({ + networkName: tooling.network.networkName, + ammConfigId: cliArguments.ammConfigId + }) + const adminCapId = await resolveAdminCapId({ + tooling, + cliArguments, + ammPackageId + }) + + const [currentOverview, ammConfigSharedObject] = await Promise.all([ + getAmmConfigOverview(ammConfigId, tooling.suiClient), + tooling.getMutableSharedObject({ objectId: ammConfigId }) + ]) + + const updateInputs = await resolveAmmUpdateInputs({ + networkName: tooling.network.networkName, + cliArguments, + currentOverview + }) + + const updateAmmTransaction = buildUpdateAmmConfigTransaction({ + packageId: ammPackageId, + adminCapId, + config: ammConfigSharedObject, + baseSpreadBps: updateInputs.baseSpreadBps, + volatilityMultiplierBps: updateInputs.volatilityMultiplierBps, + useLaser: updateInputs.useLaser, + tradingPaused: updateInputs.tradingPaused, + pythPriceFeedIdBytes: updateInputs.pythPriceFeedIdBytes + }) + + const { execution, summary } = await tooling.executeTransactionWithSummary({ + transaction: updateAmmTransaction, + signer: tooling.loadedEd25519KeyPair, + summaryLabel: "update-amm", + devInspect: cliArguments.devInspect, + dryRun: cliArguments.dryRun + }) + + if (!execution) { + return + } + + const updatedOverview = await getAmmConfigOverview( + ammConfigId, + tooling.suiClient + ) + + if ( + emitJsonOutput( + { + ammConfig: updatedOverview, + ammConfigId, + adminCapId, + pythPriceFeedIdHex: updateInputs.pythPriceFeedIdHex, + transactionSummary: summary + }, + cliArguments.json + ) + ) { + return + } + + logAmmConfigOverview(updatedOverview, { + initialSharedVersion: ammConfigSharedObject.sharedRef.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("adminCapId", { + alias: ["admin-cap-id"], + type: "string", + description: + "Admin cap object id for AMM config updates; inferred from the selected AMM publish when 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("baseSpreadBps", { + alias: ["base-spread-bps"], + type: "string", + description: + "Base spread in basis points (u64); defaults to the current config value.", + demandOption: false + }) + .option("volatilityMultiplierBps", { + alias: ["volatility-multiplier-bps"], + type: "string", + description: + "Volatility multiplier in basis points (u64); defaults to the current config value.", + demandOption: false + }) + .option("useLaser", { + alias: ["use-laser"], + type: "boolean", + description: + "Enable the laser pricing path for the AMM; defaults to the current config value." + }) + .option("tradingPaused", { + alias: ["trading-paused"], + type: "boolean", + description: + "Pause trading for the AMM; defaults to the current config value." + }) + .option("pythPriceFeedId", { + alias: ["pyth-price-feed-id", "pyth-feed-id"], + type: "string", + description: + "Pyth price feed id (32 bytes hex); defaults to the current config value.", + demandOption: false + }) + .option("pythPriceFeedLabel", { + alias: ["pyth-price-feed-label", "pyth-feed-label"], + type: "string", + description: + "Localnet artifact feed label to resolve the feed id when --pyth-price-feed-id is 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-update.test.ts b/packages/dapp/src/scripts/owner/test-integration/amm-update.test.ts new file mode 100644 index 0000000..e7900c0 --- /dev/null +++ b/packages/dapp/src/scripts/owner/test-integration/amm-update.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest" + +import { + AMM_CONFIG_TYPE_SUFFIX, + type AmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { + buildCreateAmmConfigTransaction, + parsePythPriceFeedIdBytes +} from "@sui-amm/domain-core/ptb/amm" +import { normalizeHex } from "@sui-amm/tooling-core/hex" +import { ensureCreatedObject } from "@sui-amm/tooling-core/transactions" +import { pickRootNonDependencyArtifact } from "@sui-amm/tooling-node/package" +import { createSuiLocalnetTestEnv } from "@sui-amm/tooling-node/testing/env" +import { resolveDappMoveRoot } from "@sui-amm/tooling-node/testing/paths" +import { + createSuiScriptRunner, + parseJsonFromScriptOutput +} from "@sui-amm/tooling-node/testing/scripts" +import { + DEFAULT_LOCALNET_PYTH_PRICE_FEED_ID, + resolveAmmAdminCapIdFromPublishDigest +} from "../../../utils/amm.ts" + +type AmmUpdateOutput = { + ammConfig?: AmmConfigOverview + ammConfigId?: string + adminCapId?: string + pythPriceFeedIdHex?: string + transactionSummary?: { label?: string } +} + +const resolveKeepTemp = () => process.env.SUI_IT_KEEP_TEMP === "1" + +const resolveWithFaucet = () => process.env.SUI_IT_WITH_FAUCET !== "0" + +const UPDATED_PYTH_PRICE_FEED_ID_HEX = + "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +const UPDATED_BASE_SPREAD_BPS = "55" +const UPDATED_VOLATILITY_MULTIPLIER_BPS = "555" +const UPDATED_USE_LASER = true +const UPDATED_TRADING_PAUSED = true + +const testEnv = createSuiLocalnetTestEnv({ + mode: "test", + keepTemp: resolveKeepTemp(), + withFaucet: resolveWithFaucet(), + moveSourceRootPath: resolveDappMoveRoot() +}) + +describe("owner amm-update integration", () => { + it("updates a shared AMM config and returns the latest snapshot", async () => { + await testEnv.withTestContext("owner-amm-update", 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 ammPackageId = rootArtifact.packageId + + await context.waitForFinality(rootArtifact.digest) + + const adminCapId = await resolveAmmAdminCapIdFromPublishDigest({ + publishDigest: rootArtifact.digest, + suiClient: context.suiClient + }) + + const initialConfigTransaction = buildCreateAmmConfigTransaction({ + packageId: ammPackageId, + adminCapId, + baseSpreadBps: 25n, + volatilityMultiplierBps: 200n, + useLaser: false, + pythPriceFeedIdBytes: parsePythPriceFeedIdBytes( + DEFAULT_LOCALNET_PYTH_PRICE_FEED_ID + ) + }) + + const createResult = await context.signAndExecuteTransaction( + initialConfigTransaction, + publisher + ) + await context.waitForFinality(createResult.digest) + + const ammConfigId = ensureCreatedObject( + AMM_CONFIG_TYPE_SUFFIX, + createResult + ).objectId + + const scriptRunner = createSuiScriptRunner(context) + const result = await scriptRunner.runOwnerScript("amm-update", { + account: publisher, + args: { + json: true, + ammPackageId, + ammConfigId, + adminCapId, + baseSpreadBps: UPDATED_BASE_SPREAD_BPS, + volatilityMultiplierBps: UPDATED_VOLATILITY_MULTIPLIER_BPS, + useLaser: UPDATED_USE_LASER, + tradingPaused: UPDATED_TRADING_PAUSED, + pythPriceFeedId: UPDATED_PYTH_PRICE_FEED_ID_HEX + } + }) + + expect(result.exitCode).toBe(0) + + const output = parseJsonFromScriptOutput( + result.stdout, + "amm-update output" + ) + if (!output.ammConfig) { + throw new Error("amm-update output did not include ammConfig.") + } + + expect(output.transactionSummary?.label).toBe("update-amm") + expect(output.ammConfigId).toBe(ammConfigId) + expect(output.adminCapId).toBe(adminCapId) + expect(output.ammConfig.configId).toBe(ammConfigId) + expect(output.ammConfig.baseSpreadBps).toBe(UPDATED_BASE_SPREAD_BPS) + expect(output.ammConfig.volatilityMultiplierBps).toBe( + UPDATED_VOLATILITY_MULTIPLIER_BPS + ) + expect(output.ammConfig.useLaser).toBe(UPDATED_USE_LASER) + expect(output.ammConfig.tradingPaused).toBe(UPDATED_TRADING_PAUSED) + expect(normalizeHex(output.ammConfig.pythPriceFeedIdHex)).toBe( + normalizeHex(UPDATED_PYTH_PRICE_FEED_ID_HEX) + ) + expect(normalizeHex(output.pythPriceFeedIdHex ?? "")).toBe( + normalizeHex(UPDATED_PYTH_PRICE_FEED_ID_HEX) + ) + }) + }) +})