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..abeaf68 --- /dev/null +++ b/packages/dapp/src/scripts/owner/amm-update.ts @@ -0,0 +1,288 @@ +/** + * Updates an existing shared AMM config for the target network. + */ +import yargs from "yargs" + +import { + type AmmConfigOverview, + getAmmConfigOverview +} from "@sui-amm/domain-core/models/amm" +import { + buildUpdateAmmConfigTransaction, + parsePythPriceFeedIdBytes +} 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 { + parseNonNegativeU64, + parsePositiveU64 +} from "@sui-amm/tooling-core/utils/utility" +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 = { + baseSpreadBps?: string + volatilityMultiplierBps?: string + useLaser?: boolean + tradingPaused?: boolean + pythPriceFeedId?: string + pythPriceFeedLabel?: string + ammPackageId?: string + ammConfigId?: string + adminCapId?: string + devInspect?: boolean + dryRun?: boolean + json?: boolean +} + +type ResolvedAmmUpdateInputs = { + baseSpreadBps: bigint + volatilityMultiplierBps: bigint + useLaser: boolean + tradingPaused: boolean + pythPriceFeedIdHex: string + pythPriceFeedIdBytes: number[] +} + +const resolveAmmAdminCapIdFromCli = ( + adminCapId?: string +): string | undefined => { + const trimmedAdminCapId = adminCapId?.trim() + if (!trimmedAdminCapId) return undefined + return normalizeIdOrThrow( + trimmedAdminCapId, + "An AMM admin cap id is required; provide --admin-cap-id." + ) +} + +const resolveAmmAdminCapIdForUpdate = async ({ + tooling, + cliArguments, + ammPackageId +}: { + tooling: Pick + cliArguments: UpdateAmmArguments + ammPackageId: string +}): Promise => { + const adminCapIdFromCli = resolveAmmAdminCapIdFromCli(cliArguments.adminCapId) + if (adminCapIdFromCli) return adminCapIdFromCli + + return resolveAmmAdminCapIdFromArtifacts({ + tooling, + ammPackageId + }) +} + +const resolveBaseSpreadBps = (rawValue: string): bigint => + parsePositiveU64(rawValue, "Base spread bps") + +const resolveVolatilityMultiplierBps = (rawValue: string): bigint => + parseNonNegativeU64(rawValue, "Volatility multiplier bps") + +const resolveAmmUpdateInputs = async ({ + networkName, + cliArguments, + currentOverview +}: { + networkName: string + cliArguments: UpdateAmmArguments + currentOverview: AmmConfigOverview +}): Promise => { + const baseSpreadBps = resolveBaseSpreadBps( + cliArguments.baseSpreadBps ?? currentOverview.baseSpreadBps + ) + const volatilityMultiplierBps = resolveVolatilityMultiplierBps( + cliArguments.volatilityMultiplierBps ?? + currentOverview.volatilityMultiplierBps + ) + + const useLaser = cliArguments.useLaser ?? currentOverview.useLaser + const tradingPaused = + cliArguments.tradingPaused ?? currentOverview.tradingPaused + + const shouldResolveFeedFromCli = + Boolean(cliArguments.pythPriceFeedId?.trim()) || + Boolean(cliArguments.pythPriceFeedLabel?.trim()) + + const pythPriceFeedIdHex = shouldResolveFeedFromCli + ? await resolvePythPriceFeedIdHex({ + networkName, + pythPriceFeedId: cliArguments.pythPriceFeedId, + pythPriceFeedLabel: cliArguments.pythPriceFeedLabel + }) + : currentOverview.pythPriceFeedIdHex + + return { + baseSpreadBps, + volatilityMultiplierBps, + useLaser, + tradingPaused, + pythPriceFeedIdHex, + pythPriceFeedIdBytes: parsePythPriceFeedIdBytes(pythPriceFeedIdHex) + } +} + +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 resolveAmmAdminCapIdForUpdate({ + tooling, + cliArguments, + ammPackageId + }) + + const ammConfigSharedObject = await tooling.getMutableSharedObject({ + objectId: ammConfigId + }) + const currentOverview = await getAmmConfigOverview( + ammConfigId, + tooling.suiClient + ) + + 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: + "AMM admin cap id; inferred from the latest publish artifacts 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 mock feed label used when resolving a new feed id.", + 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-update.test.ts b/packages/dapp/src/scripts/owner/test-integration/amm-update.test.ts new file mode 100644 index 0000000..a5873e7 --- /dev/null +++ b/packages/dapp/src/scripts/owner/test-integration/amm-update.test.ts @@ -0,0 +1,143 @@ +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 { 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 } from "@sui-amm/tooling-node/testing/paths" +import { + createSuiScriptRunner, + parseJsonFromScriptOutput, + resolveOwnerScriptPath +} from "@sui-amm/tooling-node/testing/scripts" +import { 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 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)( + "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 + }) + if (!adminCapId) + throw new Error("Expected AMM admin cap to be published.") + + const initialConfigTransaction = buildCreateAmmConfigTransaction({ + packageId: ammPackageId, + baseSpreadBps: 25n, + volatilityMultiplierBps: 200n, + useLaser: false, + pythPriceFeedIdBytes: parsePythPriceFeedIdBytes( + DEFAULT_MOCK_PRICE_FEED.feedIdHex + ) + }) + + 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.runScript( + resolveOwnerScriptPath("amm-update"), + { + account: publisher, + args: { + json: true, + ammPackageId, + ammConfigId, + adminCapId, + baseSpreadBps: "55", + volatilityMultiplierBps: "555", + useLaser: true, + tradingPaused: true, + 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("55") + expect(output.ammConfig.volatilityMultiplierBps).toBe("555") + expect(output.ammConfig.useLaser).toBe(true) + expect(output.ammConfig.tradingPaused).toBe(true) + 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) + ) + }) + }) + } +)