From c9b0666f7300ffeb4da1c50ca37a4b5c0d3baa86 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Tue, 21 Apr 2026 20:51:07 -0700 Subject: [PATCH] fix(deepbook-v3): repay_base/repay_quote no longer drain balance on amount=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous truthy guard (`amount ? … : null`) collapsed `0` onto the Option::None branch. In Move, None means 'repay as much as possible using the available balance of the asset in the balance manager', capped at the full outstanding debt — so `repayBase(mgr, 0)` / `repayQuote(mgr, 0)` silently triggered a full-debt repayment instead of a no-op. The guard now uses `amount !== undefined`, so `0` serializes as Option::Some(0) and only an omitted argument becomes None. Also switches the Option argument from `tx.object.option` to `tx.pure.option`, matching marginLiquidations.ts and marginPool.ts and avoiding an extra 0x1::option::some/none PTB command. Adds unit tests that inspect the emitted Option input bytes for all three cases (Some(0), Some(n), None) on both repay methods. --- .../fix-deepbook-v3-repay-amount-zero.md | 5 + .../src/transactions/marginManager.ts | 16 +-- .../unit/transactions/marginManager.test.ts | 100 ++++++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-deepbook-v3-repay-amount-zero.md create mode 100644 packages/deepbook-v3/test/unit/transactions/marginManager.test.ts diff --git a/.changeset/fix-deepbook-v3-repay-amount-zero.md b/.changeset/fix-deepbook-v3-repay-amount-zero.md new file mode 100644 index 000000000..00e6b4316 --- /dev/null +++ b/.changeset/fix-deepbook-v3-repay-amount-zero.md @@ -0,0 +1,5 @@ +--- +'@mysten/deepbook-v3': patch +--- + +Fix `MarginManagerContract.repayBase` and `repayQuote` mishandling `amount = 0`. The previous truthy guard (`amount ? … : null`) collapsed `0` onto the `Option::None` branch, causing `repay_base(mgr, 0)` / `repay_quote(mgr, 0)` to repay the full outstanding debt using whatever balance of the asset was held in the balance manager — rather than being a no-op as the numeric argument suggests. The check now uses `amount !== undefined`, so `0` correctly serializes as `Option::Some(0)` and only an omitted argument becomes `None`. Also switches the `Option` argument from `tx.object.option` to `tx.pure.option` to avoid an extra `0x1::option::some/none` PTB command, matching `marginLiquidations.ts` and `marginPool.ts`. diff --git a/packages/deepbook-v3/src/transactions/marginManager.ts b/packages/deepbook-v3/src/transactions/marginManager.ts index 2bb495c89..49b5403b5 100644 --- a/packages/deepbook-v3/src/transactions/marginManager.ts +++ b/packages/deepbook-v3/src/transactions/marginManager.ts @@ -383,10 +383,10 @@ export class MarginManagerContract { tx.object(manager.address), tx.object(this.#config.MARGIN_REGISTRY_ID), tx.object(baseMarginPool.address), - tx.object.option({ - type: 'u64', - value: amount ? tx.pure.u64(convertQuantity(amount, baseCoin.scalar)) : null, - }), + tx.pure.option( + 'u64', + amount !== undefined ? convertQuantity(amount, baseCoin.scalar) : null, + ), tx.object.clock(), ], typeArguments: [baseCoin.type, quoteCoin.type], @@ -411,10 +411,10 @@ export class MarginManagerContract { tx.object(manager.address), tx.object(this.#config.MARGIN_REGISTRY_ID), tx.object(quoteMarginPool.address), - tx.object.option({ - type: 'u64', - value: amount ? tx.pure.u64(convertQuantity(amount, quoteCoin.scalar)) : null, - }), + tx.pure.option( + 'u64', + amount !== undefined ? convertQuantity(amount, quoteCoin.scalar) : null, + ), tx.object.clock(), ], typeArguments: [baseCoin.type, quoteCoin.type], diff --git a/packages/deepbook-v3/test/unit/transactions/marginManager.test.ts b/packages/deepbook-v3/test/unit/transactions/marginManager.test.ts new file mode 100644 index 000000000..39edb1997 --- /dev/null +++ b/packages/deepbook-v3/test/unit/transactions/marginManager.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import { Transaction } from '@mysten/sui/transactions'; +import { fromBase64 } from '@mysten/sui/utils'; +import { describe, expect, it } from 'vitest'; + +import { MarginManagerContract } from '../../../src/transactions/marginManager.js'; +import { DeepBookConfig } from '../../../src/utils/config.js'; + +const MARGIN_MANAGER_KEY = 'TEST_MGR'; + +function newContract() { + const config = new DeepBookConfig({ + network: 'testnet', + address: '0x1', + marginManagers: { + [MARGIN_MANAGER_KEY]: { + address: '0x2222222222222222222222222222222222222222222222222222222222222222', + poolKey: 'SUI_DBUSDC', + }, + }, + }); + return new MarginManagerContract(config); +} + +function lastMoveCall(tx: Transaction) { + const { commands } = tx.getData(); + const last = commands[commands.length - 1]; + if (last.$kind !== 'MoveCall') throw new Error('expected last command to be MoveCall'); + return last.MoveCall; +} + +function pureBytesForArg(tx: Transaction, arg: { $kind: string; Input?: number }) { + if (arg.$kind !== 'Input' || arg.Input === undefined) { + throw new Error(`expected pure Input argument, got ${JSON.stringify(arg)}`); + } + const input = tx.getData().inputs[arg.Input]; + if (input.$kind !== 'Pure') throw new Error('expected pure input'); + return fromBase64(input.Pure.bytes); +} + +describe('MarginManagerContract.repayBase', () => { + it('encodes Option::Some(0) when amount=0 (not None)', () => { + const tx = new Transaction(); + newContract().repayBase(MARGIN_MANAGER_KEY, 0)(tx); + const call = lastMoveCall(tx); + expect(call.function).toBe('repay_base'); + const optionArg = call.arguments[3]; + const bytes = pureBytesForArg(tx, optionArg); + const parsed = bcs.option(bcs.u64()).parse(bytes); + expect(parsed).toBe('0'); + }); + + it('encodes Option::Some(amount) when amount is a positive number', () => { + const tx = new Transaction(); + newContract().repayBase(MARGIN_MANAGER_KEY, 1.5)(tx); + const call = lastMoveCall(tx); + const bytes = pureBytesForArg(tx, call.arguments[3]); + // SUI base scalar is 1e9, so 1.5 -> 1_500_000_000 + expect(bcs.option(bcs.u64()).parse(bytes)).toBe('1500000000'); + }); + + it('encodes Option::None when amount is omitted', () => { + const tx = new Transaction(); + newContract().repayBase(MARGIN_MANAGER_KEY)(tx); + const call = lastMoveCall(tx); + const bytes = pureBytesForArg(tx, call.arguments[3]); + expect(bcs.option(bcs.u64()).parse(bytes)).toBeNull(); + }); +}); + +describe('MarginManagerContract.repayQuote', () => { + it('encodes Option::Some(0) when amount=0 (not None)', () => { + const tx = new Transaction(); + newContract().repayQuote(MARGIN_MANAGER_KEY, 0)(tx); + const call = lastMoveCall(tx); + expect(call.function).toBe('repay_quote'); + const bytes = pureBytesForArg(tx, call.arguments[3]); + expect(bcs.option(bcs.u64()).parse(bytes)).toBe('0'); + }); + + it('encodes Option::Some(amount) when amount is a positive number', () => { + const tx = new Transaction(); + newContract().repayQuote(MARGIN_MANAGER_KEY, 2.5)(tx); + const call = lastMoveCall(tx); + const bytes = pureBytesForArg(tx, call.arguments[3]); + // DBUSDC quote scalar is 1e6, so 2.5 -> 2_500_000 + expect(bcs.option(bcs.u64()).parse(bytes)).toBe('2500000'); + }); + + it('encodes Option::None when amount is omitted', () => { + const tx = new Transaction(); + newContract().repayQuote(MARGIN_MANAGER_KEY)(tx); + const call = lastMoveCall(tx); + const bytes = pureBytesForArg(tx, call.arguments[3]); + expect(bcs.option(bcs.u64()).parse(bytes)).toBeNull(); + }); +});