From 0eb82fa2a3fcc358b6ddf5776d6894f6e783d485 Mon Sep 17 00:00:00 2001 From: 0xTopaz <60733299+onlyhyde@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:36:04 +0900 Subject: [PATCH] GSW-1968 refactor: integration common and position contract (#447) * GSW-1968 refactor: common contract - Math function-focused modifications * feat: change type for tickRatio and binaryLog * refactor: position contract * fix : rfc comments --------- Co-authored-by: Lee ByeongJun --- _deploy/r/gnoswap/common/access.gno | 12 +- .../r/gnoswap/common/address_and_username.gno | 2 +- _deploy/r/gnoswap/common/errors.gno | 16 +- _deploy/r/gnoswap/common/grc20reg_helper.gno | 2 +- _deploy/r/gnoswap/common/grc721_token_id.gno | 4 +- _deploy/r/gnoswap/common/halt.gno | 104 +- _deploy/r/gnoswap/common/limit_caller.gno | 44 +- .../common/{tests => }/limit_caller_test.gno | 7 +- .../r/gnoswap/common/liquidity_amounts.gno | 311 ++++- .../gnoswap/common/liquidity_amounts_test.gno | 506 +++++++ _deploy/r/gnoswap/common/math.gno | 4 +- _deploy/r/gnoswap/common/tick_math.gno | 276 +++- .../common/{tests => }/tick_math_test.gno | 8 +- _deploy/r/gnoswap/common/util.gno | 27 +- _deploy/r/gnoswap/consts/consts.gno | 3 + pool/pool_manager.gno | 6 +- pool/pool_manager_test.gno | 2 +- position/_helper_test.gno | 236 +++- position/errors.gno | 47 +- position/gno_helper.gno | 11 - position/helper.gno | 131 -- position/helper_test.gno | 397 ------ position/liquidity_management.gno | 95 +- position/liquidity_management_test.gno | 115 ++ position/native_token.gno | 171 +++ position/native_token_test.gno | 393 ++++++ position/position.gno | 1225 ++++++++++------- position/position_key.gno | 19 - position/position_test.gno | 615 +++++++++ position/type.gno | 61 +- position/utils.gno | 313 ++++- position/utils_test.gno | 781 +++++++++-- position/wrap_unwrap.gno | 49 - 33 files changed, 4494 insertions(+), 1499 deletions(-) rename _deploy/r/gnoswap/common/{tests => }/limit_caller_test.gno (62%) create mode 100644 _deploy/r/gnoswap/common/liquidity_amounts_test.gno rename _deploy/r/gnoswap/common/{tests => }/tick_math_test.gno (90%) delete mode 100644 position/gno_helper.gno delete mode 100644 position/helper.gno delete mode 100644 position/helper_test.gno create mode 100644 position/liquidity_management_test.gno create mode 100644 position/native_token.gno create mode 100644 position/native_token_test.gno delete mode 100644 position/position_key.gno create mode 100644 position/position_test.gno delete mode 100644 position/wrap_unwrap.gno diff --git a/_deploy/r/gnoswap/common/access.gno b/_deploy/r/gnoswap/common/access.gno index c96bc2efd..0297c4a60 100644 --- a/_deploy/r/gnoswap/common/access.gno +++ b/_deploy/r/gnoswap/common/access.gno @@ -11,6 +11,7 @@ const ( ErrNoPermission = "caller(%s) has no permission" ) +// AssertCaller checks if the caller is the given address. func AssertCaller(caller, addr std.Address) error { if caller != addr { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -25,6 +26,7 @@ func SatisfyCond(cond bool) error { return nil } +// AdminOnly checks if the caller is the admin. func AdminOnly(caller std.Address) error { if caller != consts.ADMIN { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -32,6 +34,7 @@ func AdminOnly(caller std.Address) error { return nil } +// GovernanceOnly checks if the caller is the gov governance contract. func GovernanceOnly(caller std.Address) error { if caller != consts.GOV_GOVERNANCE_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -39,6 +42,7 @@ func GovernanceOnly(caller std.Address) error { return nil } +// GovStakerOnly checks if the caller is the gov staker contract. func GovStakerOnly(caller std.Address) error { if caller != consts.GOV_STAKER_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -46,6 +50,7 @@ func GovStakerOnly(caller std.Address) error { return nil } +// RouterOnly checks if the caller is the router contract. func RouterOnly(caller std.Address) error { if caller != consts.ROUTER_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -53,6 +58,7 @@ func RouterOnly(caller std.Address) error { return nil } +// PositionOnly checks if the caller is the position contract. func PositionOnly(caller std.Address) error { if caller != consts.POSITION_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -60,6 +66,7 @@ func PositionOnly(caller std.Address) error { return nil } +// StakerOnly checks if the caller is the staker contract. func StakerOnly(caller std.Address) error { if caller != consts.STAKER_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -67,6 +74,7 @@ func StakerOnly(caller std.Address) error { return nil } +// LaunchpadOnly checks if the caller is the launchpad contract. func LaunchpadOnly(caller std.Address) error { if caller != consts.LAUNCHPAD_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -74,6 +82,7 @@ func LaunchpadOnly(caller std.Address) error { return nil } +// EmissionOnly checks if the caller is the emission contract. func EmissionOnly(caller std.Address) error { if caller != consts.EMISSION_ADDR { return ufmt.Errorf(ErrNoPermission, caller.String()) @@ -90,8 +99,7 @@ func TokenRegisterOnly(caller std.Address) error { return nil } -// realm assertion - +// UserOnly checks if the caller is a user. func UserOnly(prev std.Realm) error { if !prev.IsUser() { return ufmt.Errorf("caller(%s) is not a user", prev.PkgPath()) diff --git a/_deploy/r/gnoswap/common/address_and_username.gno b/_deploy/r/gnoswap/common/address_and_username.gno index 708c55e2c..908f81712 100644 --- a/_deploy/r/gnoswap/common/address_and_username.gno +++ b/_deploy/r/gnoswap/common/address_and_username.gno @@ -24,6 +24,6 @@ func UserToAddr(user pusers.AddressOrName) std.Address { // It panics with a detailed error message if the address is invalid. func assertValidAddr(addr std.Address) { if !addr.IsValid() { - panic(addDetailToError(errInvalidAddr, addr.String())) + panic(newErrorWithDetail(errInvalidAddr, addr.String())) } } diff --git a/_deploy/r/gnoswap/common/errors.gno b/_deploy/r/gnoswap/common/errors.gno index 168d8b859..f96124477 100644 --- a/_deploy/r/gnoswap/common/errors.gno +++ b/_deploy/r/gnoswap/common/errors.gno @@ -14,9 +14,19 @@ var ( errInvalidAddr = errors.New("[GNOSWAP-COMMON-005] invalid address") errOverflow = errors.New("[GNOSWAP-COMMON-006] overflow") errInvalidTokenId = errors.New("[GNOSWAP-COMMON-007] invalid tokenId") + errInvalidInput = errors.New("[GNOSWAP-COMMON-008] invalid input data") + errOverFlow = errors.New("[GNOSWAP-COMMON-009] overflow") + errIdenticalTicks = errors.New("[GNOSWAP-COMMON-010] identical ticks") ) -func addDetailToError(err error, detail string) string { - finalErr := ufmt.Errorf("%s || %s", err.Error(), detail) - return finalErr.Error() +// newErrorWithDetail appends additional context or details to an existing error message. +// +// Parameters: +// - err: The original error (error). +// - detail: Additional context or detail to append to the error message (string). +// +// Returns: +// - string: The combined error message in the format " || ". +func newErrorWithDetail(err error, detail string) string { + return ufmt.Errorf("%s || %s", err.Error(), detail).Error() } diff --git a/_deploy/r/gnoswap/common/grc20reg_helper.gno b/_deploy/r/gnoswap/common/grc20reg_helper.gno index 992a02702..b3f23147d 100644 --- a/_deploy/r/gnoswap/common/grc20reg_helper.gno +++ b/_deploy/r/gnoswap/common/grc20reg_helper.gno @@ -51,7 +51,7 @@ func IsRegistered(path string) error { // if token is not registered, it will panic func MustRegistered(path string) { if err := IsRegistered(path); err != nil { - panic(addDetailToError( + panic(newErrorWithDetail( errNotRegistered, ufmt.Sprintf("token(%s)", path), )) diff --git a/_deploy/r/gnoswap/common/grc721_token_id.gno b/_deploy/r/gnoswap/common/grc721_token_id.gno index 2cc4c5620..033fb4991 100644 --- a/_deploy/r/gnoswap/common/grc721_token_id.gno +++ b/_deploy/r/gnoswap/common/grc721_token_id.gno @@ -16,7 +16,7 @@ import ( // output: grc721.TokenID func TokenIdFrom(tokenId interface{}) grc721.TokenID { if tokenId == nil { - panic(addDetailToError( + panic(newErrorWithDetail( errInvalidTokenId, "can not be nil", )) @@ -33,7 +33,7 @@ func TokenIdFrom(tokenId interface{}) grc721.TokenID { return tokenId.(grc721.TokenID) default: estimatedType := ufmt.Sprintf("%T", tokenId) - panic(addDetailToError( + panic(newErrorWithDetail( errInvalidTokenId, ufmt.Sprintf("unsupported tokenId type: %s", estimatedType), )) diff --git a/_deploy/r/gnoswap/common/halt.gno b/_deploy/r/gnoswap/common/halt.gno index 9856a75c6..2ea37b471 100644 --- a/_deploy/r/gnoswap/common/halt.gno +++ b/_deploy/r/gnoswap/common/halt.gno @@ -4,33 +4,49 @@ import ( "std" "gno.land/p/demo/ufmt" - "gno.land/r/gnoswap/v1/consts" ) -var ( - halted bool = false -) +// halted is a global flag that indicates whether the GnoSwap is currently halted. +// When true, most operations are disabled to prevent further actions. +// Default value is false, meaning the GnoSwap is active by default. +var halted bool = false +// GetHalt returns the current halted status of the GnoSwap. +// +// Returns: +// - bool: true if the GnoSwap is halted, false otherwise. func GetHalt() bool { return halted } +// IsHalted checks if the GnoSwap is currently halted. +// If the GnoSwap is halted, the function panics with an errHalted error. +// +// Panics: +// - If the halted flag is true, indicating that the GnoSwap is inactive. func IsHalted() { if halted { - panic(addDetailToError( + panic(newErrorWithDetail( errHalted, - "gnoswap halted", + "GnoSwap is halted", )) } } -// SetHaltByAdmin sets the halt status. +// SetHaltByAdmin allows an admin to set the halt status of the GnoSwap. +// Only an admin can execute this function. If a non-admin attempts to call this function, +// the function panics with an errNoPermission error. +// +// Parameters: +// - halt (bool): The new halt status to set (true to halt, false to unhalt). +// +// Panics: +// - If the caller is not an admin, the function will panic with an errNoPermission error. func SetHaltByAdmin(halt bool) { - caller := std.PrevRealm().Addr() - err := AdminOnly(caller) - if err != nil { - panic(addDetailToError( + caller := getPrevAddr() + if err := AdminOnly(caller); err != nil { + panic(newErrorWithDetail( errNoPermission, ufmt.Sprintf( "only admin(%s) can set halt, called from %s", @@ -39,34 +55,21 @@ func SetHaltByAdmin(halt bool) { ), )) } - setHalt(halt) - - prevAddr, prevRealm := getPrev() - if halt { - std.Emit( - "SetHaltByAdmin", - "prevAddr", prevAddr, - "prevRealm", prevRealm, - "halt", ufmt.Sprintf("%t", halt), - ) - } else { - std.Emit( - "UnsetHaltByAdmin", - "prevAddr", prevAddr, - "prevRealm", prevRealm, - "halt", ufmt.Sprintf("%t", halt), - ) - } } -// SetHalt sets the halt status. -// Only governance contract can execute this function via proposal +// SetHalt allows the governance contract to set the halt status of the GnoSwap. +// Only the governance contract can execute this function through a proposal process. +// +// Parameters: +// - halt (bool): The new halt status to set (true to halt, false to unhalt). +// +// Panics: +// - If the caller is not the governance contract, the function will panic with an errNoPermission error. func SetHalt(halt bool) { - caller := std.PrevRealm().Addr() - err := GovernanceOnly(caller) - if err != nil { - panic(addDetailToError( + caller := getPrevAddr() + if err := GovernanceOnly(caller); err != nil { + panic(newErrorWithDetail( errNoPermission, ufmt.Sprintf( "only governance(%s) can set halt, called from %s", @@ -75,27 +78,22 @@ func SetHalt(halt bool) { ), )) } - setHalt(halt) - - prevAddr, prevRealm := getPrev() - if halt { - std.Emit( - "SetHalt", - "prevAddr", prevAddr, - "prevRealm", prevRealm, - "halt", ufmt.Sprintf("%t", halt), - ) - } else { - std.Emit( - "UnsetHalt", - "prevAddr", prevAddr, - "prevRealm", prevRealm, - "halt", ufmt.Sprintf("%t", halt), - ) - } } +// setHalt updates the halted flag to the specified value. +// This is an internal function that should only be called by SetHalt or SetHaltByAdmin. +// +// Parameters: +// - halt (bool): The new halt status to set. func setHalt(halt bool) { halted = halt + + prevAddr, prevRealm := getPrevAsString() + std.Emit( + "setHalt", + "prevAddr", prevAddr, + "prevRealm", prevRealm, + "halt", ufmt.Sprintf("%t", halt), + ) } diff --git a/_deploy/r/gnoswap/common/limit_caller.gno b/_deploy/r/gnoswap/common/limit_caller.gno index d376b7605..c12d3611b 100644 --- a/_deploy/r/gnoswap/common/limit_caller.gno +++ b/_deploy/r/gnoswap/common/limit_caller.gno @@ -4,25 +4,49 @@ import ( "std" "gno.land/p/demo/ufmt" - - "gno.land/r/gnoswap/v1/consts" ) -var ( - limitCaller bool = true -) +// limitCaller is a global boolean flag that controls whether function calls are restricted. +// Default value is true, meaning call restrictions are enabled by default. +var limitCaller bool = true +// GetLimitCaller returns the current state of the limitCaller flag. +// If true, call restrictions are active; if false, call restrictions are disabled. +// +// Returns: +// - bool: Current state of the limitCaller (true if active, false if inactive). func GetLimitCaller() bool { return limitCaller } +// SetLimitCaller updates the limitCaller flag to either enable or disable call restrictions. +// This function can only be called by an admin. If a non-admin attempts to call this function, +// the function will panic. +// +// Parameters: +// - v (bool): The new state for the limitCaller flag (true to enable, false to disable). +// +// Panics: +// - If the caller is not an admin, the function panics with an errNoPermission error. func SetLimitCaller(v bool) { - caller := std.PrevRealm().Addr() - if caller != consts.ADMIN { - panic(addDetailToError( + caller := getPrevAddr() + if err := AdminOnly(caller); err != nil { + panic(newErrorWithDetail( errNoPermission, - ufmt.Sprintf("limit_caller.gno__SetLimitCaller() || only admin(%s) can set limit caller, called from %s", consts.ADMIN, caller), - )) + ufmt.Sprintf( + "only Admin can set halt, called from %s", + caller, + )), + ) } + limitCaller = v + + prevAddr, prevPkgPath := getPrevAsString() + std.Emit( + "SetLimitCaller", + "prevAddr", prevAddr, + "prevRealm", prevPkgPath, + "limitCaller", ufmt.Sprintf("%t", v), + ) } diff --git a/_deploy/r/gnoswap/common/tests/limit_caller_test.gno b/_deploy/r/gnoswap/common/limit_caller_test.gno similarity index 62% rename from _deploy/r/gnoswap/common/tests/limit_caller_test.gno rename to _deploy/r/gnoswap/common/limit_caller_test.gno index 7b98961ab..d8007140e 100644 --- a/_deploy/r/gnoswap/common/tests/limit_caller_test.gno +++ b/_deploy/r/gnoswap/common/limit_caller_test.gno @@ -5,12 +5,9 @@ import ( "testing" "gno.land/p/demo/uassert" - "gno.land/r/gnoswap/v1/consts" ) -var adminRealm = std.NewUserRealm(consts.ADMIN) - func TestSetLimitCaller(t *testing.T) { t.Run("initial check", func(t *testing.T) { uassert.True(t, GetLimitCaller()) @@ -18,13 +15,13 @@ func TestSetLimitCaller(t *testing.T) { t.Run("with non-admin privilege, panics", func(t *testing.T) { uassert.PanicsWithMessage(t, - `[GNOSWAP-COMMON-001] caller has no permission || limit_caller.gno__SetLimitCaller() || only admin(g17290cwvmrapvp869xfnhhawa8sm9edpufzat7d) can set limit caller, called from g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm`, + `[GNOSWAP-COMMON-001] caller has no permission || only Admin can set halt, called from g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm`, func() { SetLimitCaller(false) }, ) }) t.Run("with admin privilege, success", func(t *testing.T) { - std.TestSetRealm(adminRealm) + std.TestSetRealm(std.NewUserRealm(consts.ADMIN)) SetLimitCaller(false) uassert.False(t, GetLimitCaller()) }) diff --git a/_deploy/r/gnoswap/common/liquidity_amounts.gno b/_deploy/r/gnoswap/common/liquidity_amounts.gno index f312e656b..09be46b62 100644 --- a/_deploy/r/gnoswap/common/liquidity_amounts.gno +++ b/_deploy/r/gnoswap/common/liquidity_amounts.gno @@ -1,15 +1,12 @@ package common import ( - i256 "gno.land/p/gnoswap/int256" + "gno.land/p/demo/ufmt" u256 "gno.land/p/gnoswap/uint256" - - plp "gno.land/p/gnoswap/pool" - "gno.land/r/gnoswap/v1/consts" ) -// toAscendingOrder checkes if the first value is greater than +// toAscendingOrder checks if the first value is greater than // the second then swaps two values. func toAscendingOrder(a, b *u256.Uint) (*u256.Uint, *u256.Uint) { if a.Gt(b) { @@ -19,119 +16,299 @@ func toAscendingOrder(a, b *u256.Uint) (*u256.Uint, *u256.Uint) { return a, b } -// computeLiquidityForAmount0 calculates liquidity for a given amount of token 0. +// toUint128 ensures a *u256.Uint value fits within the uint128 range. +// +// This function validates that the given `value` is properly initialized (not nil) and checks whether +// it exceeds the maximum value of uint128. If the value exceeds the uint128 range, +// it applies a masking operation to truncate the value to fit within the uint128 limit. +// +// Parameters: +// - value: *u256.Uint, the value to be checked and possibly truncated. +// +// Returns: +// - *u256.Uint: A value guaranteed to fit within the uint128 range. +// +// Notes: +// - The function first checks if the value is not nil to avoid potential runtime errors. +// - The mask ensures that only the lower 128 bits of the value are retained. +// - If the input value is already within the uint128 range, it remains unchanged. +// - MAX_UINT128 is a constant representing `2^128 - 1`. +func toUint128(value *u256.Uint) *u256.Uint { + assertOnlyNotNil(value) + if value.Gt(u256.MustFromDecimal(consts.MAX_UINT128)) { + mask := new(u256.Uint).Lsh(u256.One(), consts.Q128_RESOLUTION) + mask = new(u256.Uint).Sub(mask, u256.One()) + value = value.And(value, mask) + } + return value +} + +// safeConvertToUint128 safely ensures a *u256.Uint value fits within the uint128 range. +// +// This function verifies that the provided unsigned 256-bit integer does not exceed the maximum value for uint128 (`2^128 - 1`). +// If the value is within the uint128 range, it is returned as is; otherwise, the function triggers a panic. +// +// Parameters: +// - value (*u256.Uint): The unsigned 256-bit integer to be checked. +// +// Returns: +// - *u256.Uint: The same value if it is within the uint128 range. +// +// Panics: +// - If the value exceeds the maximum uint128 value (`2^128 - 1`), the function will panic with a descriptive error +// indicating the overflow and the original value. +// +// Notes: +// - The constant `MAX_UINT128` is defined as `340282366920938463463374607431768211455` (the largest uint128 value). +// - No actual conversion occurs since the function works directly with *u256.Uint types. +// +// Example: +// validUint128 := safeConvertToUint128(u256.MustFromDecimal("340282366920938463463374607431768211455")) // Valid +// safeConvertToUint128(u256.MustFromDecimal("340282366920938463463374607431768211456")) // Panics due to overflow +func safeConvertToUint128(value *u256.Uint) *u256.Uint { + if value.Gt(u256.MustFromDecimal(consts.MAX_UINT128)) { + panic(ufmt.Sprintf( + "%v: amount(%s) overflows uint128 range", + errOverFlow, value.ToString())) + } + return value +} + +// computeLiquidityForAmount0 calculates the liquidity for a given amount of token0. +// +// This function computes the maximum possible liquidity that can be provided for `token0` +// based on the provided price boundaries (sqrtRatioAX96 and sqrtRatioBX96) in Q64.96 format. +// +// Parameters: +// - sqrtRatioAX96: *u256.Uint - The square root price at the lower tick boundary (Q64.96). +// - sqrtRatioBX96: *u256.Uint - The square root price at the upper tick boundary (Q64.96). +// - amount0: *u256.Uint - The amount of token0 to be converted to liquidity. +// +// Returns: +// - *u256.Uint: The calculated liquidity, represented as an unsigned 128-bit integer (uint128). +// +// Panics: +// - If the resulting liquidity exceeds the uint128 range, `safeConvertToUint128` will trigger a panic. func computeLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0 *u256.Uint) *u256.Uint { sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) - intermediate := u256.MulDiv(sqrtRatioAX96, sqrtRatioBX96, u256.MustFromDecimal(consts.Q96)) diff := new(u256.Uint).Sub(sqrtRatioBX96, sqrtRatioAX96) - + if diff.IsZero() { + panic(newErrorWithDetail( + errIdenticalTicks, + ufmt.Sprintf("sqrtRatioAX96 (%s) and sqrtRatioBX96 (%s) are identical", sqrtRatioAX96.ToString(), sqrtRatioBX96.ToString()), + )) + } res := u256.MulDiv(amount0, intermediate, diff) - return res + return safeConvertToUint128(res) } -// computeLiquidityForAmount1 calculates liquidity for a given amount of token 1. +// computeLiquidityForAmount1 calculates liquidity based on the provided token1 amount and price range. +// +// This function computes the liquidity for a given amount of token1 by using the difference +// between the upper and lower square root price ratios. The calculation uses Q96 fixed-point +// arithmetic to maintain precision. +// +// Parameters: +// - sqrtRatioAX96: *u256.Uint - The square root ratio of price at the lower tick, represented in Q96 format. +// - sqrtRatioBX96: *u256.Uint - The square root ratio of price at the upper tick, represented in Q96 format. +// - amount1: *u256.Uint - The amount of token1 to calculate liquidity for. +// +// Returns: +// - *u256.Uint: The calculated liquidity based on the provided amount of token1 and price range. +// +// Notes: +// - The result is not directly limited to uint128, as liquidity values can exceed uint128 bounds. +// - If `sqrtRatioAX96 == sqrtRatioBX96`, the function will panic due to division by zero. +// - Q96 is a constant representing `2^96`, ensuring that precision is maintained during division. +// +// Panics: +// - If the resulting liquidity exceeds the uint128 range, `safeConvertToUint128` will trigger a panic. func computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1 *u256.Uint) *u256.Uint { sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) diff := new(u256.Uint).Sub(sqrtRatioBX96, sqrtRatioAX96) - + if diff.IsZero() { + panic(newErrorWithDetail( + errIdenticalTicks, + ufmt.Sprintf("sqrtRatioAX96 (%s) and sqrtRatioBX96 (%s) are identical", sqrtRatioAX96.ToString(), sqrtRatioBX96.ToString()), + )) + } res := u256.MulDiv(amount1, u256.MustFromDecimal(consts.Q96), diff) - return res + return safeConvertToUint128(res) } -// GetLiquidityForAmounts calculates the liquidity for given amounts od token 0 and token 1. +// GetLiquidityForAmounts calculates the maximum liquidity given the current price (sqrtRatioX96), +// upper and lower price bounds (sqrtRatioAX96 and sqrtRatioBX96), and token amounts (amount0, amount1). +// +// This function evaluates how much liquidity can be obtained for specified amounts of token0 and token1 +// within the provided price range. It returns the lesser liquidity based on available token0 or token1 +// to ensure the pool remains balanced. +// +// Parameters: +// - sqrtRatioX96: The current price as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioAX96: The lower bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioBX96: The upper bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - amount0: The amount of token0 available to provide liquidity (*u256.Uint). +// - amount1: The amount of token1 available to provide liquidity (*u256.Uint). +// +// Returns: +// - *u256.Uint: The maximum possible liquidity that can be minted. +// +// Notes: +// - The `Clone` method is used to prevent modification of the original values during computation. +// - The function ensures that liquidity calculations handle edge cases when the current price +// is outside the specified range by returning liquidity based on the dominant token. func GetLiquidityForAmounts(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, amount0, amount1 *u256.Uint) *u256.Uint { - sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) + if amount0.IsZero() || amount1.IsZero() { + return u256.Zero() + } + + sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96.Clone(), sqrtRatioBX96.Clone()) var liquidity *u256.Uint if sqrtRatioX96.Lte(sqrtRatioAX96) { - liquidity = computeLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0) + liquidity = computeLiquidityForAmount0(sqrtRatioAX96.Clone(), sqrtRatioBX96.Clone(), amount0.Clone()) } else if sqrtRatioX96.Lt(sqrtRatioBX96) { - liquidity0 := computeLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0) - liquidity1 := computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1) + liquidity0 := computeLiquidityForAmount0(sqrtRatioX96.Clone(), sqrtRatioBX96.Clone(), amount0.Clone()) + liquidity1 := computeLiquidityForAmount1(sqrtRatioAX96.Clone(), sqrtRatioX96.Clone(), amount1.Clone()) if liquidity0.Lt(liquidity1) { liquidity = liquidity0 } else { liquidity = liquidity1 } - } else { - liquidity = computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1) + liquidity = computeLiquidityForAmount1(sqrtRatioAX96.Clone(), sqrtRatioBX96.Clone(), amount1.Clone()) } - return liquidity } -// computeAmount0ForLiquidity calculates the amount of token0 for a given liquidity. +// computeAmount0ForLiquidity calculates the required amount of token0 for a given liquidity level +// within a specified price range (represented by sqrt ratios). +// +// This function determines the amount of token0 needed to provide a specified amount of liquidity +// within a price range defined by sqrtRatioAX96 (lower bound) and sqrtRatioBX96 (upper bound). +// +// Parameters: +// - sqrtRatioAX96: The lower bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioBX96: The upper bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - liquidity: The liquidity to be provided (*u256.Uint). +// +// Returns: +// - *u256.Uint: The amount of token0 required to achieve the specified liquidity level. +// +// Notes: +// - This function assumes the price bounds are expressed in Q64.96 fixed-point format. +// - The function returns 0 if the liquidity is 0 or the price bounds are invalid. +// - Handles edge cases where sqrtRatioAX96 equals sqrtRatioBX96 by returning 0 (to prevent division by zero). func computeAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity *u256.Uint) *u256.Uint { sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) + if sqrtRatioAX96.IsZero() || sqrtRatioBX96.IsZero() || liquidity.IsZero() || sqrtRatioAX96.Eq(sqrtRatioBX96) { + return u256.Zero() + } - val1 := new(u256.Uint).Lsh(liquidity, 96) + val1 := new(u256.Uint).Lsh(liquidity, consts.Q96_RESOLUTION) val2 := new(u256.Uint).Sub(sqrtRatioBX96, sqrtRatioAX96) res := u256.MulDiv(val1, val2, sqrtRatioBX96) - - res = res.Div(res, sqrtRatioAX96) + res = new(u256.Uint).Div(res, sqrtRatioAX96) return res } -// computeAmount1ForLiquidity calculates the amount of token1 for a given liquidity. +// computeAmount1ForLiquidity calculates the required amount of token1 for a given liquidity level +// within a specified price range (represented by sqrt ratios). +// +// This function determines the amount of token1 needed to provide liquidity between the +// lower (sqrtRatioAX96) and upper (sqrtRatioBX96) price bounds. The calculation is performed +// in Q64.96 fixed-point format, which is standard for many liquidity calculations. +// +// Parameters: +// - sqrtRatioAX96: The lower bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioBX96: The upper bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - liquidity: The liquidity amount to be used in the calculation (*u256.Uint). +// +// Returns: +// - *u256.Uint: The amount of token1 required to achieve the specified liquidity level. +// +// Notes: +// - This function handles edge cases where the liquidity is zero or when sqrtRatioAX96 equals sqrtRatioBX96 +// to prevent division by zero. +// - The calculation assumes sqrtRatioAX96 is always less than or equal to sqrtRatioBX96 after the initial +// ascending order sorting. func computeAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity *u256.Uint) *u256.Uint { sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) + if liquidity.IsZero() || sqrtRatioAX96.Eq(sqrtRatioBX96) { + return u256.Zero() + } - val2 := new(u256.Uint).Sub(sqrtRatioBX96, sqrtRatioAX96) + diff := new(u256.Uint).Sub(sqrtRatioBX96, sqrtRatioAX96) + res := u256.MulDiv(liquidity, diff, u256.MustFromDecimal(consts.Q96)) - res := u256.MulDiv(liquidity, val2, u256.MustFromDecimal(consts.Q96)) return res } -// GetAmountsForLiquidity calculates the amounts of token0 and token1 for a given liquidity. -func GetAmountsForLiquidity(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96 *u256.Uint, liquidity *i256.Int) (string, string) { - var amount0, amount1 *i256.Int - - if !(liquidity.IsZero()) { - if sqrtRatioX96.Lt(sqrtRatioAX96) { // currentTick < tickLower - _amount0Str := plp.SqrtPriceMathGetAmount0DeltaStr( - sqrtRatioAX96, - sqrtRatioBX96, - liquidity, - ) - amount0 = i256.MustFromDecimal(_amount0Str) - - } else if sqrtRatioX96.Lt(sqrtRatioBX96) { // currentTick < tickUpper - _amount0Str := plp.SqrtPriceMathGetAmount0DeltaStr( - sqrtRatioX96, - sqrtRatioBX96, - liquidity, - ) - amount0 = i256.MustFromDecimal(_amount0Str) - - _amount1Str := plp.SqrtPriceMathGetAmount1DeltaStr( - sqrtRatioAX96, - sqrtRatioX96, - liquidity, - ) - amount1 = i256.MustFromDecimal(_amount1Str) +// GetAmountsForLiquidity calculates the amounts of token0 and token1 required +// to provide a specified liquidity within a price range. +// +// This function determines the quantities of token0 and token1 necessary to achieve +// a given liquidity level, depending on the current price (sqrtRatioX96) and the +// bounds of the price range (sqrtRatioAX96 and sqrtRatioBX96). The function returns +// the calculated amounts of token0 and token1 as strings. +// +// If the current price is below the lower bound of the price range, only token0 is required. +// If the current price is above the upper bound, only token1 is required. When the +// price is within the range, both token0 and token1 are calculated. +// +// Parameters: +// - sqrtRatioX96: The current price represented as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioAX96: The lower bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - sqrtRatioBX96: The upper bound of the price range as a square root ratio in Q64.96 format (*u256.Uint). +// - liquidity: The amount of liquidity to be provided (*u256.Uint). +// +// Returns: +// - string: The calculated amount of token0 required to achieve the specified liquidity. +// - string: The calculated amount of token1 required to achieve the specified liquidity. +// +// Notes: +// - If liquidity is zero, the function returns "0" for both token0 and token1. +// - The function guarantees that sqrtRatioAX96 is always the lower bound and +// sqrtRatioBX96 is the upper bound by calling toAscendingOrder(). +// - Edge cases where the current price is exactly on the bounds are handled without division by zero. +// +// Example: +// ``` +// amount0, amount1 := GetAmountsForLiquidity( +// +// u256.MustFromDecimal("79228162514264337593543950336"), // sqrtRatioX96 (1.0 in Q64.96) +// u256.MustFromDecimal("39614081257132168796771975168"), // sqrtRatioAX96 (0.5 in Q64.96) +// u256.MustFromDecimal("158456325028528675187087900672"), // sqrtRatioBX96 (2.0 in Q64.96) +// u256.MustFromDecimal("1000000"), // Liquidity +// +// ) +// fmt.Println("Token0:", amount0, "Token1:", amount1) +// // Example output: Token0: 500000, Token1: 250000 +// ``` +func GetAmountsForLiquidity(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, liquidity *u256.Uint) (string, string) { + if liquidity.IsZero() { + return "0", "0" + } - } else { - _amount1Str := plp.SqrtPriceMathGetAmount1DeltaStr( - sqrtRatioAX96, - sqrtRatioBX96, - liquidity, - ) - amount1 = i256.MustFromDecimal(_amount1Str) - } + sqrtRatioAX96, sqrtRatioBX96 = toAscendingOrder(sqrtRatioAX96, sqrtRatioBX96) - } + amount0 := u256.Zero() + amount1 := u256.Zero() - // if position is out of range, one of amount0 or amount1 can be nil - // > handle as 0 - amount0 = amount0.NilToZero() - amount1 = amount1.NilToZero() + if sqrtRatioX96.Lte(sqrtRatioAX96) { + amount0 = computeAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity) + } else if sqrtRatioX96.Lt(sqrtRatioBX96) { + amount0 = computeAmount0ForLiquidity(sqrtRatioX96, sqrtRatioBX96, liquidity) + amount1 = computeAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioX96, liquidity) + } else { + amount1 = computeAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity) + } return amount0.ToString(), amount1.ToString() } diff --git a/_deploy/r/gnoswap/common/liquidity_amounts_test.gno b/_deploy/r/gnoswap/common/liquidity_amounts_test.gno new file mode 100644 index 000000000..d471db2d9 --- /dev/null +++ b/_deploy/r/gnoswap/common/liquidity_amounts_test.gno @@ -0,0 +1,506 @@ +package common + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + u256 "gno.land/p/gnoswap/uint256" + "gno.land/r/gnoswap/v1/consts" +) + +func TestToAscendingOrder(t *testing.T) { + tests := []struct { + name string + a *u256.Uint + b *u256.Uint + expectedA string + expectedB string + }{ + { + name: "Ascending order - a < b", + a: u256.MustFromDecimal("10"), + b: u256.MustFromDecimal("20"), + expectedA: "10", + expectedB: "20", + }, + { + name: "Descending order - a > b", + a: u256.MustFromDecimal("50"), + b: u256.MustFromDecimal("30"), + expectedA: "30", + expectedB: "50", + }, + { + name: "Equal values - a == b", + a: u256.MustFromDecimal("100"), + b: u256.MustFromDecimal("100"), + expectedA: "100", + expectedB: "100", + }, + { + name: "Large numbers", + a: u256.MustFromDecimal("340282366920938463463374607431768211455"), // 2^128 - 1 + b: u256.MustFromDecimal("170141183460469231731687303715884105727"), // 2^127 - 1 + expectedA: "170141183460469231731687303715884105727", + expectedB: "340282366920938463463374607431768211455", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + min, max := toAscendingOrder(tt.a, tt.b) + + if min.ToString() != tt.expectedA { + t.Errorf("Expected min to be %s, got %s", tt.expectedA, min.ToString()) + } + + if max.ToString() != tt.expectedB { + t.Errorf("Expected max to be %s, got %s", tt.expectedB, max.ToString()) + } + }) + } +} + +func TestSafeConvertToUint128(t *testing.T) { + tests := []struct { + name string + input *u256.Uint + expected *u256.Uint + shouldPanic bool + }{ + { + name: "Valid uint128 value", + input: u256.MustFromDecimal("340282366920938463463374607431768211455"), // MAX_UINT128 + expected: u256.MustFromDecimal("340282366920938463463374607431768211455"), + shouldPanic: false, + }, + { + name: "Value exceeding uint128 range", + input: u256.MustFromDecimal("340282366920938463463374607431768211456"), // MAX_UINT128 + 1 + shouldPanic: true, + }, + { + name: "Zero value", + input: u256.Zero(), + expected: u256.Zero(), + shouldPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.shouldPanic { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic but got none") + } + }() + safeConvertToUint128(tt.input) + } else { + got := safeConvertToUint128(tt.input) + if !got.Eq(tt.expected) { + t.Errorf("Expected %s, got %s", tt.expected.ToString(), got.ToString()) + } + } + }) + } +} + +func TestComputeLiquidityForAmount0(t *testing.T) { + testCases := []struct { + name string + sqrtRatioA string + sqrtRatioB string + amount0 string + expected string + expectPanic bool + }{ + { + name: "Basic liquidity calculation", + sqrtRatioA: "79228162514264337593543950336", // sqrt(1) << 96 + sqrtRatioB: "158456325028528675187087900672", // sqrt(4) << 96 + amount0: "1000000", + expected: "2000000", // Expected liquidity + }, + { + name: "No liquidity (zero amount)", + sqrtRatioA: "79228162514264337593543950336", + sqrtRatioB: "158456325028528675187087900672", + amount0: "0", + expected: "0", + }, + { + name: "Liquidity overflow (exceeds uint128)", + sqrtRatioA: "158456325028528675187087900672", + sqrtRatioB: "316912650057057350374175801344", + amount0: "340282366920938463463374607431768211456", // Exceeds uint128 + expectPanic: true, + }, + { + name: "Zero liquidity with equal ratios", + sqrtRatioA: "79228162514264337593543950336", + sqrtRatioB: "79228162514264337593543950336", + amount0: "1000000", + expected: "0", + expectPanic: true, + }, + { + name: "Panic with identical ticks", + sqrtRatioA: "79228162514264337593543950336", + sqrtRatioB: "79228162514264337593543950336", + amount0: "1000000", + expectPanic: true, + }, + { + name: "Large liquidity calculation", + sqrtRatioA: "79228162514264337593543950336", + sqrtRatioB: "158456325028528675187087900672", + amount0: "1000000000", + expected: "2000000000", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tc.expectPanic { + t.Errorf("Unexpected panic for test case: %s", tc.name) + } + } + }() + + sqrtRatioA := u256.MustFromDecimal(tc.sqrtRatioA) + sqrtRatioB := u256.MustFromDecimal(tc.sqrtRatioB) + amount0 := u256.MustFromDecimal(tc.amount0) + + result := computeLiquidityForAmount0(sqrtRatioA, sqrtRatioB, amount0) + if !tc.expectPanic { + if result.ToString() != tc.expected { + t.Errorf("Expected %s but got %s", tc.expected, result.ToString()) + } + } + }) + } +} + +func TestComputeLiquidityForAmount1(t *testing.T) { + q96 := u256.MustFromDecimal(consts.Q96) + amount1 := u256.MustFromDecimal("1000000") + + t.Run("Basic liquidity calculation", func(t *testing.T) { + sqrtRatioAX96 := q96 // 2^96 (1 in Q96) + sqrtRatioBX96 := new(u256.Uint).Mul(q96, u256.MustFromDecimal("4")) // 4^96 (4 in Q96) + + expected := u256.MustFromDecimal("333333") // Expected liquidity + result := computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1) + + if !result.Eq(expected) { + t.Errorf("Expected %s but got %s", expected.ToString(), result.ToString()) + } + }) + + t.Run("Zero liquidity with equal ratios", func(t *testing.T) { + sqrtRatioAX96 := q96 // 2^96 (1 in Q96) + sqrtRatioBX96 := q96 // Same as lower tick + + uassert.PanicsWithMessage(t, + "[GNOSWAP-COMMON-010] identical ticks || sqrtRatioAX96 (79228162514264337593543950336) and sqrtRatioBX96 (79228162514264337593543950336) are identical", + func() { + _ = computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1) + }) + }) + + t.Run("Large liquidity calculation", func(t *testing.T) { + sqrtRatioAX96 := q96 // 1x + sqrtRatioBX96 := new(u256.Uint).Mul(q96, u256.NewUint(16)) // 16x + largeAmount := u256.MustFromDecimal("1000000000") + + expected := u256.MustFromDecimal("66666666") // 1B / 16 = 62.5M + result := computeLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, largeAmount) + + if !result.Eq(expected) { + t.Errorf("Expected %s but got %s", expected.ToString(), result.ToString()) + } + }) +} + +func TestGetLiquidityForAmounts(t *testing.T) { + q96 := u256.MustFromDecimal(consts.Q96) + + tests := []struct { + name string + sqrtRatioX96 string + sqrtRatioAX96 string + sqrtRatioBX96 string + amount0 string + amount1 string + expected string + }{ + { + name: "Basic Liquidity Calculation - Token0 Dominant", + sqrtRatioX96: q96.ToString(), // 현재 가격이 Q96 + sqrtRatioAX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioBX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + amount0: "1000000", + amount1: "1000000", + expected: "5333333", + }, + { + name: "Within Range - Both Token0 and Token1", + sqrtRatioX96: (new(u256.Uint).Mul(q96, u256.NewUint(2))).ToString(), + sqrtRatioAX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioBX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + amount0: "2000000", + amount1: "3000000", + expected: "10666666", + }, + { + name: "Token1 Dominant - Price Above Upper Bound", + sqrtRatioX96: (new(u256.Uint).Mul(q96, u256.NewUint(20))).ToString(), + sqrtRatioAX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioBX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + amount0: "1500000", + amount1: "3000000", + expected: "250000", + }, + { + name: "Edge Case - sqrtRatioX96 = Lower Bound", + sqrtRatioX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioAX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioBX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + amount0: "500000", + amount1: "500000", + expected: "2666666", + }, + { + name: "Edge Case - sqrtRatioX96 = Upper Bound", + sqrtRatioX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + sqrtRatioAX96: (new(u256.Uint).Mul(q96, u256.NewUint(4))).ToString(), + sqrtRatioBX96: (new(u256.Uint).Mul(q96, u256.NewUint(16))).ToString(), + amount0: "1000000", + amount1: "1000000", + expected: "83333", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sqrtRatioX96 := u256.MustFromDecimal(tc.sqrtRatioX96) + sqrtRatioAX96 := u256.MustFromDecimal(tc.sqrtRatioAX96) + sqrtRatioBX96 := u256.MustFromDecimal(tc.sqrtRatioBX96) + amount0 := u256.MustFromDecimal(tc.amount0) + amount1 := u256.MustFromDecimal(tc.amount1) + + result := GetLiquidityForAmounts(sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, amount0, amount1) + expected := u256.MustFromDecimal(tc.expected) + + uassert.Equal(t, expected.ToString(), result.ToString()) + }) + } +} + +func TestComputeAmount0ForLiquidity(t *testing.T) { + q96 := u256.MustFromDecimal("79228162514264337593543950336") // 2^96 + + tests := []struct { + name string + sqrtRatioAX96 string + sqrtRatioBX96 string + liquidity string + expected string + }{ + { + name: "Basic Case - Small Range", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(4)).ToString(), + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)).ToString(), + liquidity: "1000000", + expected: "125000", + }, + { + name: "Large Liquidity - Wide Range", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(2)).ToString(), + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(16)).ToString(), + liquidity: "5000000000", + expected: "2187500000", + }, + { + name: "Edge Case - Equal Bounds", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(8)).ToString(), + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)).ToString(), + liquidity: "1000000", + expected: "0", + }, + { + name: "Minimum Liquidity", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(5)).ToString(), + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(10)).ToString(), + liquidity: "1", + expected: "0", + }, + { + name: "Max Liquidity", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(1)).ToString(), + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(32)).ToString(), + liquidity: "1000000000000000000", + expected: "968750000000000000", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sqrtRatioAX96 := u256.MustFromDecimal(tc.sqrtRatioAX96) + sqrtRatioBX96 := u256.MustFromDecimal(tc.sqrtRatioBX96) + liquidity := u256.MustFromDecimal(tc.liquidity) + + result := computeAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity) + expected := u256.MustFromDecimal(tc.expected) + + if result.ToString() != expected.ToString() { + t.Errorf("expected %s but got %s", expected.ToString(), result.ToString()) + } + }) + } +} + +func TestComputeAmount1ForLiquidity(t *testing.T) { + q96 := u256.MustFromDecimal(consts.Q96) // 2^96 = 79228162514264337593543950336 + + tests := []struct { + name string + sqrtRatioAX96 *u256.Uint + sqrtRatioBX96 *u256.Uint + liquidity *u256.Uint + expectedAmount string + }{ + { + name: "Basic Case - Small Liquidity", + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(4)), // sqrtRatioBX96 = 4 * Q96 + liquidity: u256.NewUint(1000000000), + expectedAmount: "3000000000", // (4-1)*liquidity = 3 * 10^9 + }, + { + name: "Edge Case - Equal Ratios", + sqrtRatioAX96: q96, + sqrtRatioBX96: q96, + liquidity: u256.NewUint(1000000), + expectedAmount: "0", + }, + { + name: "Zero Liquidity", + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(2)), + liquidity: u256.Zero(), + expectedAmount: "0", + }, + { + name: "Large Liquidity", + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(16)), // sqrtRatioBX96 = 16 * Q96 + liquidity: u256.NewUint(1000000000000000000), // 1e18 liquidity + expectedAmount: "15000000000000000000", // (16-1) * 1e18 = 15 * 1e18 + }, + { + name: "Descending Ratios (Order Correction)", + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + sqrtRatioBX96: q96, + liquidity: u256.NewUint(500000), + expectedAmount: "3500000", // (8-1)*500000 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := computeAmount1ForLiquidity(tt.sqrtRatioAX96, tt.sqrtRatioBX96, tt.liquidity) + uassert.Equal(t, tt.expectedAmount, result.ToString(), ufmt.Sprintf("expected %s but got %s", tt.expectedAmount, result.ToString())) + }) + } +} + +func TestGetAmountsForLiquidity(t *testing.T) { + q96 := u256.MustFromDecimal(consts.Q96) // 2^96 = 79228162514264337593543950336 + + tests := []struct { + name string + sqrtRatioX96 *u256.Uint + sqrtRatioAX96 *u256.Uint + sqrtRatioBX96 *u256.Uint + liquidity *u256.Uint + expectedAmount0 string + expectedAmount1 string + }{ + { + name: "Basic Case - Within Range", + sqrtRatioX96: new(u256.Uint).Mul(q96, u256.NewUint(2)), // Current price at 2 * Q96 + sqrtRatioAX96: q96, // Lower bound at 1 * Q96 + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(4)), // Upper bound at 4 * Q96 + liquidity: u256.NewUint(1000000), + expectedAmount0: "250000", + expectedAmount1: "1000000", + }, + { + name: "Edge Case - At Lower Bound (sqrtRatioX96 == sqrtRatioAX96)", + sqrtRatioX96: q96, + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + liquidity: u256.NewUint(1000000), + expectedAmount0: "875000", + expectedAmount1: "0", + }, + { + name: "Edge Case - At Upper Bound (sqrtRatioX96 == sqrtRatioBX96)", + sqrtRatioX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + liquidity: u256.NewUint(1000000), + expectedAmount0: "0", + expectedAmount1: "7000000", + }, + { + name: "Out of Range - Below Lower Bound", + sqrtRatioX96: new(u256.Uint).Div(q96, u256.NewUint(2)), // Current price at 0.5 * Q96 + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + liquidity: u256.NewUint(500000), + expectedAmount0: "437500", + expectedAmount1: "0", + }, + { + name: "Out of Range - Above Upper Bound", + sqrtRatioX96: new(u256.Uint).Mul(q96, u256.NewUint(10)), // Current price at 10 * Q96 + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + liquidity: u256.NewUint(2000000), + expectedAmount0: "0", + expectedAmount1: "14000000", + }, + { + name: "Zero Liquidity", + sqrtRatioX96: q96, + sqrtRatioAX96: q96, + sqrtRatioBX96: new(u256.Uint).Mul(q96, u256.NewUint(16)), + liquidity: u256.Zero(), + expectedAmount0: "0", + expectedAmount1: "0", + }, + { + name: "Descending Ratios (Order Correction)", + sqrtRatioX96: q96, + sqrtRatioAX96: new(u256.Uint).Mul(q96, u256.NewUint(8)), + sqrtRatioBX96: q96, + liquidity: u256.NewUint(1000000), + expectedAmount0: "875000", + expectedAmount1: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amount0, amount1 := GetAmountsForLiquidity(tt.sqrtRatioX96, tt.sqrtRatioAX96, tt.sqrtRatioBX96, tt.liquidity) + uassert.Equal(t, tt.expectedAmount0, amount0, ufmt.Sprintf("expected %s but got %s for amount0", tt.expectedAmount0, amount0)) + uassert.Equal(t, tt.expectedAmount1, amount1, ufmt.Sprintf("expected %s but got %s for amount1", tt.expectedAmount1, amount1)) + }) + } +} diff --git a/_deploy/r/gnoswap/common/math.gno b/_deploy/r/gnoswap/common/math.gno index ae626926b..ac7a67b2d 100644 --- a/_deploy/r/gnoswap/common/math.gno +++ b/_deploy/r/gnoswap/common/math.gno @@ -77,7 +77,7 @@ func U256Max(x, y *u256.Uint) *u256.Uint { // If the value is greater than the maximum int256 value, it panics. func SafeConvertUint256ToInt256(x *u256.Uint) *i256.Int { if x.Gt(u256.MustFromDecimal(consts.MAX_INT256)) { - panic(addDetailToError( + panic(newErrorWithDetail( errOverflow, ufmt.Sprintf("can not convert %s to int256", x.ToString()), )) @@ -90,7 +90,7 @@ func SafeConvertUint256ToInt256(x *u256.Uint) *i256.Int { func SafeConvertUint256ToUint64(x *u256.Uint) uint64 { value, overflow := x.Uint64WithOverflow() if overflow { - panic(addDetailToError( + panic(newErrorWithDetail( errOverflow, ufmt.Sprintf("can not convert %s to uint64", x.ToString()), )) diff --git a/_deploy/r/gnoswap/common/tick_math.gno b/_deploy/r/gnoswap/common/tick_math.gno index 2a0bc689a..646573d77 100644 --- a/_deploy/r/gnoswap/common/tick_math.gno +++ b/_deploy/r/gnoswap/common/tick_math.gno @@ -1,101 +1,233 @@ package common import ( + "gno.land/p/demo/avl" "gno.land/p/demo/ufmt" i256 "gno.land/p/gnoswap/int256" u256 "gno.land/p/gnoswap/uint256" ) -var tickRatioMap = map[int32]*u256.Uint{ - 0x1: u256.MustFromDecimal("340265354078544963557816517032075149313"), // 0xfffcb933bd6fad37aa2d162d1a594001, - 0x2: u256.MustFromDecimal("340248342086729790484326174814286782778"), // 0xfff97272373d413259a46990580e213a, - 0x4: u256.MustFromDecimal("340214320654664324051920982716015181260"), // 0xfff2e50f5f656932ef12357cf3c7fdcc, - 0x8: u256.MustFromDecimal("340146287995602323631171512101879684304"), // 0xffe5caca7e10e4e61c3624eaa0941cd0, - 0x10: u256.MustFromDecimal("340010263488231146823593991679159461444"), // 0xffcb9843d60f6159c9db58835c926644, - 0x20: u256.MustFromDecimal("339738377640345403697157401104375502016"), // 0xff973b41fa98c081472e6896dfb254c0, - 0x40: u256.MustFromDecimal("339195258003219555707034227454543997025"), // 0xff2ea16466c96a3843ec78b326b52861, - 0x80: u256.MustFromDecimal("338111622100601834656805679988414885971"), // 0xfe5dee046a99a2a811c461f1969c3053, - 0x100: u256.MustFromDecimal("335954724994790223023589805789778977700"), // 0xfcbe86c7900a88aedcffc83b479aa3a4, - 0x200: u256.MustFromDecimal("331682121138379247127172139078559817300"), // 0xf987a7253ac413176f2b074cf7815e54, - 0x400: u256.MustFromDecimal("323299236684853023288211250268160618739"), // 0xf3392b0822b70005940c7a398e4b70f3, - 0x800: u256.MustFromDecimal("307163716377032989948697243942600083929"), // 0xe7159475a2c29b7443b29c7fa6e889d9, - 0x1000: u256.MustFromDecimal("277268403626896220162999269216087595045"), // 0xd097f3bdfd2022b8845ad8f792aa5825, - 0x2000: u256.MustFromDecimal("225923453940442621947126027127485391333"), // 0xa9f746462d870fdf8a65dc1f90e061e5, - 0x4000: u256.MustFromDecimal("149997214084966997727330242082538205943"), // 0x70d869a156d2a1b890bb3df62baf32f7, - 0x8000: u256.MustFromDecimal("66119101136024775622716233608466517926"), // 0x31be135f97d08fd981231505542fcfa6, - 0x10000: u256.MustFromDecimal("12847376061809297530290974190478138313"), // 0x9aa508b5b7a84e1c677de54f3e99bc9, - 0x20000: u256.MustFromDecimal("485053260817066172746253684029974020"), // 0x5d6af8dedb81196699c329225ee604, - 0x40000: u256.MustFromDecimal("691415978906521570653435304214168"), // 0x2216e584f5fa1ea926041bedfe98, - 0x80000: u256.MustFromDecimal("1404880482679654955896180642"), // 0x48a170391f7dc42444e8fa2, -} - -var binaryLogConsts = [8]*u256.Uint{ - u256.MustFromDecimal("0"), // 0x0, - u256.MustFromDecimal("3"), // 0x3, - u256.MustFromDecimal("15"), // 0xF, - u256.MustFromDecimal("255"), // 0xFF, - u256.MustFromDecimal("65535"), // 0xFFFF, - u256.MustFromDecimal("4294967295"), // 0xFFFFFFFF, - u256.MustFromDecimal("18446744073709551615"), // 0xFFFFFFFFFFFFFFFF, - u256.MustFromDecimal("340282366920938463463374607431768211455"), // 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, -} +const ( + MAX_UINT8 string = "255" + MAX_UINT16 string = "65535" + MAX_UINT32 string = "4294967295" + MAX_UINT64 string = "18446744073709551615" + MAX_UINT128 string = "340282366920938463463374607431768211455" + MAX_UINT160 string = "1461501637330902918203684832716283019655932542975" + MAX_UINT256 string = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + MAX_INT256 string = "57896044618658097711785492504343953926634992332820282019728792003956564819967" + + Q64 string = "18446744073709551616" // 2 ** 64 + Q96 string = "79228162514264337593543950336" // 2 ** 96 + Q128 string = "340282366920938463463374607431768211456" // 2 ** 128 +) var ( + tickRatioTree = NewTickRatioTree() + binaryLogTree = NewBinaryLogTree() + shift1By32Left = u256.MustFromDecimal("4294967296") // (1 << 32) maxTick = int32(887272) ) +// TreeValue wraps u256.Uint to implement custom value type for avl.Tree +type TreeValue struct { + value *u256.Uint +} + +func (tv TreeValue) String() string { + return tv.value.ToString() +} + +// TickRatioTree manages tick ratios +type TickRatioTree struct { + tree *avl.Tree +} + +// NewTickRatioTree initializes a new TickRatioTree with predefined values +func NewTickRatioTree() *TickRatioTree { + tree := avl.NewTree() + + ratios := []struct { + key int32 + value string + }{ + {0x1, "340265354078544963557816517032075149313"}, + {0x2, "340248342086729790484326174814286782778"}, + {0x4, "340214320654664324051920982716015181260"}, + {0x8, "340146287995602323631171512101879684304"}, + {0x10, "340010263488231146823593991679159461444"}, + {0x20, "339738377640345403697157401104375502016"}, + {0x40, "339195258003219555707034227454543997025"}, + {0x80, "338111622100601834656805679988414885971"}, + {0x100, "335954724994790223023589805789778977700"}, + {0x200, "331682121138379247127172139078559817300"}, + {0x400, "323299236684853023288211250268160618739"}, + {0x800, "307163716377032989948697243942600083929"}, + {0x1000, "277268403626896220162999269216087595045"}, + {0x2000, "225923453940442621947126027127485391333"}, + {0x4000, "149997214084966997727330242082538205943"}, + {0x8000, "66119101136024775622716233608466517926"}, + {0x10000, "12847376061809297530290974190478138313"}, + {0x20000, "485053260817066172746253684029974020"}, + {0x40000, "691415978906521570653435304214168"}, + {0x80000, "1404880482679654955896180642"}, + } + + for _, ratio := range ratios { + tick := ufmt.Sprintf("%d", ratio.key) + value := TreeValue{u256.MustFromDecimal(ratio.value)} + tree.Set(tick, value) + } + + return &TickRatioTree{tree} +} + +// GetRatio retrieves the ratio for a given tick +func (t *TickRatioTree) GetRatio(key int32) (*u256.Uint, bool) { + strKey := ufmt.Sprintf("%d", key) + value, exists := t.tree.Get(strKey) + + if !exists { + return nil, false + } + + if tv, ok := value.(TreeValue); ok { + return tv.value, true + } + + return nil, false +} + +// BinaryLogTree manages binary log constants +type BinaryLogTree struct { + tree *avl.Tree +} + +// NewBinaryLogTree initializes a new BinaryLogTree +func NewBinaryLogTree() *BinaryLogTree { + tree := avl.NewTree() + + logs := [8]string{ + "0", + "3", + "15", + "255", + "65535", + "4294967295", + "18446744073709551615", + "340282366920938463463374607431768211455", + } + + for i, value := range logs { + key := ufmt.Sprintf("%d", i) + tree.Set(key, TreeValue{u256.MustFromDecimal(value)}) + } + + return &BinaryLogTree{tree} +} + +// GetLog retrieves the binary log constant at given index +func (t *BinaryLogTree) GetLog(idx int) (*u256.Uint, bool) { + strKey := ufmt.Sprintf("%d", idx) + value, exists := t.tree.Get(strKey) + + if !exists { + return nil, false + } + + if tv, ok := value.(TreeValue); ok { + return tv.value, true + } + + return nil, false +} + +// TickMathGetSqrtRatioAtTick calculates the square root price ratio for a given tick. +// +// This function computes the square root ratio (sqrt(price)) at a specific tick, +// using a precomputed mapping of ratios. The result is returned as a 160-bit +// fixed-point value (Q64.96 format). +// +// Parameters: +// - tick (int32): The tick index for which the square root ratio is calculated. +// +// Returns: +// - *u256.Uint: The square root price ratio at the given tick, represented as a 160-bit unsigned integer. +// +// Behavior: +// 1. Validates that the tick is within the acceptable range by asserting its absolute value. +// 2. Initializes the ratio based on whether the least significant bit of the tick is set, using a lookup table (`tickRatioMap`). +// 3. Iteratively adjusts the ratio by multiplying and right-shifting with precomputed values for each relevant bit set in the tick value. +// 4. If the tick is positive, the ratio is inverted by dividing a maximum uint256 value by the computed ratio. +// 5. The result is split into upper 128 bits and lower 32 bits for precision handling. +// 6. If the lower 32 bits are non-zero, the upper 128 bits are incremented by 1 to ensure rounding up. +// +// Example: +// - For a tick of `0`, the ratio represents `1.0000` in Q64.96 format. +// - For a tick of `-887272` (minimum tick), the ratio represents the smallest possible price. +// - For a tick of `887272` (maximum tick), the ratio represents the highest possible price. +// +// Panics: +// - If the absolute tick value exceeds the maximum allowed tick range. +// +// Notes: +// - The function relies on a precomputed map `tickRatioMap` to optimize calculations. +// - Handles rounding by adding 1 if the remainder of the division is non-zero. func TickMathGetSqrtRatioAtTick(tick int32) *u256.Uint { // uint160 sqrtPriceX96 absTick := abs(tick) - if absTick > maxTick { - panic(addDetailToError( - errOutOfRange, - ufmt.Sprintf("tick is out of range (larger than 887272), tick: %d", tick), - )) - } + assertValidTickRange(absTick) - var initialBit int32 = 0x1 - var ratio *u256.Uint - if (absTick & initialBit) != 0 { - ratio = tickRatioMap[initialBit] - } else { - ratio = u256.MustFromDecimal("340282366920938463463374607431768211456") // consts.Q128 + ratio := u256.MustFromDecimal("340282366920938463463374607431768211456") // consts.Q128 + initialBit := int32(0x1) + + if val, exists := tickRatioTree.GetRatio(initialBit); exists && (absTick&initialBit) != 0 { + ratio = val } - for mask, value := range tickRatioMap { - if (mask != initialBit) && absTick&mask != 0 { - // ratio = (ratio * value) >> 128 - ratio = ratio.Mul(ratio, value) - ratio = ratio.Rsh(ratio, 128) + calculateRatio := func(mask int32) *u256.Uint { + if value, exists := tickRatioTree.GetRatio(mask); exists && absTick&mask != 0 { + return new(u256.Uint).Rsh( + new(u256.Uint).Mul(ratio, value), + 128, + ) } + return ratio + } + + for mask := int32(0x2); mask <= 0x80000; mask *= 2 { + ratio = calculateRatio(mask) } if tick > 0 { - maxUint256 := u256.MustFromDecimal("115792089237316195423570985008687907853269984665640564039457584007913129639935") // consts.MAX_UINT256 + maxUint256 := u256.MustFromDecimal(MAX_UINT256) // consts.MAX_UINT256 + if ratio.IsZero() { + return u256.Zero() + } ratio = new(u256.Uint).Div(maxUint256, ratio) } - shifted := new(u256.Uint).Rsh(ratio, 32) // ratio >> 32 - remainder := ratio.Mod(ratio, shift1By32Left) // ratio % (1 << 32) + upper128Bits := new(u256.Uint).Rsh(ratio, 32) // ratio >> 32 + lower32Bits := ratio.Mod(ratio, shift1By32Left) // ratio % (1 << 32) - var adj *u256.Uint - if remainder.IsZero() { - adj = u256.Zero() + var roundUp *u256.Uint + if lower32Bits.IsZero() { + roundUp = u256.Zero() } else { - adj = u256.One() + roundUp = u256.One() } - return new(u256.Uint).Add(shifted, adj) + return new(u256.Uint).Add(upper128Bits, roundUp) } func TickMathGetTickAtSqrtRatio(sqrtPriceX96 *u256.Uint) int32 { cond1 := sqrtPriceX96.Gte(u256.MustFromDecimal("4295128739")) // MIN_SQRT_RATIO cond2 := sqrtPriceX96.Lt(u256.MustFromDecimal("1461446703485210103287273052203988822378723970342")) // MAX_SQRT_RATIO if !(cond1 && cond2) { - panic(addDetailToError( + panic(newErrorWithDetail( errOutOfRange, - ufmt.Sprintf("tick_math.gno__TickMathGetTickAtSqrtRatio() || sqrtPriceX96 is out of range, sqrtPriceX96: %s", sqrtPriceX96.ToString()), + ufmt.Sprintf("sqrtPriceX96 is out of range, sqrtPriceX96: %s", sqrtPriceX96.ToString()), )) } @@ -114,10 +246,17 @@ func TickMathGetTickAtSqrtRatio(sqrtPriceX96 *u256.Uint) int32 { func findMSB(ratio *u256.Uint) (*u256.Uint, *u256.Uint) { msb := u256.Zero() + calculateMSB := func(i int) (*u256.Uint, *u256.Uint) { + if logConst, exists := binaryLogTree.GetLog(i); exists { + f := new(u256.Uint).Lsh(gt(ratio, logConst), uint(i)) + msb = new(u256.Uint).Or(msb, f) + ratio = new(u256.Uint).Rsh(ratio, uint(f.Uint64())) + } + return msb, ratio + } + for i := 7; i >= 1; i-- { - f := new(u256.Uint).Lsh(gt(ratio, binaryLogConsts[i]), uint(i)) - msb = new(u256.Uint).Or(msb, f) - ratio = new(u256.Uint).Rsh(ratio, uint(f.Uint64())) + msb, ratio = calculateMSB(i) } // handle the remaining bits @@ -219,6 +358,7 @@ func gt(x, y *u256.Uint) *u256.Uint { return u256.Zero() } +// abs returns the absolute value of the given integer. func abs(x int32) int32 { if x < 0 { return -x @@ -226,3 +366,13 @@ func abs(x int32) int32 { return x } + +// assertValidTickRange validates that the absolute tick value is within the acceptable range. +func assertValidTickRange(absTick int32) { + if absTick > maxTick { + panic(newErrorWithDetail( + errOutOfRange, + ufmt.Sprintf("abs tick is out of range (larger than 887272), abs tick: %d", absTick), + )) + } +} diff --git a/_deploy/r/gnoswap/common/tests/tick_math_test.gno b/_deploy/r/gnoswap/common/tick_math_test.gno similarity index 90% rename from _deploy/r/gnoswap/common/tests/tick_math_test.gno rename to _deploy/r/gnoswap/common/tick_math_test.gno index c97ad6fb7..bc3a70365 100644 --- a/_deploy/r/gnoswap/common/tests/tick_math_test.gno +++ b/_deploy/r/gnoswap/common/tick_math_test.gno @@ -22,7 +22,7 @@ func TestTickMathGetSqrtRatioAtTick(t *testing.T) { uassert.PanicsWithMessage( t, - "[GNOSWAP-COMMON-003] value out of range || tick_math.gno__TickMathGetSqrtRatioAtTick() || tick is out of range (larger than 887272), tick: -887273", + "[GNOSWAP-COMMON-003] value out of range || abs tick is out of range (larger than 887272), abs tick: 887273", func() { TickMathGetSqrtRatioAtTick(tick) }, @@ -34,7 +34,7 @@ func TestTickMathGetSqrtRatioAtTick(t *testing.T) { uassert.PanicsWithMessage( t, - "[GNOSWAP-COMMON-003] value out of range || tick_math.gno__TickMathGetSqrtRatioAtTick() || tick is out of range (larger than 887272), tick: 887273", + "[GNOSWAP-COMMON-003] value out of range || abs tick is out of range (larger than 887272), abs tick: 887273", func() { TickMathGetSqrtRatioAtTick(tick) }, @@ -127,7 +127,7 @@ func TestTickMathGetSqrtRatioAtTick(t *testing.T) { uassert.PanicsWithMessage( t, - "[GNOSWAP-COMMON-003] value out of range || tick_math.gno__TickMathGetTickAtSqrtRatio() || sqrtPriceX96 is out of range, sqrtPriceX96: 4295128738", + "[GNOSWAP-COMMON-003] value out of range || sqrtPriceX96 is out of range, sqrtPriceX96: 4295128738", func() { TickMathGetTickAtSqrtRatio(sqrtPriceX96) }, @@ -140,7 +140,7 @@ func TestTickMathGetSqrtRatioAtTick(t *testing.T) { uassert.PanicsWithMessage( t, - "[GNOSWAP-COMMON-003] value out of range || tick_math.gno__TickMathGetTickAtSqrtRatio() || sqrtPriceX96 is out of range, sqrtPriceX96: 1461446703485210103287273052203988822378723970343", + "[GNOSWAP-COMMON-003] value out of range || sqrtPriceX96 is out of range, sqrtPriceX96: 1461446703485210103287273052203988822378723970343", func() { TickMathGetTickAtSqrtRatio(sqrtPriceX96) }, diff --git a/_deploy/r/gnoswap/common/util.gno b/_deploy/r/gnoswap/common/util.gno index a8689214b..1fe2f40af 100644 --- a/_deploy/r/gnoswap/common/util.gno +++ b/_deploy/r/gnoswap/common/util.gno @@ -2,9 +2,32 @@ package common import ( "std" + + u256 "gno.land/p/gnoswap/uint256" ) -func getPrev() (string, string) { - prev := std.PrevRealm() +// assertOnlyNotNil panics if the value is nil. +func assertOnlyNotNil(value *u256.Uint) { + if value == nil { + panic(newErrorWithDetail( + errInvalidInput, + "value is nil", + )) + } +} + +// getPrevRealm returns object of the previous realm. +func getPrevRealm() std.Realm { + return std.PrevRealm() +} + +// getPrevAddr returns the address of the previous realm. +func getPrevAddr() std.Address { + return std.PrevRealm().Addr() +} + +// getPrevAsString returns the address and package path of the previous realm. +func getPrevAsString() (string, string) { + prev := getPrevRealm() return prev.Addr().String(), prev.PkgPath() } diff --git a/_deploy/r/gnoswap/consts/consts.gno b/_deploy/r/gnoswap/consts/consts.gno index d2ebff665..c4a3a8975 100644 --- a/_deploy/r/gnoswap/consts/consts.gno +++ b/_deploy/r/gnoswap/consts/consts.gno @@ -18,6 +18,7 @@ const ( // WRAP & UNWRAP const ( GNOT string = "gnot" + UGNOT string = "ugnot" WRAPPED_WUGNOT string = "gno.land/r/demo/wugnot" // defined in https://github.com/gnolang/gno/blob/81a88a2976ba9f2f9127ebbe7fb7d1e1f7fa4bd4/examples/gno.land/r/demo/wugnot/wugnot.gno#L19 @@ -111,7 +112,9 @@ const ( Q96 string = "79228162514264337593543950336" // 2 ** 96 Q128 string = "340282366920938463463374607431768211456" // 2 ** 128 + Q96_RESOLUTION uint = 96 Q128_RESOLUTION uint = 128 + Q160_RESOLUTION uint = 160 ) // TIMESTAMP & DAY diff --git a/pool/pool_manager.gno b/pool/pool_manager.gno index cd04d0416..a470ebb76 100644 --- a/pool/pool_manager.gno +++ b/pool/pool_manager.gno @@ -63,11 +63,11 @@ func (p *createPoolParams) isSameTokenPath() bool { // isInOrder checks if token paths are in lexicographical (or, alphabetical) order func (p *createPoolParams) isInOrder() bool { - if strings.Compare(p.token0Path, p.token1Path) > 0 { - return false + if strings.Compare(p.token0Path, p.token1Path) < 0 { + return true } - return true + return false } func (p *createPoolParams) wrap() (string, string) { diff --git a/pool/pool_manager_test.gno b/pool/pool_manager_test.gno index 4ea35cd95..8c87874f2 100644 --- a/pool/pool_manager_test.gno +++ b/pool/pool_manager_test.gno @@ -141,7 +141,7 @@ func TestCreatePool(t *testing.T) { t.Errorf("expected panic but got none") return } - errMsg := string(r) + errMsg := r.(string) if !strings.Contains(errMsg, tt.panicMsg) { t.Errorf("expected panic message containing %q but got %q", tt.panicMsg, errMsg) } diff --git a/position/_helper_test.gno b/position/_helper_test.gno index 83736375c..41c4c458f 100644 --- a/position/_helper_test.gno +++ b/position/_helper_test.gno @@ -9,18 +9,22 @@ import ( "gno.land/p/demo/uassert" pusers "gno.land/p/demo/users" "gno.land/r/demo/users" - "gno.land/r/demo/wugnot" "gno.land/r/gnoswap/v1/common" "gno.land/r/gnoswap/v1/consts" "gno.land/r/gnoswap/v1/gnft" - "gno.land/r/gnoswap/v1/gns" + pl "gno.land/r/gnoswap/v1/pool" - sr "gno.land/r/gnoswap/v1/staker" + + "gno.land/r/demo/wugnot" + "gno.land/r/gnoswap/v1/gns" "gno.land/r/onbloc/bar" "gno.land/r/onbloc/baz" "gno.land/r/onbloc/foo" "gno.land/r/onbloc/obl" "gno.land/r/onbloc/qux" + "gno.land/r/onbloc/usdc" + + sr "gno.land/r/gnoswap/v1/staker" ) const ( @@ -34,11 +38,13 @@ const ( oblPath string = "gno.land/r/onbloc/obl" quxPath string = "gno.land/r/onbloc/qux" - fee100 uint32 = 100 - fee500 uint32 = 500 - fee3000 uint32 = 3000 - maxApprove uint64 = 18446744073709551615 - max_timeout int64 = 9999999999 + fee100 uint32 = 100 + fee500 uint32 = 500 + fee3000 uint32 = 3000 + fee10000 uint32 = 10000 + maxApprove uint64 = 18446744073709551615 + max_timeout int64 = 9999999999 + maxSqrtPriceLimitX96 string = "1461446703485210103287273052203988822378723970341" TIER_1 uint64 = 1 TIER_2 uint64 = 2 @@ -162,6 +168,7 @@ var ( bob = pusers.AddressOrName(testutils.TestAddress("bob")) pool = pusers.AddressOrName(consts.POOL_ADDR) protocolFee = pusers.AddressOrName(consts.PROTOCOL_FEE_ADDR) + router = pusers.AddressOrName(consts.ROUTER_ADDR) adminRealm = std.NewUserRealm(users.Resolve(admin)) posRealm = std.NewCodeRealm(consts.POSITION_PATH) @@ -177,7 +184,10 @@ func InitialisePoolTest(t *testing.T) { std.TestSetOrigCaller(users.Resolve(admin)) TokenApprove(t, gnsPath, admin, pool, maxApprove) - CreatePool(t, wugnotPath, gnsPath, fee3000, "79228162514264337593543950336", users.Resolve(admin)) + poolPath := pl.GetPoolPath(wugnotPath, gnsPath, fee3000) + if !pl.DoesPoolPathExist(poolPath) { + pl.CreatePool(wugnotPath, gnsPath, fee3000, "79228162514264337593543950336") + } //2. create position std.TestSetOrigCaller(users.Resolve(alice)) @@ -340,6 +350,160 @@ func MintPosition(t *testing.T, caller) } +func MintPositionAll(t *testing.T, caller std.Address) { + t.Helper() + std.TestSetRealm(std.NewUserRealm(caller)) + TokenApprove(t, gnsPath, pusers.AddressOrName(caller), pool, maxApprove) + TokenApprove(t, gnsPath, pusers.AddressOrName(caller), router, maxApprove) + TokenApprove(t, wugnotPath, pusers.AddressOrName(caller), pool, maxApprove) + TokenApprove(t, wugnotPath, pusers.AddressOrName(caller), router, maxApprove) + + params := []struct { + tickLower int32 + tickUpper int32 + liquidity uint64 + zeroToOne bool + }{ + { + tickLower: -300, + tickUpper: -240, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -240, + tickUpper: -180, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -180, + tickUpper: -120, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -120, + tickUpper: -60, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -60, + tickUpper: 0, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: 0, + tickUpper: 60, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 60, + tickUpper: 120, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 120, + tickUpper: 180, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 180, + tickUpper: 240, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 240, + tickUpper: 300, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: -360, + tickUpper: -300, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -420, + tickUpper: -360, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -480, + tickUpper: -420, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -540, + tickUpper: -480, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: -600, + tickUpper: -540, + liquidity: 10, + zeroToOne: true, + }, + { + tickLower: 300, + tickUpper: 360, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 360, + tickUpper: 420, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 420, + tickUpper: 480, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 480, + tickUpper: 540, + liquidity: 10, + zeroToOne: false, + }, + { + tickLower: 540, + tickUpper: 600, + liquidity: 10, + zeroToOne: false, + }, + } + + for _, p := range params { + MintPosition(t, + wugnotPath, + gnsPath, + fee3000, + p.tickLower, + p.tickUpper, + "100", + "100", + "0", + "0", + max_timeout, + caller, + caller) + } + +} + func MakeMintPositionWithoutFee(t *testing.T) (uint64, string, string, string) { t.Helper() @@ -537,6 +701,51 @@ func resetObject(t *testing.T) { nextId = 1 } +func burnTokens(t *testing.T) { + t.Helper() + + // burn tokens + for _, addr := range addrUsedInTest { + uAddr := a2u(addr) + burnFoo(uAddr) + burnBar(uAddr) + burnBaz(uAddr) + burnQux(uAddr) + burnObl(uAddr) + burnUsdc(uAddr) + } +} + +func burnFoo(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + foo.Burn(addr, foo.BalanceOf(addr)) +} + +func burnBar(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + bar.Burn(addr, bar.BalanceOf(addr)) +} + +func burnBaz(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + baz.Burn(addr, baz.BalanceOf(addr)) +} + +func burnQux(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + qux.Burn(addr, qux.BalanceOf(addr)) +} + +func burnObl(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + obl.Burn(addr, obl.BalanceOf(addr)) +} + +func burnUsdc(addr pusers.AddressOrName) { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + usdc.Burn(addr, usdc.BalanceOf(addr)) +} + // burnAllNFT burns all NFTs func burnAllNFT(t *testing.T) { t.Helper() @@ -547,7 +756,7 @@ func burnAllNFT(t *testing.T) { } } -func TestBeforeResetObject(t *testing.T) { +func TestBeforeResetPositionObject(t *testing.T) { // make actual data to test resetting not only position's state but also pool's state std.TestSetRealm(adminRealm) @@ -569,6 +778,13 @@ func TestResetObject(t *testing.T) { uassert.Equal(t, nextId, uint64(1), "nextId should be 1") } +func TestBurnTokens(t *testing.T) { + burnTokens(t) + + uassert.Equal(t, foo.BalanceOf(a2u(addr01)), uint64(0)) // 100_000_000 -> 0 + uassert.Equal(t, bar.BalanceOf(a2u(addr01)), uint64(0)) // 100_000_000 -> 0 +} + func TestBurnAllNFT(t *testing.T) { burnAllNFT(t) uassert.Equal(t, gnft.TotalSupply(), uint64(0), "gnft total supply should be 0") diff --git a/position/errors.gno b/position/errors.gno index bfc2dd964..7328e52f8 100644 --- a/position/errors.gno +++ b/position/errors.gno @@ -7,31 +7,34 @@ import ( ) var ( - errNoPermission = errors.New("[GNOSWAP-POSITION-001] caller has no permission") - errSlippage = errors.New("[GNOSWAP-POSITION-002] slippage failed") - errWrapUnwrap = errors.New("[GNOSWAP-POSITION-003] wrap, unwrap failed") - errOutOfRange = errors.New("[GNOSWAP-POSITION-004] out of range for numeric value") - errInvalidInput = errors.New("[GNOSWAP-POSITION-005] invalid input data") - errDataNotFound = errors.New("[GNOSWAP-POSITION-006] requested data not found") - errExpired = errors.New("[GNOSWAP-POSITION-007] transaction expired") - errWugnotMinimum = errors.New("[GNOSWAP-POSITION-008] can not wrap less than minimum amount") - errNotClear = errors.New("[GNOSWAP-POSITION-009] position is not clear") - errZeroLiquidity = errors.New("[GNOSWAP-POSITION-010] zero liquidity") - errPositionExist = errors.New("[GNOSWAP-POSITION-011] position with same tokenId already exists") - errInvalidAddress = errors.New("[GNOSWAP-POSITION-012] invalid address") - errPositionDoesNotExist = errors.New("[GNOSWAP-POSITION-013] position does not exist") + errNoPermission = errors.New("[GNOSWAP-POSITION-001] caller has no permission") + errSlippage = errors.New("[GNOSWAP-POSITION-002] slippage failed") + errWrapUnwrap = errors.New("[GNOSWAP-POSITION-003] wrap, unwrap failed") + errOutOfRange = errors.New("[GNOSWAP-POSITION-004] out of range for numeric value") + errInvalidInput = errors.New("[GNOSWAP-POSITION-005] invalid input data") + errDataNotFound = errors.New("[GNOSWAP-POSITION-006] requested data not found") + errExpired = errors.New("[GNOSWAP-POSITION-007] transaction expired") + errWugnotMinimum = errors.New("[GNOSWAP-POSITION-008] can not wrap less than minimum amount") + errNotClear = errors.New("[GNOSWAP-POSITION-009] position is not clear") + errZeroLiquidity = errors.New("[GNOSWAP-POSITION-010] zero liquidity") + errPositionExist = errors.New("[GNOSWAP-POSITION-011] position with same tokenId already exists") + errInvalidAddress = errors.New("[GNOSWAP-POSITION-012] invalid address") + errPositionDoesNotExist = errors.New("[GNOSWAP-POSITION-013] position does not exist") + errZeroUGNOT = errors.New("[GNOSWAP-POSITION-014] No UGNOTs were sent") + errInsufficientUGNOT = errors.New("[GNOSWAP-POSITION-015] Insufficient UGNOT provided") + errInvalidTokenPath = errors.New("[GNOSWAP-POSITION-016] invalid token address") + errInvalidLiquidityRatio = errors.New("[GNOSWAP-POSITION-017] invalid liquidity ratio") + errUnderflow = errors.New("[GNOSWAP-POSITION-018] underflow") ) -func addDetailToError(err error, detail string) string { - finalErr := ufmt.Errorf("%s || %s", err.Error(), detail) - return finalErr.Error() -} - -// newErrorWithDetail returns a new error with the given detail -// e.g. newErrorWithDetail(err, "detail") +// newErrorWithDetail appends additional context or details to an existing error message. +// +// Parameters: +// - err: The original error (error). +// - detail: Additional context or detail to append to the error message (string). // -// input: err error, detail string -// output: "err.Error() || detail" +// Returns: +// - string: The combined error message in the format " || ". func newErrorWithDetail(err error, detail string) string { return ufmt.Errorf("%s || %s", err.Error(), detail).Error() } diff --git a/position/gno_helper.gno b/position/gno_helper.gno deleted file mode 100644 index fcc414377..000000000 --- a/position/gno_helper.gno +++ /dev/null @@ -1,11 +0,0 @@ -package position - -import ( - "std" - - "gno.land/r/gnoswap/v1/consts" -) - -func GetOrigPkgAddr() std.Address { - return consts.POSITION_ADDR -} diff --git a/position/helper.gno b/position/helper.gno deleted file mode 100644 index 806acacd5..000000000 --- a/position/helper.gno +++ /dev/null @@ -1,131 +0,0 @@ -package position - -import ( - "std" - "strconv" - - "gno.land/p/demo/grc/grc721" - "gno.land/p/demo/ufmt" - "gno.land/r/gnoswap/v1/common" - "gno.land/r/gnoswap/v1/consts" - "gno.land/r/gnoswap/v1/gnft" -) - -// nextId is the next tokenId to be minted -func getNextId() uint64 { - return nextId -} - -// tokenIdFrom converts tokenId to grc721.TokenID type -// NOTE: input parameter tokenId can be string, int, uint64, or grc721.TokenID -// if tokenId is nil or not supported, it will panic -// if tokenId is not found, it will panic -// input: tokenId interface{} -// output: grc721.TokenID -func tokenIdFrom(tokenId interface{}) grc721.TokenID { - if tokenId == nil { - panic(newErrorWithDetail(errInvalidInput, "tokenId is nil")) - } - - switch tokenId.(type) { - case string: - return grc721.TokenID(tokenId.(string)) - case int: - return grc721.TokenID(strconv.Itoa(tokenId.(int))) - case uint64: - return grc721.TokenID(strconv.Itoa(int(tokenId.(uint64)))) - case grc721.TokenID: - return tokenId.(grc721.TokenID) - default: - panic(newErrorWithDetail(errInvalidInput, "unsupported tokenId type")) - } -} - -// exists checks whether tokenId exists -// If tokenId doesn't exist, return false, otherwise return true -// input: tokenId uint64 -// output: bool -func exists(tokenId uint64) bool { - return gnft.Exists(tokenIdFrom(tokenId)) -} - -// isOwner checks whether the caller is the owner of the tokenId -// If the caller is the owner of the tokenId, return true, otherwise return false -// input: tokenId uint64, addr std.Address -// output: bool -func isOwner(tokenId uint64, addr std.Address) bool { - owner := gnft.OwnerOf(tokenIdFrom(tokenId)) - if owner == addr { - return true - } - return false -} - -// isOperator checks whether the caller is the approved operator of the tokenId -// If the caller is the approved operator of the tokenId, return true, otherwise return false -// input: tokenId uint64, addr std.Address -// output: bool -func isOperator(tokenId uint64, addr std.Address) bool { - operator, ok := gnft.GetApproved(tokenIdFrom(tokenId)) - if ok && operator == addr { - return true - } - return false -} - -// isStaked checks whether tokenId is staked -// If tokenId is staked, owner of tokenId is staker contract -// If tokenId is staked, return true, otherwise return false -// input: tokenId grc721.TokenID -// output: bool -func isStaked(tokenId grc721.TokenID) bool { - exist := gnft.Exists(tokenId) - if exist { - owner := gnft.OwnerOf(tokenId) - if owner == consts.STAKER_ADDR { - return true - } - } - return false -} - -// isOwnerOrOperator checks whether the caller is the owner or approved operator of the tokenId -// If the caller is the owner or approved operator of the tokenId, return true, otherwise return false -// input: addr std.Address, tokenId uint64 -// output: bool -func isOwnerOrOperator(addr std.Address, tokenId uint64) bool { - assertOnlyValidAddress(addr) - if !exists(tokenId) { - return false - } - if isOwner(tokenId, addr) || isOperator(tokenId, addr) { - return true - } - if isStaked(tokenIdFrom(tokenId)) { - position, exist := GetPosition(tokenId) - if exist && addr == position.operator { - return true - } - } - return false -} - -// splitOf divides poolKey into pToken0, pToken1, and pFee -// If poolKey is invalid, it will panic -// -// input: poolKey string -// output: -// - token0Path string -// - token1Path string -// - fee uint32 -func splitOf(poolKey string) (string, string, uint32) { - res, err := common.Split(poolKey, ":", 3) - if err != nil { - panic(newErrorWithDetail(errInvalidInput, ufmt.Sprintf("invalid poolKey(%s)", poolKey))) - } - - pToken0, pToken1, pFeeStr := res[0], res[1], res[2] - - pFee, _ := strconv.Atoi(pFeeStr) - return pToken0, pToken1, uint32(pFee) -} diff --git a/position/helper_test.gno b/position/helper_test.gno deleted file mode 100644 index 35be8cb56..000000000 --- a/position/helper_test.gno +++ /dev/null @@ -1,397 +0,0 @@ -package position - -import ( - "std" - "testing" - - "gno.land/p/demo/grc/grc721" - "gno.land/p/demo/uassert" - pusers "gno.land/p/demo/users" - "gno.land/r/demo/users" -) - -func TestGetNextId(t *testing.T) { - tests := []struct { - name string - newMint bool - expected uint64 - }{ - { - name: "Success - initial nextId", - newMint: false, - expected: 1, - }, - { - name: "Success - after mint", - newMint: true, - expected: 2, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.newMint { - MakeMintPositionWithoutFee(t) - } - got := getNextId() - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestTokenIdFrom(t *testing.T) { - - tests := []struct { - name string - input interface{} - expected string - shouldPanic bool - }{ - { - name: "Panic - nil", - input: nil, - expected: "[GNOSWAP-POSITION-005] invalid input data || tokenId is nil", - shouldPanic: true, - }, - { - name: "Panic - unsupported type", - input: float64(1), - expected: "[GNOSWAP-POSITION-005] invalid input data || unsupported tokenId type", - shouldPanic: true, - }, - { - name: "Success - string", - input: "1", - expected: "1", - shouldPanic: false, - }, - { - name: "Success - int", - input: int(1), - expected: "1", - shouldPanic: false, - }, - { - name: "Success - uint64", - input: uint64(1), - expected: "1", - shouldPanic: false, - }, - { - name: "Success - grc721.TokenID", - input: grc721.TokenID("1"), - expected: "1", - shouldPanic: false, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - defer func() { - r := recover() - if r == nil { - if tc.shouldPanic { - t.Errorf(">>> %s: expected panic but got none", tc.name) - return - } - } else { - switch r.(type) { - case string: - if r.(string) != tc.expected { - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) - } - case error: - if r.(error).Error() != tc.expected { - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expected) - } - default: - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) - } - } - }() - - if !tc.shouldPanic { - got := tokenIdFrom(tc.input) - uassert.Equal(t, tc.expected, string(got)) - } else { - tokenIdFrom(tc.input) - } - }) - } -} - -func TestExists(t *testing.T) { - tests := []struct { - name string - tokenId uint64 - expected bool - }{ - { - name: "Fail - not exists", - tokenId: 2, - expected: false, - }, - { - name: "Success - exists", - tokenId: 1, - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := exists(tc.tokenId) - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestIsOwner(t *testing.T) { - tests := []struct { - name string - tokenId uint64 - addr std.Address - expected bool - }{ - { - name: "Fail - is not owner", - tokenId: 1, - addr: users.Resolve(alice), - expected: false, - }, - { - name: "Success - is owner", - tokenId: 1, - addr: users.Resolve(admin), - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - MakeMintPositionWithoutFee(t) - got := isOwner(tc.tokenId, tc.addr) - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestIsOperator(t *testing.T) { - MakeMintPositionWithoutFee(t) - tests := []struct { - name string - tokenId uint64 - addr pusers.AddressOrName - expected bool - }{ - { - name: "Fail - is not operator", - tokenId: 1, - addr: alice, - expected: false, - }, - { - name: "Success - is operator", - tokenId: 1, - addr: bob, - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.expected { - LPTokenApprove(t, admin, tc.addr, tc.tokenId) - } - got := isOperator(tc.tokenId, users.Resolve(tc.addr)) - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestIsStaked(t *testing.T) { - MakeMintPositionWithoutFee(t) - tests := []struct { - name string - owner pusers.AddressOrName - operator pusers.AddressOrName - tokenId uint64 - expected bool - }{ - { - name: "Fail - is not staked", - owner: bob, - operator: alice, - tokenId: 1, - expected: false, - }, - { - name: "Fail - is not exist tokenId", - owner: admin, - operator: bob, - tokenId: 100, - expected: false, - }, - { - name: "Success - is staked", - owner: admin, - operator: admin, - tokenId: 1, - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.expected && tc.owner == tc.operator { - LPTokenStake(t, tc.owner, tc.tokenId) - } - got := isStaked(tokenIdFrom(tc.tokenId)) - uassert.Equal(t, tc.expected, got) - if tc.expected && tc.owner == tc.operator { - LPTokenUnStake(t, tc.owner, tc.tokenId, false) - } - }) - } -} - -func TestIsOwnerOrOperator(t *testing.T) { - MakeMintPositionWithoutFee(t) - tests := []struct { - name string - owner pusers.AddressOrName - operator pusers.AddressOrName - tokenId uint64 - expected bool - }{ - { - name: "Fail - is not owner or operator", - owner: admin, - operator: alice, - tokenId: 1, - expected: false, - }, - { - name: "Success - is operator", - owner: admin, - operator: bob, - tokenId: 1, - expected: true, - }, - { - name: "Success - is owner", - owner: admin, - operator: admin, - tokenId: 1, - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.expected && tc.owner != tc.operator { - LPTokenApprove(t, tc.owner, tc.operator, tc.tokenId) - } - var got bool - if tc.owner == tc.operator { - got = isOwnerOrOperator(users.Resolve(tc.owner), tc.tokenId) - } else { - got = isOwnerOrOperator(users.Resolve(tc.operator), tc.tokenId) - } - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestIsOwnerOrOperatorWithStake(t *testing.T) { - MakeMintPositionWithoutFee(t) - tests := []struct { - name string - owner pusers.AddressOrName - operator pusers.AddressOrName - tokenId uint64 - isStake bool - expected bool - }{ - { - name: "Fail - is not token staked", - owner: admin, - operator: alice, - tokenId: 1, - isStake: false, - expected: false, - }, - { - name: "Success - is token staked (position operator)", - owner: admin, - operator: admin, - tokenId: 1, - isStake: true, - expected: true, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.isStake { - LPTokenStake(t, tc.owner, tc.tokenId) - } - got := isOwnerOrOperator(users.Resolve(tc.operator), tc.tokenId) - uassert.Equal(t, tc.expected, got) - }) - } -} - -func TestPoolKeyDivide(t *testing.T) { - tests := []struct { - name string - poolKey string - expectedPath0 string - expectedPath1 string - expectedFee uint32 - expectedError string - shouldPanic bool - }{ - { - name: "Fail - invalid poolKey", - poolKey: "gno.land/r/onbloc", - expectedError: "[GNOSWAP-POSITION-005] invalid input data || invalid poolKey(gno.land/r/onbloc)", - shouldPanic: true, - }, - { - name: "Success - split poolKey", - poolKey: "gno.land/r/gnoswap/v1/gns:gno.land/r/demo/wugnot:500", - expectedPath0: gnsPath, - expectedPath1: wugnotPath, - expectedFee: fee500, - shouldPanic: false, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - defer func() { - r := recover() - if r == nil { - if tc.shouldPanic { - t.Errorf(">>> %s: expected panic but got none", tc.name) - return - } - } else { - switch r.(type) { - case string: - if r.(string) != tc.expectedError { - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expectedError) - } - case error: - if r.(error).Error() != tc.expectedError { - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expectedError) - } - default: - t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expectedError) - } - } - }() - - if !tc.shouldPanic { - gotToken0, gotToken1, gotFee := splitOf(tc.poolKey) - uassert.Equal(t, tc.expectedPath0, gotToken0) - uassert.Equal(t, tc.expectedPath1, gotToken1) - uassert.Equal(t, tc.expectedFee, gotFee) - } else { - splitOf(tc.poolKey) - } - }) - } -} diff --git a/position/liquidity_management.gno b/position/liquidity_management.gno index d9a77d0fe..006c1c8db 100644 --- a/position/liquidity_management.gno +++ b/position/liquidity_management.gno @@ -1,22 +1,81 @@ package position import ( - u256 "gno.land/p/gnoswap/uint256" + "std" "gno.land/p/demo/ufmt" + u256 "gno.land/p/gnoswap/uint256" "gno.land/r/gnoswap/v1/common" "gno.land/r/gnoswap/v1/consts" - pl "gno.land/r/gnoswap/v1/pool" ) -// addLiquidity adds liquidity to the pool and checks for slippage. -// Returns liquidity, amount0, amount1 +type AddLiquidityParams struct { + poolKey string // poolPath of the pool which has the position + tickLower int32 // lower end of the tick range for the position + tickUpper int32 // upper end of the tick range for the position + amount0Desired *u256.Uint // desired amount of token0 to be minted + amount1Desired *u256.Uint // desired amount of token1 to be minted + amount0Min *u256.Uint // minimum amount of token0 to be minted + amount1Min *u256.Uint // minimum amount of token1 to be minted + caller std.Address // address to call the function +} + +// addLiquidity calculates the liquidity to be added to a pool and mints the corresponding tokens. +// +// This function interacts with the specified pool to add liquidity for a given price range, specified by +// `tickLower` and `tickUpper`, and desired token amounts. It ensures that the resulting token amounts meet +// minimum thresholds to prevent excessive slippage. +// +// Parameters: +// - params (AddLiquidityParams): Contains the following fields: +// - poolKey: The unique identifier for the pool (string). +// - tickLower: The lower tick boundary of the liquidity range (int32). +// - tickUpper: The upper tick boundary of the liquidity range (int32). +// - amount0Desired: The desired amount of token0 to provide as liquidity (*u256.Uint). +// - amount1Desired: The desired amount of token1 to provide as liquidity (*u256.Uint). +// - amount0Min: The minimum acceptable amount of token0 to prevent slippage (*u256.Uint). +// - amount1Min: The minimum acceptable amount of token1 to prevent slippage (*u256.Uint). +// - caller: The address of the entity adding liquidity (std.Address). +// +// Returns: +// - *u256.Uint: The calculated liquidity amount to be added. +// - *u256.Uint: The actual amount of token0 used. +// - *u256.Uint: The actual amount of token1 used. +// +// Behavior: +// 1. Retrieves the pool information and current square root price (`sqrtPriceX96`). +// 2. Calculates the square root ratios (`sqrtRatioAX96` and `sqrtRatioBX96`) for the given tick boundaries. +// 3. Computes the liquidity to be added based on desired token amounts and the square root ratios. +// 4. Mints liquidity tokens to the position contract and determines the actual amounts of token0 and token1 used. +// 5. Ensures the actual token amounts used meet or exceed the specified minimum thresholds (`amount0Min` and `amount1Min`). +// - If the conditions are not met, the function panics with a slippage error. +// +// Panics: +// - If the actual token amounts used do not meet the minimum thresholds, a slippage error is raised. +// +// Notes: +// - The function relies on the `GetLiquidityForAmounts` function to calculate liquidity based on token amounts +// and price ratios. +// - Ensures the pool interactions use the caller-provided parameters to add liquidity safely. +// +// Example: +// +// liquidity, usedAmount0, usedAmount1 := addLiquidity(AddLiquidityParams{ +// poolKey: "gno.land/r/demo/pool", +// tickLower: -60000, +// tickUpper: 60000, +// amount0Desired: u256.MustFromDecimal("1000000000"), +// amount1Desired: u256.MustFromDecimal("2000000000"), +// amount0Min: u256.MustFromDecimal("950000000"), +// amount1Min: u256.MustFromDecimal("1900000000"), +// caller: userAddress, +// }) func addLiquidity(params AddLiquidityParams) (*u256.Uint, *u256.Uint, *u256.Uint) { pool := pl.GetPoolFromPoolPath(params.poolKey) - sqrtPriceX96 := pool.Slot0SqrtPriceX96() + sqrtPriceX96 := pool.Slot0SqrtPriceX96().Clone() sqrtRatioAX96 := common.TickMathGetSqrtRatioAtTick(params.tickLower) sqrtRatioBX96 := common.TickMathGetSqrtRatioAtTick(params.tickUpper) @@ -28,11 +87,11 @@ func addLiquidity(params AddLiquidityParams) (*u256.Uint, *u256.Uint, *u256.Uint params.amount1Desired, ) - pToken0, pToken1, pFee := splitOf(params.poolKey) - amount0, amount1 := pl.Mint( - pToken0, - pToken1, - pFee, + token0, token1, fee := splitOf(params.poolKey) + amount0Str, amount1Str := pl.Mint( + token0, + token1, + fee, consts.POSITION_ADDR, params.tickLower, params.tickUpper, @@ -40,18 +99,20 @@ func addLiquidity(params AddLiquidityParams) (*u256.Uint, *u256.Uint, *u256.Uint params.caller, ) - amount0Uint := u256.MustFromDecimal(amount0) - amount1Uint := u256.MustFromDecimal(amount1) + amount0 := u256.MustFromDecimal(amount0Str) + amount1 := u256.MustFromDecimal(amount1Str) - amount0Cond := amount0Uint.Gte(params.amount0Min) - amount1Cond := amount1Uint.Gte(params.amount1Min) + amount0Cond := amount0.Gte(params.amount0Min) + amount1Cond := amount1.Gte(params.amount1Min) if !(amount0Cond && amount1Cond) { - panic(addDetailToError( + panic(newErrorWithDetail( errSlippage, - ufmt.Sprintf("LM_Price Slippage Check(amount0(%s) >= params.amount0Min(%s), amount1(%s) >= params.amount1Min(%s))", amount0Uint.ToString(), params.amount0Min.ToString(), amount1Uint.ToString(), params.amount1Min.ToString()), + ufmt.Sprintf( + "Price Slippage Check(amount0(%s) >= amount0Min(%s), amount1(%s) >= amount1Min(%s))", + amount0Str, params.amount0Min.ToString(), amount1Str, params.amount1Min.ToString()), )) } - return liquidity, amount0Uint, amount1Uint + return liquidity, amount0, amount1 } diff --git a/position/liquidity_management_test.gno b/position/liquidity_management_test.gno new file mode 100644 index 000000000..d78debd57 --- /dev/null +++ b/position/liquidity_management_test.gno @@ -0,0 +1,115 @@ +package position + +import ( + "std" + "testing" + + "gno.land/p/demo/uassert" + pusers "gno.land/p/demo/users" + u256 "gno.land/p/gnoswap/uint256" + "gno.land/r/demo/users" + "gno.land/r/gnoswap/v1/common" + "gno.land/r/gnoswap/v1/consts" + pl "gno.land/r/gnoswap/v1/pool" +) + +func InitialSetup(t *testing.T) { + std.TestSetRealm(adminRealm) + + pl.SetPoolCreationFeeByAdmin(0) + CreatePool(t, gnsPath, barPath, fee3000, common.TickMathGetSqrtRatioAtTick(0).ToString(), users.Resolve(admin)) + TokenFaucet(t, gnsPath, alice) + TokenFaucet(t, barPath, alice) +} + +func TestAddLiquidity(t *testing.T) { + poolKey := computePoolPath(gnsPath, barPath, fee3000) + + tests := []struct { + name string + params AddLiquidityParams + expectPanic bool + expectedAmount0 string + expectedAmount1 string + }{ + { + name: "Successful Liquidity Addition", + params: AddLiquidityParams{ + poolKey: poolKey, + tickLower: -600, + tickUpper: 600, + amount0Desired: u256.MustFromDecimal("1000000"), + amount1Desired: u256.MustFromDecimal("2000000"), + amount0Min: u256.MustFromDecimal("400000"), + amount1Min: u256.MustFromDecimal("800000"), + caller: users.Resolve(alice), + }, + expectPanic: false, + expectedAmount0: "1000000", + expectedAmount1: "1000000", + }, + { + name: "Slippage Panic", + params: AddLiquidityParams{ + poolKey: poolKey, + tickLower: -600, + tickUpper: 600, + amount0Desired: u256.MustFromDecimal("1000000"), + amount1Desired: u256.MustFromDecimal("2000000"), + amount0Min: u256.MustFromDecimal("1100000"), + amount1Min: u256.MustFromDecimal("2200000"), + caller: users.Resolve(alice), + }, + expectPanic: true, + }, + { + name: "Zero Liquidity", + params: AddLiquidityParams{ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + amount0Desired: u256.Zero(), + amount1Desired: u256.Zero(), + amount0Min: u256.Zero(), + amount1Min: u256.Zero(), + caller: users.Resolve(alice), + }, + expectPanic: true, + expectedAmount0: "0", + expectedAmount1: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.expectPanic { + t.Errorf("unexpected panic: %v", r) + } + } else { + if tt.expectPanic { + t.Errorf("expected panic but did not occur") + } + } + }() + + InitialSetup(t) + std.TestSetRealm(std.NewUserRealm(users.Resolve(alice))) + TokenApprove(t, gnsPath, alice, pusers.AddressOrName(consts.POOL_ADDR), tt.params.amount0Desired.Uint64()) + TokenApprove(t, barPath, alice, pusers.AddressOrName(consts.POOL_ADDR), tt.params.amount1Desired.Uint64()) + + _, amount0, amount1 := addLiquidity(tt.params) + + if !tt.expectPanic { + uassert.Equal(t, tt.expectedAmount0, amount0.ToString()) + uassert.Equal(t, tt.expectedAmount1, amount1.ToString()) + } + }) + } +} + +func TestSplitOf(t *testing.T) { + // TODO: + +} diff --git a/position/native_token.gno b/position/native_token.gno new file mode 100644 index 000000000..392bc0ab6 --- /dev/null +++ b/position/native_token.gno @@ -0,0 +1,171 @@ +package position + +import ( + "std" + "strconv" + + "gno.land/r/demo/wugnot" + + "gno.land/p/demo/ufmt" + "gno.land/r/gnoswap/v1/consts" +) + +// wrap wraps the specified amount of the native token `ugnot` into the wrapped token `wugnot`. +// +// Parameters: +// - ugnotAmount (uint64): The amount of `ugnot` tokens to wrap into `wugnot`. +// - to (std.Address): The recipient's address to receive the wrapped tokens. +// +// Returns: +// - error: An error if the `ugnot` amount is zero, below the minimum wrapping threshold, or any other issue occurs. +// +// Example: +// +// wrap(1000, userAddress) +// - Wraps 1000 UGNOT into WUGNOT and transfers the WUGNOT to `userAddress`. +// +// Errors: +// - Returns an error if `ugnotAmount` is zero or less than the minimum deposit threshold. +func wrap(ugnotAmount uint64, to std.Address) error { + if ugnotAmount == 0 || ugnotAmount < consts.UGNOT_MIN_DEPOSIT_TO_WRAP { + return ufmt.Errorf("amount(%d) < minimum(%d)", ugnotAmount, consts.UGNOT_MIN_DEPOSIT_TO_WRAP) + } + + wugnotAddr := std.DerivePkgAddr(consts.WRAPPED_WUGNOT) + transferUGNOT(consts.POSITION_ADDR, wugnotAddr, ugnotAmount) + + wugnot.Deposit() // POSITION HAS WUGNOT + wugnot.Transfer(a2u(to), ugnotAmount) // SEND WUGNOT: POSITION -> USER + + return nil +} + +// unwrap converts a specified amount of `WUGNOT` tokens into `UGNOT` tokens +// and transfers the resulting `UGNOT` back to the specified recipient address. +// +// Parameters: +// - `wugnotAmount`: The amount of `WUGNOT` tokens to unwrap (uint64). +// - `to`: The recipient's address (std.Address) to receive the unwrapped `UGNOT`. +// +// Example: +// unwrap(100, userAddress) +// - Converts 100 WUGNOT into UGNOT and sends the resulting UGNOT to `userAddress`. +func unwrap(wugnotAmount uint64, to std.Address) error { + if wugnotAmount == 0 { + return ufmt.Errorf("amount(%d) is zero", wugnotAmount) + } + + wugnot.TransferFrom(a2u(to), a2u(consts.POSITION_ADDR), wugnotAmount) // SEND WUGNOT: USER -> POSITION + wugnot.Withdraw(wugnotAmount) // POSITION HAS UGNOT + transferUGNOT(consts.POSITION_ADDR, to, wugnotAmount) // SEND UGNOT: POSITION -> USER + return nil +} + +// transferUGNOT transfers a specified amount of `UGNOT` tokens from one address to another. +// The function ensures that no transaction occurs if the transfer amount is zero. +// It uses the `std.BankerTypeRealmSend` banker type to facilitate the transfer. +// +// Parameters: +// - `from`: The sender's address (std.Address). +// - `to`: The recipient's address (std.Address). +// - `amount`: The amount of UGNOT tokens to transfer (uint64). +// +// Example: +// transferUGNOT(sender, receiver, 100) // Transfers 100 UGNOT from `sender` to `receiver`. +func transferUGNOT(from, to std.Address, amount uint64) { + if amount == 0 { + return + } + + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins(from, to, std.Coins{ + {Denom: consts.UGNOT, Amount: int64(amount)}, + }) +} + +// refundUGNOT refunds a specified amount of `UGNOT` tokens to the provided address. +// This function uses `transferUGNOT` to perform the transfer from the contract's position address +// (`POSITION_ADDR`) to the recipient. +// +// Parameters: +// - `to`: The recipient's address (std.Address) who will receive the refund. +// - `amount`: The amount of `UGNOT` tokens to refund (uint64). +func refundUGNOT(to std.Address, amount uint64) { + transferUGNOT(consts.POSITION_ADDR, to, amount) +} + +// isNative checks whether the given token is a native token. +func isNative(token string) bool { + return token == consts.GNOT +} + +// isWrappedToken checks whether the tokenPath is wrapped token +func isWrappedToken(tokenPath string) bool { + return tokenPath == consts.WRAPPED_WUGNOT +} + +// safeWrapNativeToken safely wraps the native token `ugnot` into the wrapped token `wugnot` for a user. +// +// Parameters: +// - amountDesired: The desired amount of `ugnot` to be wrapped, provided as a string. +// - userAddress: The address of the user initiating the wrapping process. +// +// Returns: +// - uint64: The amount of `ugnot` that was successfully wrapped into `wugnot`. +// +// Panics: +// - If the sent `ugnot` amount is zero. +// - If `amountDesired` cannot be parsed into a valid uint64 value. +// - If the sent `ugnot` amount is less than `amountDesired`. +// - If the `wrap` function fails to wrap the tokens. +// - If there is a mismatch between the expected wrapped token amount and the user's balance after wrapping. +func safeWrapNativeToken(amountDesired string, userAddress std.Address) uint64 { + beforeWugnotBalance := wugnot.BalanceOf(a2u(userAddress)) + sentNative := std.GetOrigSend() + sentUgnotAmount := uint64(sentNative.AmountOf(consts.UGNOT)) + + if sentUgnotAmount <= 0 { + panic(newErrorWithDetail(errZeroUGNOT, "amount of ugnot is zero")) + } + + amount, err := strconv.ParseUint(amountDesired, 10, 64) + if err != nil { + panic(newErrorWithDetail(errWrapUnwrap, err.Error())) + } + + if sentUgnotAmount < amount { + panic(newErrorWithDetail(errInsufficientUGNOT, "amount of ugnot is less than desired amount")) + } + + if sentUgnotAmount > amount { + exceed := sentUgnotAmount - amount + refundUGNOT(userAddress, exceed) + transferUGNOT(consts.POSITION_ADDR, userAddress, amount) + sentUgnotAmount = amount + } + + if err = wrap(sentUgnotAmount, userAddress); err != nil { + panic(newErrorWithDetail(errWugnotMinimum, err.Error())) + } + + afterWugnotBalance := wugnot.BalanceOf(a2u(userAddress)) + diff := afterWugnotBalance - beforeWugnotBalance + + if diff != sentUgnotAmount { + panic(newErrorWithDetail( + errWrapUnwrap, + ufmt.Sprintf("amount of ugnot (%d) is not equal to amount of wugnot. (diff: %d)", sentUgnotAmount, diff), + )) + } + return sentUgnotAmount +} + +func handleUnwrap(pToken0, pToken1 string, unwrapResult bool, userOldWugnotBalance uint64, to std.Address) { + if (pToken0 == consts.WRAPPED_WUGNOT || pToken1 == consts.WRAPPED_WUGNOT) && unwrapResult { + userNewWugnotBalance := wugnot.BalanceOf(a2u(to)) + leftOver := userNewWugnotBalance - userOldWugnotBalance + if leftOver > 0 { + unwrap(leftOver, to) + } + } +} diff --git a/position/native_token_test.gno b/position/native_token_test.gno new file mode 100644 index 000000000..1a1e935ca --- /dev/null +++ b/position/native_token_test.gno @@ -0,0 +1,393 @@ +package position + +import ( + "std" + "strconv" + "testing" + + "gno.land/p/demo/uassert" + pusers "gno.land/p/demo/users" + "gno.land/r/demo/users" + "gno.land/r/gnoswap/v1/consts" +) + +func TestTransferUGNOT(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, from, to std.Address) + verify func(t *testing.T, to std.Address) uint64 + from std.Address + to std.Address + expected string + shouldPanic bool + }{ + { + name: "Success - Zero amount", + action: func(t *testing.T, from, to std.Address) { + transferUGNOT(from, to, 0) + }, + verify: func(t *testing.T, to std.Address) uint64 { + return ugnotBalanceOf(t, to) + }, + from: users.Resolve(alice), + to: users.Resolve(bob), + expected: "0", + shouldPanic: false, + }, + { + name: "Success - Valid transfer", + action: func(t *testing.T, from, to std.Address) { + ugnotFaucet(t, from, 100) + std.TestSetRealm(std.NewUserRealm(from)) + transferUGNOT(from, to, 100) + }, + verify: func(t *testing.T, to std.Address) uint64 { + return ugnotBalanceOf(t, to) + }, + from: consts.POSITION_ADDR, + to: users.Resolve(bob), + expected: "100", + shouldPanic: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + if tc.shouldPanic { + t.Errorf(">>> %s: expected panic but got none", tc.name) + return + } + } else { + switch r.(type) { + case string: + if r.(string) != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + case error: + if r.(error).Error() != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expected) + } + default: + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + } + }() + + if !tc.shouldPanic { + tc.action(t, tc.from, tc.to) + if tc.verify != nil { + balance := tc.verify(t, tc.to) + uassert.Equal(t, tc.expected, strconv.FormatUint(balance, 10)) + } + } else { + tc.action(t, tc.from, tc.to) + } + }) + } +} + +func TestWrap(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, from, to std.Address) error + verify func(t *testing.T, to std.Address) uint64 + from std.Address + to std.Address + expected string + shouldPanic bool + }{ + { + name: "Failure - Amount less than minimum", + action: func(t *testing.T, from, to std.Address) error { + return wrap(999, to) + }, + verify: nil, + from: users.Resolve(alice), + to: users.Resolve(bob), + expected: "amount(999) < minimum(1000)", + shouldPanic: true, + }, + { + name: "Failure - Zero amount", + action: func(t *testing.T, from, to std.Address) error { + return wrap(0, to) + }, + verify: nil, + from: users.Resolve(alice), + to: users.Resolve(bob), + expected: "amount(0) < minimum(1000)", + shouldPanic: true, + }, + { + name: "Success - Valid amount", + action: func(t *testing.T, from, to std.Address) error { + ugnotFaucet(t, from, 1000) + std.TestSetRealm(std.NewUserRealm(from)) + return wrap(1000, to) + }, + verify: func(t *testing.T, to std.Address) uint64 { + return TokenBalance(t, wugnotPath, pusers.AddressOrName(to)) + }, + from: consts.POSITION_ADDR, + to: users.Resolve(bob), + expected: "1000", + shouldPanic: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r != nil { + switch r.(type) { + case string: + if r.(string) != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + case error: + if r.(error).Error() != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expected) + } + default: + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + } + }() + + if !tc.shouldPanic { + err := tc.action(t, tc.from, tc.to) + if err == nil && tc.verify != nil { + balance := tc.verify(t, tc.to) + uassert.Equal(t, tc.expected, strconv.FormatUint(balance, 10)) + } + } else { + err := tc.action(t, tc.from, tc.to) + if err != nil { + uassert.Equal(t, tc.expected, err.Error()) + } else { + t.Errorf(">>> %s: expected panic but got none", tc.name) + } + } + }) + } +} + +func TestUnWrap(t *testing.T) { + tests := []struct { + name string + action func(t *testing.T, from, to std.Address) error + verify func(t *testing.T, to std.Address) uint64 + from std.Address + to std.Address + expected string + shouldPanic bool + }{ + { + name: "Failure - Zero amount", + action: func(t *testing.T, from, to std.Address) error { + return unwrap(0, to) + }, + verify: nil, + from: users.Resolve(alice), + to: users.Resolve(bob), + expected: "amount(0) is zero", + shouldPanic: true, + }, + { + name: "Success - Valid amount", + action: func(t *testing.T, from, to std.Address) error { + ugnotFaucet(t, from, 1000) + std.TestSetRealm(std.NewUserRealm(from)) + wrap(1000, to) + std.TestSetRealm(std.NewUserRealm(to)) + TokenApprove(t, wugnotPath, pusers.AddressOrName(to), pusers.AddressOrName(from), 1000) + return unwrap(1000, to) + }, + verify: func(t *testing.T, to std.Address) uint64 { + return TokenBalance(t, wugnotPath, pusers.AddressOrName(to)) + }, + from: consts.POSITION_ADDR, + to: users.Resolve(bob), + expected: "1000", + shouldPanic: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r != nil { + switch r.(type) { + case string: + if r.(string) != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + case error: + if r.(error).Error() != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expected) + } + default: + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + } + }() + + if !tc.shouldPanic { + err := tc.action(t, tc.from, tc.to) + if err == nil && tc.verify != nil { + balance := tc.verify(t, tc.to) + uassert.Equal(t, tc.expected, strconv.FormatUint(balance, 10)) + } + } else { + err := tc.action(t, tc.from, tc.to) + if err != nil { + uassert.Equal(t, tc.expected, err.Error()) + } else { + t.Errorf(">>> %s: expected panic but got none", tc.name) + } + } + }) + } +} + +func TestIsNative(t *testing.T) { + tests := []struct { + name string + token string + expected bool + }{ + { + name: "Native Token - GNOT", + token: "gnot", + expected: true, + }, + { + name: "Non-Native Token", + token: "usdt", + expected: false, + }, + { + name: "Empty Token", + token: "", + expected: false, + }, + { + name: "Similar but Different Token", + token: "GNOT", // 대문자 + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNative(tt.token) + uassert.Equal(t, tt.expected, result, "Unexpected result for token: "+tt.token) + }) + } +} + +func TestIsWrappedToken(t *testing.T) { + // TODO: +} + +func TestSafeWrapNativeToken(t *testing.T) { + tests := []struct { + name string + amountDesired string + userAddress std.Address + sentAmount uint64 + expectPanic bool + expectedWrap uint64 + }{ + { + name: "Panic - Zero UGNOT", + amountDesired: "50", + userAddress: users.Resolve(alice), + sentAmount: 0, + expectPanic: true, + }, + { + name: "Panic - Insufficient UGNOT", + amountDesired: "150", + userAddress: users.Resolve(alice), + sentAmount: 100, + expectPanic: true, + }, + { + name: "Panic - Invalid Desired Amount", + amountDesired: "invalid", + userAddress: users.Resolve(alice), + sentAmount: 200, + expectPanic: true, + }, + { + name: "Successful wrap - Exact Amount", + amountDesired: "1050", + userAddress: users.Resolve(alice), + sentAmount: 1050, + expectPanic: false, + expectedWrap: 1050, + }, + { + name: "Excess Refund", + amountDesired: "1000", + userAddress: users.Resolve(alice), + sentAmount: 1500, + expectPanic: false, + expectedWrap: 1000, + }, + { + name: "Boundary Test - Exact Match", + amountDesired: "1000", + userAddress: users.Resolve(alice), + sentAmount: 1000, + expectPanic: false, + expectedWrap: 1000, + }, + { + name: "Zero Desired Amount", + amountDesired: "0", + userAddress: users.Resolve(alice), + sentAmount: 100, + expectPanic: true, + }, + { + name: "Wrap Error Test", + amountDesired: "100", + userAddress: users.Resolve(alice), + sentAmount: 100, + expectPanic: true, // Simulate wrap error internally + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.expectPanic { + t.Errorf("unexpected panic: %v", r) + } + } else { + if tt.expectPanic { + t.Errorf("expected panic but did not occur") + } + } + }() + + amount, _ := strconv.ParseUint(tt.amountDesired, 10, 64) + ugnotFaucet(t, consts.POSITION_ADDR, amount) + std.TestSetRealm(std.NewUserRealm(consts.POSITION_ADDR)) + transferUGNOT(consts.POSITION_ADDR, consts.POSITION_ADDR, amount) + + // Perform wrapping + wrappedAmount := safeWrapNativeToken(tt.amountDesired, tt.userAddress) + + // Verify wrapped amount + if !tt.expectPanic { + uassert.Equal(t, tt.expectedWrap, wrappedAmount) + } + }) + } +} diff --git a/position/position.gno b/position/position.gno index a2d1cd0ae..a913d77d5 100644 --- a/position/position.gno +++ b/position/position.gno @@ -1,14 +1,13 @@ package position import ( - "std" - "strconv" - + "encoding/base64" "gno.land/p/demo/avl" - "gno.land/p/demo/ufmt" "gno.land/r/demo/wugnot" "gno.land/r/gnoswap/v1/gnft" + "std" + "strconv" u256 "gno.land/p/gnoswap/uint256" @@ -19,14 +18,154 @@ import ( pl "gno.land/r/gnoswap/v1/pool" ) +const ( + ZERO_LIQUIDITY_FOR_FEE_COLLECTION = "0" +) + var ( positions = avl.NewTree() // tokenId[uint64] -> Position nextId = uint64(1) ) -// Mint creates a new liquidity position and mints liquidity tokens. -// It also handles the conversion between GNOT and WUGNOT transparently for the user. -// Returns minted tokenId, liquidity, amount0, amount1 +// MustGetPosition returns a position for a given tokenId +// panics if position doesn't exist +func MustGetPosition(tokenId uint64) Position { + position, exist := GetPosition(tokenId) + if !exist { + panic(newErrorWithDetail( + errPositionDoesNotExist, + ufmt.Sprintf("position with tokenId(%d) doesn't exist", tokenId), + )) + } + return position +} + +// mustUpdatePosition updates a position for a given tokenId +func mustUpdatePosition(tokenId uint64, position Position) { + update := setPosition(tokenId, position) + if !update { + panic(newErrorWithDetail( + errPositionDoesNotExist, + ufmt.Sprintf("position with tokenId(%d) doesn't exist", tokenId), + )) + } +} + +// GetPosition returns a position for a given tokenId +// Returns false if position doesn't exist +func GetPosition(tokenId uint64) (Position, bool) { + tokenIdStr := strconv.FormatUint(tokenId, 10) + iPosition, exist := positions.Get(tokenIdStr) + if !exist { + return Position{}, false + } + + return iPosition.(Position), true +} + +// setPosition sets a position for a given tokenId +// Returns true if position is newly created, false if position already exists and just updated. +func setPosition(tokenId uint64, position Position) bool { + tokenIdStr := strconv.FormatUint(tokenId, 10) + return positions.Set(tokenIdStr, position) +} + +// removePosition removes a position for a given tokenId +func removePosition(tokenId uint64) { + tokenIdStr := strconv.FormatUint(tokenId, 10) + positions.Remove(tokenIdStr) +} + +func existPosition(tokenId uint64) bool { + _, exist := GetPosition(tokenId) + return exist +} + +// computePositionKey generates a unique base64-encoded key for a liquidity position. +// +// This function takes an owner's address and the lower and upper tick bounds of a position, +// and generates a unique key by concatenating the parameters into a string. The resulting +// string is base64 encoded to ensure it is compact and unique. +// +// Parameters: +// - owner (std.Address): The address of the position owner. +// - tickLower (int32): The lower tick boundary of the position. +// - tickUpper (int32): The upper tick boundary of the position. +// +// Returns: +// - string: A base64-encoded string representing the unique key for the position. +// +// Notes: +// - This function is useful in scenarios where unique identifiers for liquidity positions +// are required (e.g., decentralized exchange positions). +// - The key format follows the pattern "ownerAddress__tickLower__tickUpper" to ensure uniqueness. +func computePositionKey( + owner std.Address, + tickLower int32, + tickUpper int32, +) string { + key := ufmt.Sprintf("%s__%d__%d", owner.String(), tickLower, tickUpper) + encoded := base64.StdEncoding.EncodeToString([]byte(key)) + return encoded +} + +// nextId is the next tokenId to be minted +func getNextId() uint64 { + return nextId +} + +// incrementNextId increments the next tokenId to be minted +func incrementNextId() { + nextId++ +} + +// Mint creates a new liquidity position by depositing token pairs into the pool and minting a new LP token. +// +// Parameters: +// - token0: The address of token0. +// - token1: The address of token1. +// - fee: The fee tier of the pool, in basis points. +// - tickLower: The lower tick boundary of the position. +// - tickUpper: The upper tick boundary of the position. +// - amount0Desired: Desired amount of token0 to add as liquidity, as a string. +// - amount1Desired: Desired amount of token1 to add as liquidity, as a string. +// - amount0Min: Minimum acceptable amount of token0 to add as liquidity, as a string. +// - amount1Min: Minimum acceptable amount of token1 to add as liquidity, as a string. +// - deadline: Expiration timestamp for the transaction. +// - mintTo: Address to receive the minted LP token. +// - caller: The address of the entity (contract or user) providing liquidity; assets will be withdrawn from this address. +// +// Returns: +// - uint64: The ID of the newly minted liquidity position. +// - string: The amount of liquidity provided to the position. +// - string: The amount of token0 used in the mint. +// - string: The amount of token1 used in the mint. +// +// Behavior: +// 1. **Validation**: +// - Ensures the contract is not halted. +// - Validates that the caller is either a user or a staker contract. +// - If the caller is a user, validates the `mintTo` and `caller` addresses to ensure they match. +// - Checks the transaction's deadline to prevent expired transactions. +// 2. **Pre-Mint Setup**: +// - Calls `MintAndDistributeGns` to handle GNS emissions. +// - Processes the input parameters for minting (`processMintInput`) to standardize and validate the inputs. +// 3. **Mint Execution**: +// - Executes the mint operation using the processed parameters. +// - Withdraws the required token amounts (`token0` and `token1`) from the `caller` address. +// - Mints a new LP token, and the resulting LP token is sent to the `mintTo` address. +// 4. **Post-Mint Cleanup**: +// - If native tokens were used (e.g., `ugnot`), unwraps any leftover wrapped tokens (`wugnot`) and refunds them to the `caller` address. +// 5. **Event Emission**: +// - Emits a "Mint" event containing detailed information about the mint operation. +// +// Panics: +// - If the contract is halted. +// - If the caller is not authorized. +// - If the transaction deadline has passed. +// - If input validation fails. +// - If errors occur during the minting process or leftover token unwrapping. +// // ref: https://docs.gnoswap.io/contracts/position/position.gno#mint func Mint( token0 string, @@ -34,60 +173,27 @@ func Mint( fee uint32, tickLower int32, tickUpper int32, - amount0DesiredStr string, - amount1DesiredStr string, - amount0MinStr string, - amount1MinStr string, + amount0Desired string, + amount1Desired string, + amount0Min string, + amount1Min string, deadline int64, mintTo std.Address, caller std.Address, ) (uint64, string, string, string) { - common.IsHalted() - en.MintAndDistributeGns() - - prev := std.PrevRealm() - isUserCalled := prev.PkgPath() == "" - isStakerCalled := prev.Addr() == consts.STAKER_ADDR - - if common.GetLimitCaller() { - // only user or staker can call - if !(isUserCalled || isStakerCalled) { - panic(addDetailToError( - errNoPermission, - ufmt.Sprintf("only user or staker can call isUserCalled(%t) || isStakerCalled(%t), but called from %s", isUserCalled, isStakerCalled, prev.Addr().String()), - )) - } + assertOnlyNotHalted() + prevCaller := getPrevRealm() + assertOnlyUserOrStaker(prevCaller) + if isUserCall() { + // if user called, validate the prev address and input addresses(mintTo, caller) + assertOnlyValidAddressWith(prevCaller.Addr(), mintTo) + assertOnlyValidAddressWith(prevCaller.Addr(), caller) } + checkDeadline(deadline) - // if user called, set caller & mintTo to user address - if isUserCalled { - caller = prev.Addr() - mintTo = prev.Addr() - } - - token0, token1, token0IsNative, token1IsNative := processTokens(token0, token1) - userWugnotBalance := wugnot.BalanceOf(a2u(caller)) - - if token1 < token0 { - token0, token1 = token1, token0 - amount0DesiredStr, amount1DesiredStr = amount1DesiredStr, amount0DesiredStr - amount0MinStr, amount1MinStr = amount1MinStr, amount0MinStr - tickLower, tickUpper = -tickUpper, -tickLower - token0IsNative, token1IsNative = token1IsNative, token0IsNative - } - - amount0Desired := u256.MustFromDecimal(amount0DesiredStr) - amount1Desired := u256.MustFromDecimal(amount1DesiredStr) - amount0Min := u256.MustFromDecimal(amount0MinStr) - amount1Min := u256.MustFromDecimal(amount1MinStr) - - // one of token amount can be 0 if position is out of range - // check this condition by using DryMint() - poolPath := ufmt.Sprintf("%s:%s:%d", token0, token1, fee) - - handleNativeToken(token0IsNative, token1IsNative, caller) + en.MintAndDistributeGns() - mintParams := MintParams{ + mintInput := MintInput{ token0: token0, token1: token1, fee: fee, @@ -102,11 +208,30 @@ func Mint( caller: caller, } + processedInput, err := processMintInput(mintInput) + if err != nil { + panic(newErrorWithDetail(errInvalidInput, err.Error())) + } + + mintParams := newMintParams(processedInput, mintInput) tokenId, liquidity, amount0, amount1 := mint(mintParams) - handleLeftoverNativeToken(token0IsNative, token1IsNative, userWugnotBalance, caller) + if processedInput.tokenPair.token0IsNative && processedInput.tokenPair.wrappedAmount > amount0.Uint64() { + // unwrap leftover wugnot + err = unwrap(processedInput.tokenPair.wrappedAmount-amount0.Uint64(), caller) + if err != nil { + panic(newErrorWithDetail(errWrapUnwrap, err.Error())) + } + } + if processedInput.tokenPair.token1IsNative && processedInput.tokenPair.wrappedAmount > amount1.Uint64() { + // unwrap leftover wugnot + err = unwrap(processedInput.tokenPair.wrappedAmount-amount1.Uint64(), caller) + if err != nil { + panic(newErrorWithDetail(errWrapUnwrap, err.Error())) + } + } - poolSqrtPriceX96 := pl.PoolGetSlot0SqrtPriceX96(poolPath) + poolSqrtPriceX96 := pl.PoolGetSlot0SqrtPriceX96(processedInput.poolPath) prevAddr, prevPkgPath := getPrevAsString() @@ -114,69 +239,271 @@ func Mint( "Mint", "prevAddr", prevAddr, "prevRealm", prevPkgPath, - "tickLower", ufmt.Sprintf("%d", tickLower), - "tickUpper", ufmt.Sprintf("%d", tickUpper), - "poolPath", poolPath, + "tickLower", ufmt.Sprintf("%d", processedInput.tickLower), + "tickUpper", ufmt.Sprintf("%d", processedInput.tickUpper), + "poolPath", processedInput.poolPath, "mintTo", mintTo.String(), "caller", caller.String(), - "internal_lpTokenId", ufmt.Sprintf("%d", tokenId), - "internal_liquidity", liquidity.ToString(), - "internal_amount0", amount0.ToString(), - "internal_amount1", amount1.ToString(), - "internal_sqrtPriceX96", poolSqrtPriceX96, + "lpTokenId", ufmt.Sprintf("%d", tokenId), + "liquidity", liquidity.ToString(), + "amount0", amount0.ToString(), + "amount1", amount1.ToString(), + "sqrtPriceX96", poolSqrtPriceX96, ) return tokenId, liquidity.ToString(), amount0.ToString(), amount1.ToString() } -func processTokens(token0, token1 string) (string, string, bool, bool) { +// processMintInput processes and validates user input for minting liquidity. +// +// This function standardizes and verifies minting parameters by ensuring token order, parsing desired amounts, +// and handling native token wrapping. It returns a structured `ProcessedMintInput` that can be used for further minting operations. +// +// Parameters: +// - input (MintInput): Raw user input containing token addresses, tick bounds, and liquidity amounts. +// +// Returns: +// - ProcessedMintInput: A structured and validated version of the minting input. +// - error: Returns an error if input parsing, token processing, or number validation fails. +// +// Behavior: +// +// 1. **Number Validation**: +// - Validates `amount0Desired`, `amount1Desired`, `amount0Min`, and `amount1Min` to ensure they are valid numeric strings. +// - If validation fails, the function panics immediately with an error indicating invalid input. +// +// 2. **Token Processing**: +// - Calls `processTokens` to validate and convert tokens (`token0` and `token1`) into their final forms. +// - Handles wrapping of native tokens (e.g., UGNOT to WUGNOT) if necessary. +// - Stores token metadata in a `TokenPair` struct, including wrapped amounts and native token status. +// +// 3. **Amount Parsing**: +// - Converts `amount0Desired`, `amount1Desired`, `amount0Min`, and `amount1Min` from string to `u256.Uint` using `parseAmounts`. +// - Ensures accurate representation of liquidity amounts for further processing. +// +// 4. **Token Order Enforcement**: +// - If `token1` is lexicographically smaller than `token0`, the function swaps their order to enforce consistent pool identification. +// - Along with token swaps, the tick bounds (`tickLower`, `tickUpper`) are inverted to preserve correct price boundaries. +// - This step guarantees pool uniqueness by ensuring `token0 < token1`. +// +// 5. **Pool Path Calculation**: +// - Computes the pool path (`poolPath`) using the finalized token addresses and fee tier. +// - The pool path uniquely identifies the pool in which liquidity will be minted. +// +// 6. **Return**: +// - Returns a populated `ProcessedMintInput` struct containing the finalized minting parameters. +// +// Panics: +// - If any of the provided amount strings are invalid (non-numeric or empty). +// - If `processTokens` encounters errors during token validation or wrapping. +// +// Notes: +// - This function enforces token order and validates amounts to ensure the integrity of liquidity minting operations. +func processMintInput(input MintInput) (ProcessedMintInput, error) { + assertValidNumberString(input.amount0Desired) + assertValidNumberString(input.amount1Desired) + assertValidNumberString(input.amount0Min) + assertValidNumberString(input.amount1Min) + var result ProcessedMintInput + + // process tokens + token0, token1, token0IsNative, token1IsNative, wrappedAmount := processTokens(input.token0, input.token1, input.amount0Desired, input.amount1Desired, input.caller) + pair := TokenPair{ + token0: token0, + token1: token1, + token0IsNative: token0IsNative, + token1IsNative: token1IsNative, + wrappedAmount: wrappedAmount, + } + + // parse amounts + amount0Desired, amount1Desired, amount0Min, amount1Min := parseAmounts(input.amount0Desired, input.amount1Desired, input.amount0Min, input.amount1Min) + + tickLower, tickUpper := input.tickLower, input.tickUpper + + // swap if token1 < token0 + if token1 < token0 { + pair.token0, pair.token1 = pair.token1, pair.token0 + amount0Desired, amount1Desired = amount1Desired, amount0Desired + amount0Min, amount1Min = amount1Min, amount0Min + tickLower, tickUpper = -tickUpper, -tickLower + pair.token0IsNative, pair.token1IsNative = pair.token1IsNative, pair.token0IsNative + } + + poolPath := computePoolPath(pair.token0, pair.token1, input.fee) + + result = ProcessedMintInput{ + tokenPair: pair, + amount0Desired: amount0Desired.Clone(), + amount1Desired: amount1Desired.Clone(), + amount0Min: amount0Min.Clone(), + amount1Min: amount1Min.Clone(), + tickLower: tickLower, + tickUpper: tickUpper, + poolPath: poolPath, + } + + return result, nil +} + +// processTokens processes two token paths, validates them, and handles the wrapping of native tokens into wrapped tokens if applicable. +// +// Parameters: +// - token0: The first token path to process. +// - token1: The second token path to process. +// - caller: The address of the user initiating the token processing. +// +// Returns: +// - string: Processed token0 path (potentially modified if it was a native token). +// - string: Processed token1 path (potentially modified if it was a native token). +// - bool: Indicates whether token0 was a native token (`true` if native, `false` otherwise). +// - bool: Indicates whether token1 was a native token (`true` if native, `false` otherwise). +// - uint64: The amount of the native token that was wrapped into the wrapped token. +// +// Behavior: +// 1. Validates the token paths using `validateTokenPath`. +// - Panics with a detailed error if validation fails. +// 2. Checks if `token0` or `token1` is a native token using `isNative`. +// - If a token is native, it is replaced with the wrapped token path (`WRAPPED_WUGNOT`). +// - The native token is then wrapped into the wrapped token using `safeWrapNativeToken`. +// 3. Returns the processed token paths, flags indicating if the tokens were native, and the wrapped amount. +// +// Panics: +// - If `validateTokenPath` fails validation. +// - If wrapping the native token using `safeWrapNativeToken` encounters an issue. +func processTokens( + token0 string, + token1 string, + amount0Desired string, + amount1Desired string, + caller std.Address, +) (string, string, bool, bool, uint64) { + err := validateTokenPath(token0, token1) + if err != nil { + panic(newErrorWithDetail(err, ufmt.Sprintf("token0(%s), token1(%s)", token0, token1))) + } + token0IsNative := false token1IsNative := false - if token0 == consts.GNOT { + wrappedAmount := uint64(0) + + if isNative(token0) { token0 = consts.WRAPPED_WUGNOT token0IsNative = true - } else if token1 == consts.GNOT { + + wrappedAmount = safeWrapNativeToken(amount0Desired, caller) + } else if isNative(token1) { token1 = consts.WRAPPED_WUGNOT token1IsNative = true + + wrappedAmount = safeWrapNativeToken(amount1Desired, caller) } - return token0, token1, token0IsNative, token1IsNative -} - -func handleNativeToken(token0IsNative, token1IsNative bool, caller std.Address) { - if token0IsNative || token1IsNative { - oldUserWugnotBalance := wugnot.BalanceOf(a2u(caller)) - sent := std.GetOrigSend() - ugnotSent := uint64(sent.AmountOf("ugnot")) - if ugnotSent > 0 { - wrap(ugnotSent, caller) - newUserWugnotBalance := wugnot.BalanceOf(a2u(caller)) - if (newUserWugnotBalance - oldUserWugnotBalance) != ugnotSent { - panic(addDetailToError( - errWrapUnwrap, - ufmt.Sprintf("ugnot sent(%d) != wugnot received(%d)", ugnotSent, newUserWugnotBalance-oldUserWugnotBalance), - )) - } - } - } + + return token0, token1, token0IsNative, token1IsNative, wrappedAmount } -func handleLeftoverNativeToken(token0IsNative, token1IsNative bool, userWugnotBalance uint64, caller std.Address) { - if token0IsNative || token1IsNative { - userWugnotAfterMint := wugnot.BalanceOf(a2u(caller)) - leftOver := userWugnotAfterMint - userWugnotBalance - if leftOver > 0 { - unwrap(leftOver, caller) - } +// validateTokenPath validates the relationship and format of token paths. +// Ensures that token paths are not identical, not conflicting (e.g., GNOT and WUGNOT), +// and each token path is in a valid format. +// +// Parameters: +// - token0: The first token path to validate. +// - token1: The second token path to validate. +// +// Returns: +// - error: Returns `errInvalidTokenPath` or nil +// +// Example: +// +// validateTokenPath("tokenA", "tokenB") -> nil +// validateTokenPath("tokenA", "tokenA") -> errInvalidTokenPath +// validateTokenPath(GNOT, WUGNOT) -> errInvalidTokenPath +func validateTokenPath(token0, token1 string) error { + if token0 == token1 { + return errInvalidTokenPath + } + if (token0 == consts.GNOT && token1 == consts.WRAPPED_WUGNOT) || + (token0 == consts.WRAPPED_WUGNOT && token1 == consts.GNOT) { + return errInvalidTokenPath } + if (!isNative(token0) && !isValidTokenPath(token0)) || + (!isNative(token1) && !isValidTokenPath(token1)) { + return errInvalidTokenPath + } + return nil } -func mint(params MintParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint) { - checkDeadline(params.deadline) +// isValidTokenPath checks if the provided token path is registered. +// +// This function verifies if the specified token path exists in the system registry +// by invoking the `IsRegistered` method. A path is considered valid if no error +// is returned during the registration check. +// +// Parameters: +// - tokenPath: The string representing the token path to validate. +// +// Returns: +// - bool: Returns `true` if the token path is registered; otherwise, `false`. +func isValidTokenPath(tokenPath string) bool { + return common.IsRegistered(tokenPath) == nil +} - pool := pl.GetPool(params.token0, params.token1, params.fee) +// parseAmounts converts strings to u256.Uint values for amount0Desired, amount1Desired, amount0Min, and amount1Min. +func parseAmounts(amount0Desired, amount1Desired, amount0Min, amount1Min string) (*u256.Uint, *u256.Uint, *u256.Uint, *u256.Uint) { + return u256.MustFromDecimal(amount0Desired), u256.MustFromDecimal(amount1Desired), u256.MustFromDecimal(amount0Min), u256.MustFromDecimal(amount1Min) +} + +// computePoolPath returns the pool path based on the token pair and fee tier. +// +// This function constructs a unique pool path identifier by utilizing the two token addresses +// and the pool fee tier. It helps identify the specific liquidity pool on the platform. +// +// Parameters: +// - token0: The address of the first token (string). +// - token1: The address of the second token (string). +// - fee: The fee for the liquidity pool (uint32). +// +// Returns: +// - string: A unique path string representing the liquidity pool. +func computePoolPath(token0, token1 string, fee uint32) string { + return pl.GetPoolPath(token0, token1, fee) +} + +// mint creates a new liquidity position by adding liquidity to a pool and minting an NFT representing the position. +// +// This function handles the entire lifecycle of creating a new liquidity position, including adding liquidity +// to a pool, minting an NFT to represent the position, and storing the position's state. +// +// Parameters: +// - params (MintParams): A struct containing all necessary parameters to mint a new liquidity position, including: +// - token0, token1: The addresses of the token pair. +// - fee: The fee tier of the pool. +// - tickLower, tickUpper: The price range (ticks) for the liquidity position. +// - amount0Desired, amount1Desired: Desired amounts of token0 and token1 to provide. +// - amount0Min, amount1Min: Minimum acceptable amounts to prevent slippage. +// - caller: The address initiating the mint. The required token amounts (token0 and token1) will be withdrawn +// from the caller's balance and deposited into the pool. +// - mintTo: The address to receive the newly minted NFT. +// +// Returns: +// - uint64: The token ID of the minted liquidity position NFT. +// - *u256.Uint: The amount of liquidity added to the pool. +// - *u256.Uint: The actual amount of token0 used in the liquidity addition. +// - *u256.Uint: The actual amount of token1 used in the liquidity addition. +// +// Panics: +// - If the liquidity position (tokenId) already exists. +// - If adding liquidity fails due to insufficient amounts or invalid tick ranges. +// +// Notes: +// - This function relies on `addLiquidity` to perform the liquidity calculation and ensure proper slippage checks. +// - The NFT minted is critical for tracking the user's liquidity in the pool. +// - Position state management is handled by `setPosition`, ensuring the uniqueness of the tokenId. +func mint(params MintParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint) { + poolKey := pl.GetPoolPath(params.token0, params.token1, params.fee) liquidity, amount0, amount1 := addLiquidity( AddLiquidityParams{ - poolKey: pl.GetPoolPath(params.token0, params.token1, params.fee), + poolKey: poolKey, tickLower: params.tickLower, tickUpper: params.tickUpper, amount0Desired: params.amount0Desired, @@ -186,21 +513,26 @@ func mint(params MintParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint) { caller: params.caller, }, ) + // Ensure liquidity is not zero before minting NFT + if liquidity.IsZero() { + panic(newErrorWithDetail( + errZeroLiquidity, + "Liquidity is zero, cannot mint position.", + )) + } - tokenId := nextId + tokenId := getNextId() gnft.Mint(a2u(params.mintTo), tokenIdFrom(tokenId)) // owner, tokenId - nextId++ - positionKey := positionKeyCompute(GetOrigPkgAddr(), params.tickLower, params.tickUpper) - _feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey) - _feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey) - feeGrowthInside0LastX128 := u256.MustFromDecimal(_feeGrowthInside0LastX128.ToString()) - feeGrowthInside1LastX128 := u256.MustFromDecimal(_feeGrowthInside1LastX128.ToString()) + pool := pl.GetPoolFromPoolPath(poolKey) + positionKey := computePositionKey(GetOrigPkgAddr(), params.tickLower, params.tickUpper) + feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey).Clone() + feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey).Clone() position := Position{ nonce: u256.Zero(), operator: consts.ZERO_ADDRESS, - poolKey: pl.GetPoolPath(params.token0, params.token1, params.fee), + poolKey: poolKey, tickLower: params.tickLower, tickUpper: params.tickUpper, liquidity: liquidity, @@ -210,13 +542,16 @@ func mint(params MintParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint) { tokensOwed1: u256.Zero(), burned: false, } + + // The tokenId should not exist at the time of minting updated := setPosition(tokenId, position) if updated { - panic(addDetailToError( + panic(newErrorWithDetail( errPositionExist, ufmt.Sprintf("tokenId(%d) already exists", tokenId), )) } + incrementNextId() return tokenId, liquidity, amount0, amount1 } @@ -232,13 +567,31 @@ func IncreaseLiquidity( amount1MinStr string, deadline int64, ) (uint64, string, string, string, string) { - common.IsHalted() + assertOnlyNotHalted() + assertValidNumberString(amount0DesiredStr) + assertValidNumberString(amount1DesiredStr) + assertValidNumberString(amount0MinStr) + assertValidNumberString(amount1MinStr) + checkDeadline(deadline) + en.MintAndDistributeGns() - amount0Desired := u256.MustFromDecimal(amount0DesiredStr) - amount1Desired := u256.MustFromDecimal(amount1DesiredStr) - amount0Min := u256.MustFromDecimal(amount0MinStr) - amount1Min := u256.MustFromDecimal(amount1MinStr) + position := MustGetPosition(tokenId) + token0, token1, _ := splitOf(position.poolKey) + err := validateTokenPath(token0, token1) + if err != nil { + panic(newErrorWithDetail(err, ufmt.Sprintf("token0(%s), token1(%s)", token0, token1))) + } + caller := getPrevAddr() + + wrappedAmount := uint64(0) + if isWrappedToken(token0) { + wrappedAmount = safeWrapNativeToken(amount0DesiredStr, caller) + } else if isWrappedToken(token1) { + wrappedAmount = safeWrapNativeToken(amount1DesiredStr, caller) + } + + amount0Desired, amount1Desired, amount0Min, amount1Min := parseAmounts(amount0DesiredStr, amount1DesiredStr, amount0MinStr, amount1MinStr) increaseLiquidityParams := IncreaseLiquidityParams{ tokenId: tokenId, amount0Desired: amount0Desired, @@ -248,29 +601,21 @@ func IncreaseLiquidity( deadline: deadline, } - // wrap if target pool has wugnot - position := MustGetPosition(tokenId) - pToken0, pToken1, _ := splitOf(position.poolKey) - - isToken0Wugnot := pToken0 == consts.WRAPPED_WUGNOT - isToken1Wugnot := pToken1 == consts.WRAPPED_WUGNOT - - userOldWugnotBalance := wugnot.BalanceOf(a2u(std.PrevRealm().Addr())) // before wrap, user's origin wugnot balance - if isToken0Wugnot || isToken1Wugnot { - sent := std.GetOrigSend() - ugnotSent := uint64(sent.AmountOf("ugnot")) - wrap(ugnotSent, std.PrevRealm().Addr()) - } - - // increase liquidity _, liquidity, amount0, amount1, poolPath := increaseLiquidity(increaseLiquidityParams) - // unwrap left - if isToken0Wugnot || isToken1Wugnot { - userNewWugnotBalance := wugnot.BalanceOf(a2u(std.PrevRealm().Addr())) - - leftOver := userNewWugnotBalance - userOldWugnotBalance - unwrap(leftOver, std.PrevRealm().Addr()) + if isWrappedToken(token0) && wrappedAmount > amount0.Uint64() { + // unwrap leftover wugnot + err = unwrap(wrappedAmount-amount0.Uint64(), caller) + if err != nil { + panic(newErrorWithDetail(errWrapUnwrap, err.Error())) + } + } + if isWrappedToken(token1) && wrappedAmount > amount1.Uint64() { + // unwrap leftover wugnot + err = unwrap(wrappedAmount-amount1.Uint64(), caller) + if err != nil { + panic(newErrorWithDetail(errWrapUnwrap, err.Error())) + } } poolSqrtPriceX96 := pl.PoolGetSlot0SqrtPriceX96(poolPath) @@ -281,38 +626,77 @@ func IncreaseLiquidity( "IncreaseLiquidity", "prevAddr", prevAddr, "prevRealm", prevPkgPath, + "poolPath", poolPath, + "caller", caller.String(), "lpTokenId", ufmt.Sprintf("%d", tokenId), - "internal_poolPath", poolPath, - "internal_liquidity", liquidity.ToString(), - "internal_amount0", amount0.ToString(), - "internal_amount1", amount1.ToString(), - "internal_sqrtPriceX96", poolSqrtPriceX96, + "liquidity", liquidity.ToString(), + "amount0", amount0.ToString(), + "amount1", amount1.ToString(), + "sqrtPriceX96", poolSqrtPriceX96, ) return tokenId, liquidity.ToString(), amount0.ToString(), amount1.ToString(), poolPath } -func increaseLiquidity(params IncreaseLiquidityParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint, string) { - // verify tokenId exists - if !exists(params.tokenId) { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("tokenId(%d) doesn't exist", params.tokenId), - )) - } +// FeeGrowthInside represents fee growth inside ticks +type FeeGrowthInside struct { + feeGrowthInside0LastX128 *u256.Uint + feeGrowthInside1LastX128 *u256.Uint +} - // MUST BE OWNER TO INCREASE LIQUIDITY - // can not be approved address ≈ staked position can't be modified - owner := gnft.OwnerOf(tokenIdFrom(params.tokenId)) - caller := std.PrevRealm().Addr() - if owner != caller { - panic(addDetailToError( - errNoPermission, - ufmt.Sprintf("only owner(%s) can increase liquidity for tokenId(%d), but called from %s", owner, params.tokenId, caller), - )) +// PositionFeeUpdate represents fee update calculation result +type PositionFeeUpdate struct { + tokensOwed0 *u256.Uint + tokensOwed1 *u256.Uint + feeGrowthInside0LastX128 *u256.Uint + feeGrowthInside1LastX128 *u256.Uint +} + +func calculatePositionFeeUpdate( + position Position, + currentFeeGrowth FeeGrowthInside, +) PositionFeeUpdate { + tokensOwed0 := calculateTokensOwed( + currentFeeGrowth.feeGrowthInside0LastX128, + position.feeGrowthInside0LastX128, + position.liquidity, + ) + + tokensOwed1 := calculateTokensOwed( + currentFeeGrowth.feeGrowthInside1LastX128, + position.feeGrowthInside1LastX128, + position.liquidity, + ) + + return PositionFeeUpdate{ + tokensOwed0: new(u256.Uint).Add(position.tokensOwed0, tokensOwed0), + tokensOwed1: new(u256.Uint).Add(position.tokensOwed1, tokensOwed1), + feeGrowthInside0LastX128: currentFeeGrowth.feeGrowthInside0LastX128.Clone(), + feeGrowthInside1LastX128: currentFeeGrowth.feeGrowthInside1LastX128.Clone(), } +} - checkDeadline(params.deadline) +// updatePosition updates the position with new liquidity and fee data +func updatePosition( + position Position, + feeUpdate PositionFeeUpdate, + newLiquidity *u256.Uint, +) Position { + position.tokensOwed0 = feeUpdate.tokensOwed0 + position.tokensOwed1 = feeUpdate.tokensOwed1 + position.feeGrowthInside0LastX128 = feeUpdate.feeGrowthInside0LastX128 + position.feeGrowthInside1LastX128 = feeUpdate.feeGrowthInside1LastX128 + position.liquidity = new(u256.Uint).Add(position.liquidity, newLiquidity) + position.burned = false + + return position +} + +func increaseLiquidity(params IncreaseLiquidityParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint, string) { + // verify tokenId exists + assertTokenExists(params.tokenId) + caller := getPrevAddr() + assertOnlyOwnerOfToken(params.tokenId, caller) position := MustGetPosition(params.tokenId) liquidity, amount0, amount1 := addLiquidity( @@ -324,39 +708,37 @@ func increaseLiquidity(params IncreaseLiquidityParams) (uint64, *u256.Uint, *u25 amount1Desired: params.amount1Desired, amount0Min: params.amount0Min, amount1Min: params.amount1Min, - caller: std.PrevRealm().Addr(), + caller: caller, }, ) pool := pl.GetPoolFromPoolPath(position.poolKey) - positionKey := positionKeyCompute(GetOrigPkgAddr(), position.tickLower, position.tickUpper) - _feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey) - _feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey) - feeGrowthInside0LastX128 := u256.MustFromDecimal(_feeGrowthInside0LastX128.ToString()) - feeGrowthInside1LastX128 := u256.MustFromDecimal(_feeGrowthInside1LastX128.ToString()) + positionKey := computePositionKey(GetOrigPkgAddr(), position.tickLower, position.tickUpper) + feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey).Clone() + feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey).Clone() { diff := new(u256.Uint).Sub(feeGrowthInside0LastX128, position.feeGrowthInside0LastX128) - mulDiv := u256.MulDiv(diff, position.liquidity, u256.MustFromDecimal(consts.Q128)) + mulDiv := u256.MulDiv(diff, position.liquidity.Clone(), u256.MustFromDecimal(consts.Q128)) position.tokensOwed0 = new(u256.Uint).Add(position.tokensOwed0, mulDiv) } { diff := new(u256.Uint).Sub(feeGrowthInside1LastX128, position.feeGrowthInside1LastX128) - mulDiv := u256.MulDiv(diff, position.liquidity, u256.MustFromDecimal(consts.Q128)) + mulDiv := u256.MulDiv(diff, position.liquidity.Clone(), u256.MustFromDecimal(consts.Q128)) position.tokensOwed1 = new(u256.Uint).Add(position.tokensOwed1, mulDiv) } position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128 position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128 - position.liquidity = new(u256.Uint).Add(position.liquidity, liquidity) + position.liquidity = new(u256.Uint).Add(position.liquidity.Clone(), liquidity) position.burned = false updated := setPosition(params.tokenId, position) if !updated { - panic(addDetailToError( + panic(newErrorWithDetail( errPositionDoesNotExist, ufmt.Sprintf("can not increase liquidity for non-existent position(%d)", params.tokenId), )) @@ -369,6 +751,8 @@ func increaseLiquidity(params IncreaseLiquidityParams) (uint64, *u256.Uint, *u25 // It also handles the conversion between GNOT and WUGNOT transparently for the user. // Returns tokenId, liquidity, fee0, fee1, amount0, amount1, poolPath // ref: https://docs.gnoswap.io/contracts/position/position.gno#decreaseliquidity +// TODO: +// 1. liquidityRatio가 맞는지 검토 func DecreaseLiquidity( tokenId uint64, liquidityRatio uint64, @@ -377,16 +761,12 @@ func DecreaseLiquidity( deadline int64, unwrapResult bool, ) (uint64, string, string, string, string, string, string) { - common.IsHalted() - en.MintAndDistributeGns() + assertOnlyNotHalted() + isAuthorizedForToken(tokenId) + checkDeadline(deadline) + assertValidLiquidityRatio(liquidityRatio) - isNormalRange := liquidityRatio >= 1 && liquidityRatio <= 100 - if !isNormalRange { - panic(addDetailToError( - errOutOfRange, - ufmt.Sprintf("liquidityRatio(%d) should be in range 1 ~ 100", liquidityRatio), - )) - } + en.MintAndDistributeGns() amount0Min := u256.MustFromDecimal(amount0MinStr) amount1Min := u256.MustFromDecimal(amount1MinStr) @@ -429,10 +809,7 @@ func DecreaseLiquidity( // unwrapped to GNOT at the end of the operation. // Returns tokenId, liquidity, fee0, fee1, amount0, amount1, poolPath func decreaseLiquidity(params DecreaseLiquidityParams) (uint64, *u256.Uint, *u256.Uint, *u256.Uint, *u256.Uint, *u256.Uint, string) { - userOldWugnotBalance := wugnot.BalanceOf(a2u(std.PrevRealm().Addr())) // before unwrap - verifyTokenIdAndOwnership(params.tokenId) - checkDeadline(params.deadline) // BEFORE DECREASE LIQUIDITY, COLLECT FEE FIRST _, fee0Str, fee1Str, _, _, _ := CollectFee(params.tokenId, params.unwrapResult) @@ -441,32 +818,29 @@ func decreaseLiquidity(params DecreaseLiquidityParams) (uint64, *u256.Uint, *u25 position := MustGetPosition(params.tokenId) positionLiquidity := position.liquidity - if positionLiquidity.IsZero() { - panic(addDetailToError( + panic(newErrorWithDetail( errZeroLiquidity, ufmt.Sprintf("position(tokenId:%d) has 0 liquidity", params.tokenId), )) } - liquidityToRemove := calculateLiquidityToRemove(positionLiquidity, params.liquidityRatio) + caller := getPrevAddr() + beforeWugnotBalance := wugnot.BalanceOf(a2u(caller)) // before unwrap + liquidityToRemove := calculateLiquidityToRemove(positionLiquidity, params.liquidityRatio) pToken0, pToken1, pFee := splitOf(position.poolKey) - pool := pl.GetPoolFromPoolPath(position.poolKey) - // BURN HERE - _burnedAmount0, _burnedAmount1 := pl.Burn(pToken0, pToken1, pFee, position.tickLower, position.tickUpper, liquidityToRemove.ToString()) + burn0, burn1 := pl.Burn(pToken0, pToken1, pFee, position.tickLower, position.tickUpper, liquidityToRemove.ToString()) - burnedAmount0 := u256.MustFromDecimal(_burnedAmount0) - burnedAmount1 := u256.MustFromDecimal(_burnedAmount1) + burnedAmount0 := u256.MustFromDecimal(burn0) + burnedAmount1 := u256.MustFromDecimal(burn1) + verifySlippageAmounts(burnedAmount0, burnedAmount1, params.amount0Min, params.amount1Min) - verifyBurnedAmounts(burnedAmount0, burnedAmount1, params.amount0Min, params.amount1Min) - - positionKey := positionKeyCompute(GetOrigPkgAddr(), position.tickLower, position.tickUpper) - _feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey) - _feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey) - feeGrowthInside0LastX128 := u256.MustFromDecimal(_feeGrowthInside0LastX128.ToString()) - feeGrowthInside1LastX128 := u256.MustFromDecimal(_feeGrowthInside1LastX128.ToString()) + positionKey := computePositionKey(GetOrigPkgAddr(), position.tickLower, position.tickUpper) + pool := pl.GetPoolFromPoolPath(position.poolKey) + feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey).Clone() + feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey).Clone() position.tokensOwed0 = updateTokensOwed( feeGrowthInside0LastX128, @@ -487,39 +861,38 @@ func decreaseLiquidity(params DecreaseLiquidityParams) (uint64, *u256.Uint, *u25 position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128 position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128 position.liquidity = new(u256.Uint).Sub(positionLiquidity, liquidityToRemove) - updated := setPosition(params.tokenId, position) - if !updated { - panic(addDetailToError( - errPositionDoesNotExist, - ufmt.Sprintf("can not decrease liquidity for non-existent position(%d)", params.tokenId), - )) - } + mustUpdatePosition(params.tokenId, position) - // GIVE BACK TO USER - _amount0, _amount1 := pl.Collect( + collect0, collect1 := pl.Collect( pToken0, pToken1, pFee, - std.PrevRealm().Addr(), + caller, position.tickLower, position.tickUpper, - _burnedAmount0, - _burnedAmount1, + burn0, + burn1, ) - amount0 := u256.MustFromDecimal(_amount0) - amount1 := u256.MustFromDecimal(_amount1) + collectAmount0 := u256.MustFromDecimal(collect0) + collectAmount1 := u256.MustFromDecimal(collect1) - overflow := false - position.tokensOwed0, overflow = new(u256.Uint).SubOverflow(position.tokensOwed0, amount0) - if overflow { - position.tokensOwed0 = u256.Zero() + underflow := false + position.tokensOwed0, underflow = new(u256.Uint).SubOverflow(position.tokensOwed0, collectAmount0) + if underflow { + panic(newErrorWithDetail( + errUnderflow, + "tokensOwed0 underflow", + )) } - position.tokensOwed1, overflow = new(u256.Uint).SubOverflow(position.tokensOwed1, amount1) - if overflow { - position.tokensOwed1 = u256.Zero() + position.tokensOwed1, underflow = new(u256.Uint).SubOverflow(position.tokensOwed1, collectAmount1) + if underflow { + panic(newErrorWithDetail( + errUnderflow, + "tokensOwed1 underflow", + )) } - setPosition(params.tokenId, position) + mustUpdatePosition(params.tokenId, position) if position.isClear() { burnPosition(params.tokenId) // just update flag (we don't want to burn actual position) @@ -527,12 +900,123 @@ func decreaseLiquidity(params DecreaseLiquidityParams) (uint64, *u256.Uint, *u25 // NO UNWRAP if !params.unwrapResult { - return params.tokenId, liquidityToRemove, fee0, fee1, amount0, amount1, position.poolKey + return params.tokenId, liquidityToRemove, fee0, fee1, collectAmount0, collectAmount1, position.poolKey + } + + handleUnwrap(pToken0, pToken1, params.unwrapResult, beforeWugnotBalance, caller) + + return params.tokenId, liquidityToRemove, fee0, fee1, collectAmount0, collectAmount1, position.poolKey +} + +// CollectFee collects swap fee from the position +// Returns tokenId, afterFee0, afterFee1, poolPath, origFee0, origFee1 +// ref: https://docs.gnoswap.io/contracts/position/position.gno#collectfee +func CollectFee(tokenId uint64, unwrapResult bool) (uint64, string, string, string, string, string) { + assertOnlyNotHalted() + assertTokenExists(tokenId) + isAuthorizedForToken(tokenId) + + en.MintAndDistributeGns() + + // verify position + position := MustGetPosition(tokenId) + token0, token1, fee := splitOf(position.poolKey) + + pl.Burn( + token0, + token1, + fee, + position.tickLower, + position.tickUpper, + ZERO_LIQUIDITY_FOR_FEE_COLLECTION, // burn '0' liquidity to collect fee + ) + + ////////////// + currentFeeGrowth, err := getCurrentFeeGrowth(position, token0, token1, fee) + if err != nil { + panic(newErrorWithDetail(err, "failed to get current fee growth")) } - handleUnwrap(pToken0, pToken1, params.unwrapResult, userOldWugnotBalance, std.PrevRealm().Addr()) + tokensOwed0, tokensOwed1 := calculateFees(position, currentFeeGrowth) + + position.feeGrowthInside0LastX128 = currentFeeGrowth.feeGrowthInside0LastX128 + position.feeGrowthInside1LastX128 = currentFeeGrowth.feeGrowthInside1LastX128 + + // check user wugnot amount + // need this value to unwrap fee + caller := getPrevAddr() + userWugnot := wugnot.BalanceOf(a2u(caller)) + + // collect fee + amount0, amount1 := pl.Collect( + token0, token1, fee, + caller, + position.tickLower, position.tickUpper, + tokensOwed0.ToString(), tokensOwed1.ToString(), + ) + + // sometimes there will be a few less uBase amount than expected due to rounding down in core, but we just subtract the full amount expected + // instead of the actual amount so we can burn the token + position.tokensOwed0 = new(u256.Uint).Sub(tokensOwed0, u256.MustFromDecimal(amount0)) + position.tokensOwed1 = new(u256.Uint).Sub(tokensOwed1, u256.MustFromDecimal(amount1)) + mustUpdatePosition(tokenId, position) + + // handle withdrawal fee + withoutFee0, withoutFee1 := pl.HandleWithdrawalFee(tokenId, token0, amount0, token1, amount1, position.poolKey, std.PrevRealm().Addr()) + + // UNWRAP + pToken0, pToken1, _ := splitOf(position.poolKey) + handleUnwrap(pToken0, pToken1, unwrapResult, userWugnot, caller) + + prevAddr, prevPkgPath := getPrevAsString() + + std.Emit( + "CollectSwapFee", + "prevAddr", prevAddr, + "prevRealm", prevPkgPath, + "lpTokenId", ufmt.Sprintf("%d", tokenId), + "fee0", withoutFee0, + "fee1", withoutFee1, + "poolPath", position.poolKey, + "unwrapResult", ufmt.Sprintf("%t", unwrapResult), + ) + + return tokenId, withoutFee0, withoutFee1, position.poolKey, amount0, amount1 +} + +// calculateFees calculates the fees for the current position. +func calculateFees(position Position, currentFeeGrowth FeeGrowthInside) (*u256.Uint, *u256.Uint) { + fee0 := calculateTokensOwed( + currentFeeGrowth.feeGrowthInside0LastX128, + position.feeGrowthInside0LastX128, + position.liquidity, + ) + + fee1 := calculateTokensOwed( + currentFeeGrowth.feeGrowthInside1LastX128, + position.feeGrowthInside1LastX128, + position.liquidity, + ) + + tokensOwed0 := new(u256.Uint).Add(position.tokensOwed0.Clone(), fee0) + tokensOwed1 := new(u256.Uint).Add(position.tokensOwed1.Clone(), fee1) - return params.tokenId, liquidityToRemove, fee0, fee1, amount0, amount1, position.poolKey + return tokensOwed0, tokensOwed1 +} + +func getCurrentFeeGrowth(postion Position, token0, token1 string, fee uint32) (FeeGrowthInside, error) { + pool := pl.GetPoolFromPoolPath(postion.poolKey) + positionKey := computePositionKey(GetOrigPkgAddr(), postion.tickLower, postion.tickUpper) + + feeGrowthInside0 := pool.PositionFeeGrowthInside0LastX128(positionKey).Clone() + feeGrowthInside1 := pool.PositionFeeGrowthInside1LastX128(positionKey).Clone() + + feeGrowthInside := FeeGrowthInside{ + feeGrowthInside0LastX128: feeGrowthInside0, + feeGrowthInside1LastX128: feeGrowthInside1, + } + + return feeGrowthInside, nil } // Repositiomn adjusts the position of an existing liquidity token @@ -548,27 +1032,10 @@ func Reposition( amount0MinStr string, amount1MinStr string, ) (uint64, string, int32, int32, string, string) { - common.IsHalted() - en.MintAndDistributeGns() + assertOnlyNotHalted() + verifyTokenIdAndOwnership(tokenId) - // verify tokenId exists - if !exists(tokenId) { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("tokenId(%d) doesn't exist", tokenId), - )) - } - - // MUST BE OWNER TO REPOSITION - // can not be approved address > staked position can't be modified - owner := gnft.OwnerOf(tokenIdFrom(tokenId)) - caller := std.PrevRealm().Addr() - if owner != caller { - panic(addDetailToError( - errNoPermission, - ufmt.Sprintf("only owner(%s) can reposition for tokenId(%d), but called from %s", owner, tokenId, caller), - )) - } + en.MintAndDistributeGns() // position should be burned to reposition position := MustGetPosition(tokenId) @@ -576,30 +1043,15 @@ func Reposition( oldTickUpper := position.tickUpper if !(position.isClear()) { - panic(addDetailToError( + panic(newErrorWithDetail( errNotClear, ufmt.Sprintf("position(%d) isn't clear(liquidity:%s, tokensOwed0:%s, tokensOwed1:%s)", tokenId, position.liquidity.ToString(), position.tokensOwed0.ToString(), position.tokensOwed1.ToString()), )) } - token0, token1, _ := splitOf(position.poolKey) - // check if gnot pool - token0IsNative := false - token1IsNative := false - if token0 == consts.WRAPPED_WUGNOT { - token0IsNative = true - } else if token1 == consts.WRAPPED_WUGNOT { - token1IsNative = true - } - - ugnotSent := uint64(0) - if token0IsNative || token1IsNative { - // WRAP IT - sent := std.GetOrigSend() - ugnotSent = uint64(sent.AmountOf("ugnot")) - - wrap(ugnotSent, std.PrevRealm().Addr()) - } + caller := getPrevAddr() + token0, token1, fee := splitOf(position.poolKey) + token0, token1, _, _, _ = processTokens(token0, token1, amount0DesiredStr, amount1DesiredStr, caller) liquidity, amount0, amount1 := addLiquidity( AddLiquidityParams{ @@ -610,39 +1062,27 @@ func Reposition( amount1Desired: u256.MustFromDecimal(amount1DesiredStr), amount0Min: u256.MustFromDecimal(amount0MinStr), amount1Min: u256.MustFromDecimal(amount1MinStr), - caller: std.PrevRealm().Addr(), + caller: caller, }, ) - pool := pl.GetPoolFromPoolPath(position.poolKey) - positionKey := positionKeyCompute(GetOrigPkgAddr(), tickLower, tickUpper) - _feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey) - _feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey) - feeGrowthInside0LastX128 := u256.MustFromDecimal(_feeGrowthInside0LastX128.ToString()) - feeGrowthInside1LastX128 := u256.MustFromDecimal(_feeGrowthInside1LastX128.ToString()) - + currentFeeGrowth, err := getCurrentFeeGrowth(position, token0, token1, fee) + if err != nil { + panic(newErrorWithDetail(err, "failed to get current fee growth")) + } + position.feeGrowthInside0LastX128 = currentFeeGrowth.feeGrowthInside0LastX128 + position.feeGrowthInside1LastX128 = currentFeeGrowth.feeGrowthInside1LastX128 position.tickLower = tickLower position.tickUpper = tickUpper position.liquidity = liquidity - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128 - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128 // OBS: do not reset feeGrowthInside1LastX128 and feeGrowthInside1LastX128 to zero // if so, ( decrease 100% -> reposition ) // > at this point, that position will have unclaimedFee which isn't intended - // position.feeGrowthInside0LastX128 = u256.Zero() - // position.feeGrowthInside1LastX128 = u256.Zero() - position.tokensOwed0 = u256.Zero() position.tokensOwed1 = u256.Zero() position.burned = false - updated := setPosition(tokenId, position) - if !updated { - panic(addDetailToError( - errPositionDoesNotExist, - ufmt.Sprintf("can not reposition non-existent position(%d)", tokenId), - )) - } + mustUpdatePosition(tokenId, position) poolSqrtPriceX96 := pl.PoolGetSlot0SqrtPriceX96(position.poolKey) @@ -656,134 +1096,17 @@ func Reposition( "tickLower", ufmt.Sprintf("%d", tickLower), "tickUpper", ufmt.Sprintf("%d", tickUpper), "liquidity", liquidity.ToString(), - "internal_amount0", amount0.ToString(), - "internal_amount1", amount1.ToString(), - "internal_oldTickLower", ufmt.Sprintf("%d", oldTickLower), - "internal_oldTickUpper", ufmt.Sprintf("%d", oldTickUpper), - "internal_poolPath", position.poolKey, - "internal_sqrtPriceX96", poolSqrtPriceX96, + "amount0", amount0.ToString(), + "amount1", amount1.ToString(), + "oldTickLower", ufmt.Sprintf("%d", oldTickLower), + "oldTickUpper", ufmt.Sprintf("%d", oldTickUpper), + "poolPath", position.poolKey, + "sqrtPriceX96", poolSqrtPriceX96, ) return tokenId, liquidity.ToString(), tickLower, tickUpper, amount0.ToString(), amount1.ToString() } -// CollectFee collects swap fee from the position -// Returns tokenId, afterFee0, afterFee1, poolPath, origFee0, origFee1 -// ref: https://docs.gnoswap.io/contracts/position/position.gno#collectfee -func CollectFee(tokenId uint64, unwrapResult bool) (uint64, string, string, string, string, string) { - common.IsHalted() - en.MintAndDistributeGns() - - // verify tokenId - if !exists(tokenId) { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("tokenId(%d) doesn't exist", tokenId), - )) - } - - // verify owner or approved - isAuthorizedForToken(tokenId) - - // verify position - position := MustGetPosition(tokenId) - - token0, token1, fee := splitOf(position.poolKey) - - pl.Burn( - token0, - token1, - fee, - position.tickLower, - position.tickUpper, - "0", // burn '0' liquidity to collect fee - ) - - positionKey := positionKeyCompute(GetOrigPkgAddr(), position.tickLower, position.tickUpper) - pool := pl.GetPoolFromPoolPath(position.poolKey) - _feeGrowthInside0LastX128 := pool.PositionFeeGrowthInside0LastX128(positionKey) - _feeGrowthInside1LastX128 := pool.PositionFeeGrowthInside1LastX128(positionKey) - feeGrowthInside0LastX128 := u256.MustFromDecimal(_feeGrowthInside0LastX128.ToString()) - feeGrowthInside1LastX128 := u256.MustFromDecimal(_feeGrowthInside1LastX128.ToString()) - - tokensOwed0 := position.tokensOwed0 - tokensOwed1 := position.tokensOwed1 - - { - diff := new(u256.Uint).Sub(feeGrowthInside0LastX128, position.feeGrowthInside0LastX128) - mulDiv := u256.MulDiv(diff, position.liquidity, u256.MustFromDecimal(consts.Q128)) - - tokensOwed0 = new(u256.Uint).Add(tokensOwed0, mulDiv) - } - - { - diff := new(u256.Uint).Sub(feeGrowthInside1LastX128, position.feeGrowthInside1LastX128) - mulDiv := u256.MulDiv(diff, position.liquidity, u256.MustFromDecimal(consts.Q128)) - - tokensOwed1 = new(u256.Uint).Add(tokensOwed1, mulDiv) - } - - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128 - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128 - - // check user wugnot amount - // need this value to unwrap fee - userWugnot := wugnot.BalanceOf(a2u(std.PrevRealm().Addr())) - - amount0, amount1 := pl.Collect( - token0, - token1, - fee, - std.PrevRealm().Addr(), - position.tickLower, - position.tickUpper, - tokensOwed0.ToString(), - tokensOwed1.ToString(), - ) - - // sometimes there will be a few less uBase amount than expected due to rounding down in core, but we just subtract the full amount expected - // instead of the actual amount so we can burn the token - position.tokensOwed0 = new(u256.Uint).Sub(tokensOwed0, u256.MustFromDecimal(amount0)) - position.tokensOwed1 = new(u256.Uint).Sub(tokensOwed1, u256.MustFromDecimal(amount1)) - - updated := setPosition(tokenId, position) - if !updated { - panic(addDetailToError( - errPositionDoesNotExist, - ufmt.Sprintf("can not collect fee for non-existent position(%d)", tokenId), - )) - } - - // handle withdrawal fee - withoutFee0, withoutFee1 := pl.HandleWithdrawalFee(tokenId, token0, amount0, token1, amount1, position.poolKey, std.PrevRealm().Addr()) - - // UNWRAP - pToken0, pToken1, _ := splitOf(position.poolKey) - if (pToken0 == consts.WUGNOT_PATH || pToken1 == consts.WUGNOT_PATH) && unwrapResult { - userNewWugnot := wugnot.BalanceOf(a2u(std.PrevRealm().Addr())) - unwrapAmount := userNewWugnot - userWugnot - - if unwrapAmount > 0 { - unwrap(unwrapAmount, std.PrevRealm().Addr()) - } - } - - prevAddr, prevPkgPath := getPrevAsString() - - std.Emit( - "CollectSwapFee", - "prevAddr", prevAddr, - "prevRealm", prevPkgPath, - "lpTokenId", ufmt.Sprintf("%d", tokenId), - "internal_fee0", withoutFee0, - "internal_fee1", withoutFee1, - "internal_poolPath", position.poolKey, - "internal_unwrapResult", ufmt.Sprintf("%t", unwrapResult), - ) - - return tokenId, withoutFee0, withoutFee1, position.poolKey, amount0, amount1 -} - func calculateTokensOwed( feeGrowthInsideLastX128 *u256.Uint, positionFeeGrowthInsideLastX128 *u256.Uint, @@ -808,47 +1131,14 @@ func updateTokensOwed( func burnPosition(tokenId uint64) { position := MustGetPosition(tokenId) if !(position.isClear()) { - panic(addDetailToError( + panic(newErrorWithDetail( errNotClear, ufmt.Sprintf("position(%d) isn't clear(liquidity:%s, tokensOwed0:%s, tokensOwed1:%s)", tokenId, position.liquidity.ToString(), position.tokensOwed0.ToString(), position.tokensOwed1.ToString()), )) } position.burned = true - updated := setPosition(tokenId, position) - if !updated { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("can not burn non-existent position(%d)", tokenId), - )) - } -} - -func isAuthorizedForToken(tokenId uint64) { - if !(isOwnerOrOperator(std.PrevRealm().Addr(), tokenId)) { - panic(addDetailToError( - errNoPermission, - ufmt.Sprintf("caller(%s) is not approved or owner of tokenId(%d)", std.PrevRealm().Addr(), tokenId), - )) - } -} - -func verifyTokenIdAndOwnership(tokenId uint64) { - if !exists(tokenId) { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("tokenId(%d) doesn't exist", tokenId), - )) - } - - owner := gnft.OwnerOf(tokenIdFrom(tokenId)) - caller := std.PrevRealm().Addr() - if owner != caller { - panic(addDetailToError( - errNoPermission, - ufmt.Sprintf("only owner(%s) can decrease liquidity(tokenId:%d), but called from %s", owner, tokenId, caller), - )) - } + mustUpdatePosition(tokenId, position) } func calculateLiquidityToRemove(positionLiquidity *u256.Uint, liquidityRatio uint64) *u256.Uint { @@ -860,27 +1150,10 @@ func calculateLiquidityToRemove(positionLiquidity *u256.Uint, liquidityRatio uin return liquidityToRemove } -func verifyBurnedAmounts(burnedAmount0, burnedAmount1, amount0Min, amount1Min *u256.Uint) { - if !(burnedAmount0.Gte(amount0Min) && burnedAmount1.Gte(amount1Min)) { - panic(addDetailToError( - errSlippage, - ufmt.Sprintf("burnedAmount0(%s) >= amount0Min(%s) && burnedAmount1(%s) >= amount1Min(%s)", burnedAmount0.ToString(), amount0Min.ToString(), burnedAmount1.ToString(), amount1Min.ToString()), - )) - } -} - -func handleUnwrap(pToken0, pToken1 string, unwrapResult bool, userOldWugnotBalance uint64, to std.Address) { - if (pToken0 == consts.WRAPPED_WUGNOT || pToken1 == consts.WRAPPED_WUGNOT) && unwrapResult { - userNewWugnotBalance := wugnot.BalanceOf(a2u(to)) - leftOver := userNewWugnotBalance - userOldWugnotBalance - unwrap(leftOver, to) - } -} - func SetPositionOperator(tokenId uint64, operator std.Address) { - caller := std.PrevRealm().PkgPath() + caller := getPrevRealm().PkgPath() if caller != consts.STAKER_PATH { - panic(addDetailToError( + panic(newErrorWithDetail( errNoPermission, ufmt.Sprintf("caller(%s) is not staker", caller), )) @@ -888,49 +1161,5 @@ func SetPositionOperator(tokenId uint64, operator std.Address) { position := MustGetPosition(tokenId) position.operator = operator - updated := setPosition(tokenId, position) - if !updated { - panic(addDetailToError( - errPositionDoesNotExist, - ufmt.Sprintf("can not set operator for non-existent position(%d)", tokenId), - )) - } -} - -// MustGetPosition returns a position for a given tokenId -// panics if position doesn't exist -func MustGetPosition(tokenId uint64) Position { - position, exist := GetPosition(tokenId) - if !exist { - panic(addDetailToError( - errDataNotFound, - ufmt.Sprintf("position with tokenId(%d) doesn't exist", tokenId), - )) - } - return position -} - -// GetPosition returns a position for a given tokenId -// Returns false if position doesn't exist -func GetPosition(tokenId uint64) (Position, bool) { - tokenIdStr := strconv.FormatUint(tokenId, 10) - iPosition, exist := positions.Get(tokenIdStr) - if !exist { - return Position{}, false - } - - return iPosition.(Position), true -} - -// setPosition sets a position for a given tokenId -// Returns true if position is newly created, false if position already exists and just updated. -func setPosition(tokenId uint64, position Position) bool { - tokenIdStr := strconv.FormatUint(tokenId, 10) - return positions.Set(tokenIdStr, position) -} - -// removePosition removes a position for a given tokenId -func removePosition(tokenId uint64) { - tokenIdStr := strconv.FormatUint(tokenId, 10) - positions.Remove(tokenIdStr) + mustUpdatePosition(tokenId, position) } diff --git a/position/position_key.gno b/position/position_key.gno deleted file mode 100644 index cde00f2e1..000000000 --- a/position/position_key.gno +++ /dev/null @@ -1,19 +0,0 @@ -package position - -import ( - "encoding/base64" - "std" - - "gno.land/p/demo/ufmt" -) - -func positionKeyCompute( - owner std.Address, - tickLower int32, - tickUpper int32, -) string { - key := ufmt.Sprintf("%s__%d__%d", owner.String(), tickLower, tickUpper) - - encoded := base64.StdEncoding.EncodeToString([]byte(key)) - return encoded -} diff --git a/position/position_test.gno b/position/position_test.gno new file mode 100644 index 000000000..3a6e2666e --- /dev/null +++ b/position/position_test.gno @@ -0,0 +1,615 @@ +package position + +import ( + "std" + "strconv" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + u256 "gno.land/p/gnoswap/uint256" + + "gno.land/r/demo/users" + "gno.land/r/gnoswap/v1/consts" +) + +var ( + mockRegistry = make(map[string]bool) +) + +// MockRegister registers a token path in the mock registry. +func MockRegister(t *testing.T, tokenPath string) { + t.Helper() + mockRegistry[tokenPath] = true +} + +// MockUnregister unregisters a token path in the mock registry. +func MockUnregister(t *testing.T, tokenPath string) { + t.Helper() + delete(mockRegistry, tokenPath) +} + +// IsRegistered checks if a token path is registered in the mock registry. +func IsRegistered(t *testing.T, tokenPath string) error { + t.Helper() + if mockRegistry[tokenPath] { + return nil + } + return errInvalidTokenPath +} + +func TestMustGetPosition(t *testing.T) { + t.Skip("TODO: Implement") +} + +func TestGetPosition(t *testing.T) { + t.Skip("TODO: Implement") +} + +func TestSetPosition(t *testing.T) { + t.Skip("TODO: Implement") +} + +func TestRemovePosition(t *testing.T) { + t.Skip("TODO: Implement") +} + +func TestExistPosition(t *testing.T) { + t.Skip("TODO: Implement") +} + +func TestComputePositionKey(t *testing.T) { + tests := []struct { + name string + owner std.Address + tickLower int32 + tickUpper int32 + expected string + }{ + { + name: "Basic Position Key", + owner: users.Resolve(alice), + tickLower: -100, + tickUpper: 200, + expected: "ZzF2OWt4amNtOXRhMDQ3aDZsdGEwNDdoNmx0YTA0N2g2bHpkNDBnaF9fLTEwMF9fMjAw", // Base64 of "g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh__-100__200" + }, + { + name: "Zero Ticks", + owner: users.Resolve(alice), + tickLower: 0, + tickUpper: 0, + expected: "ZzF2OWt4amNtOXRhMDQ3aDZsdGEwNDdoNmx0YTA0N2g2bHpkNDBnaF9fMF9fMA==", // Base64 of "g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh__0__0" + }, + { + name: "Negative Lower Tick", + owner: users.Resolve(alice), + tickLower: -50, + tickUpper: 150, + expected: "ZzF2OWt4amNtOXRhMDQ3aDZsdGEwNDdoNmx0YTA0N2g2bHpkNDBnaF9fLTUwX18xNTA=", // Base64 of "g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh__-50__150" + }, + { + name: "Same Tick Bounds", + owner: users.Resolve(alice), + tickLower: 300, + tickUpper: 300, + expected: "ZzF2OWt4amNtOXRhMDQ3aDZsdGEwNDdoNmx0YTA0N2g2bHpkNDBnaF9fMzAwX18zMDA=", // Base64 of "g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh__300__300" + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := computePositionKey(tt.owner, tt.tickLower, tt.tickUpper) + + if result != tt.expected { + t.Errorf("expected %s but got %s", tt.expected, result) + } + }) + } +} + +func TestNextIdFunctions(t *testing.T) { + nextId = uint64(1) + t.Run("Initial nextId should return 1", func(t *testing.T) { + uassert.Equal(t, uint64(1), getNextId(), "expected nextId to start at 1") + }) + + t.Run("After mint nextId should return 2", func(t *testing.T) { + MakeMintPositionWithoutFee(t) + uassert.Equal(t, uint64(2), getNextId(), "expected nextId to be 2 after mint") + }) + + t.Run("Increment nextId once", func(t *testing.T) { + incrementNextId() + uassert.Equal(t, uint64(3), getNextId(), "expected nextId to increment to 2") + }) + + t.Run("Increment nextId multiple times", func(t *testing.T) { + for i := 0; i < 2; i++ { + incrementNextId() + } + uassert.Equal(t, uint64(5), getNextId(), "expected nextId to increment to 5 after 3 more increments") + }) + + t.Run("Ensure no overflow on normal increments", func(t *testing.T) { + for i := uint64(5); i < 100; i++ { + incrementNextId() + } + uassert.Equal(t, uint64(100), getNextId(), "expected nextId to reach 100 after continuous increments") + }) +} + +func TestIsValidTokenPath(t *testing.T) { + tests := []struct { + name string + tokenPath string + register bool + expected bool + }{ + { + name: "Valid Token Path", + tokenPath: gnsPath, + register: true, + expected: true, + }, + { + name: "Invalid Token Path", + tokenPath: "invalid/path", + register: false, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidTokenPath(tt.tokenPath) + uassert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateTokenPath(t *testing.T) { + tests := []struct { + name string + token0 string + token1 string + register0 bool + register1 bool + expected error + }{ + { + name: "Valid Token Path", + token0: gnsPath, + token1: barPath, + register0: true, + register1: true, + expected: nil, + }, + { + name: "Same Token Path", + token0: "tokenA", + token1: "tokenA", + register0: true, + register1: true, + expected: errInvalidTokenPath, + }, + { + name: "Conflicting Tokens (GNOT ↔ WUGNOT)", + token0: consts.GNOT, + token1: consts.WRAPPED_WUGNOT, + register0: true, + register1: true, + expected: errInvalidTokenPath, + }, + { + name: "Invalid Token Path", + token0: "tokenX", + token1: "tokenY", + register0: false, + register1: true, + expected: errInvalidTokenPath, + }, + { + name: "Both Invalid Tokens", + token0: "invalidA", + token1: "invalidB", + register0: false, + register1: false, + expected: errInvalidTokenPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.register0 { + MockRegister(t, tt.token0) + } else { + MockUnregister(t, tt.token0) + } + + if tt.register1 { + MockRegister(t, tt.token1) + } else { + MockUnregister(t, tt.token1) + } + + if tt.expected != nil { + err := validateTokenPath(tt.token0, tt.token1) + uassert.Equal(t, tt.expected.Error(), err.Error()) + } else { + uassert.NotPanics(t, func() { + err := validateTokenPath(tt.token0, tt.token1) + if err != nil { + t.Errorf("expected no error but got %s", err.Error()) + } + }) + } + }) + } +} + +func TestProcessTokens(t *testing.T) { + tests := []struct { + name string + token0 string + token1 string + amount0Desired string + amount1Desired string + caller std.Address + expected0 string + expected1 string + isNative0 bool + isNative1 bool + expectedWrap uint64 + expectPanic bool + expectMsg string + }{ + { + name: "Both tokens valid and not native", + token0: gnsPath, + token1: "tokenB", + amount0Desired: "100", + amount1Desired: "200", + caller: users.Resolve(alice), + expected0: "tokenA", + expected1: "tokenB", + isNative0: false, + isNative1: false, + expectedWrap: 0, + expectPanic: true, + expectMsg: "[GNOSWAP-POSITION-016] invalid token address || token0(gno.land/r/gnoswap/v1/gns), token1(tokenB)", + }, + { + name: "token0 is native", + token0: consts.GNOT, + token1: gnsPath, + amount0Desired: "1300", + amount1Desired: "200", + caller: users.Resolve(alice), + expected0: consts.WRAPPED_WUGNOT, + expected1: "gno.land/r/gnoswap/v1/gns", + isNative0: true, + isNative1: false, + expectedWrap: 1300, + expectPanic: false, + expectMsg: "[GNOSWAP-POSITION-016] invalid token address || token0(gnot), token1(gno.land/r/gnoswap/v1/gns)", + }, + { + name: "token1 is native", + token0: gnsPath, + token1: consts.GNOT, + amount0Desired: "150", + amount1Desired: "1250", + caller: testutils.TestAddress("user3"), + expected0: "gno.land/r/gnoswap/v1/gns", + expected1: consts.WRAPPED_WUGNOT, + isNative0: false, + isNative1: true, + expectedWrap: 1250, + expectPanic: false, + }, + { + name: "Both tokens are native", + token0: consts.GNOT, + token1: consts.GNOT, + amount0Desired: "1100", + amount1Desired: "1200", + caller: testutils.TestAddress("user4"), + expected0: consts.WRAPPED_WUGNOT, + expected1: consts.WRAPPED_WUGNOT, + isNative0: true, + isNative1: true, + expectedWrap: 2300, + expectPanic: true, + expectMsg: "[GNOSWAP-POSITION-016] invalid token address || token0(gnot), token1(gnot)", + }, + { + name: "Invalid token path", + token0: "invalidToken", + token1: "tokenB", + amount0Desired: "150", + amount1Desired: "200", + caller: testutils.TestAddress("user5"), + expectPanic: true, + expectMsg: "[GNOSWAP-POSITION-016] invalid token address || token0(invalidToken), token1(tokenB)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + MockRegister(t, tt.token0) + MockRegister(t, tt.token1) + + defer func() { + if r := recover(); r != nil { + if !tt.expectPanic { + t.Errorf("unexpected panic: %v", r) + } + } + }() + + if tt.token0 == consts.GNOT { + amount, _ := strconv.ParseUint(tt.amount0Desired, 10, 64) + ugnotFaucet(t, consts.POSITION_ADDR, amount) + std.TestSetRealm(std.NewUserRealm(consts.POSITION_ADDR)) + transferUGNOT(consts.POSITION_ADDR, consts.POSITION_ADDR, amount) + } + if tt.token1 == consts.GNOT { + amount, _ := strconv.ParseUint(tt.amount1Desired, 10, 64) + ugnotFaucet(t, consts.POSITION_ADDR, amount) + std.TestSetRealm(std.NewUserRealm(consts.POSITION_ADDR)) + transferUGNOT(consts.POSITION_ADDR, consts.POSITION_ADDR, amount) + } + + if !tt.expectPanic { + token0, token1, native0, native1, wrapped := processTokens( + tt.token0, + tt.token1, + tt.amount0Desired, + tt.amount1Desired, + tt.caller, + ) + + uassert.Equal(t, tt.expected0, token0) + uassert.Equal(t, tt.expected1, token1) + uassert.Equal(t, tt.isNative0, native0) + uassert.Equal(t, tt.isNative1, native1) + uassert.Equal(t, tt.expectedWrap, wrapped) + } else { + uassert.PanicsWithMessage(t, tt.expectMsg, func() { + processTokens(tt.token0, tt.token1, tt.amount0Desired, tt.amount1Desired, tt.caller) + }) + } + }) + } +} + +func TestProcessMintInput(t *testing.T) { + tests := []struct { + name string + input MintInput + expectedToken0 string + expectedToken1 string + expectedAmount0 string + expectedAmount1 string + expectedTickL int32 + expectedTickU int32 + expectError bool + }{ + { + name: "Standard Mint - Token0 < Token1", + input: MintInput{ + token0: gnsPath, + token1: barPath, + amount0Desired: "1000", + amount1Desired: "2000", + amount0Min: "800", + amount1Min: "1800", + tickLower: -10000, + tickUpper: 10000, + caller: users.Resolve(alice), + }, + expectedToken0: gnsPath, + expectedToken1: barPath, + expectedAmount0: "1000", + expectedAmount1: "2000", + expectedTickL: -10000, + expectedTickU: 10000, + expectError: false, + }, + { + name: "Token Swap - Token1 < Token0", + input: MintInput{ + token0: barPath, + token1: gnsPath, + amount0Desired: "2000", + amount1Desired: "1000", + amount0Min: "1800", + amount1Min: "800", + tickLower: -20000, + tickUpper: 20000, + caller: users.Resolve(alice), + }, + expectedToken0: gnsPath, + expectedToken1: barPath, + expectedAmount0: "1000", + expectedAmount1: "2000", + expectedTickL: -20000, + expectedTickU: 20000, + expectError: false, + }, + { + name: "Error Case - Invalid Amounts", + input: MintInput{ + token0: gnsPath, + token1: barPath, + amount0Desired: "invalid", + amount1Desired: "2000", + amount0Min: "800", + amount1Min: "1800", + tickLower: -5000, + tickUpper: 5000, + caller: users.Resolve(alice), + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.expectError { + t.Errorf("unexpected panic: %v", r) + } else { + uassert.Equal(t, "[GNOSWAP-POSITION-005] invalid input data || input string : invalid", r) + } + } + }() + processed, err := processMintInput(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + uassert.Equal(t, tt.expectedToken0, processed.tokenPair.token0) + uassert.Equal(t, tt.expectedToken1, processed.tokenPair.token1) + uassert.Equal(t, tt.expectedAmount0, processed.amount0Desired.ToString()) + uassert.Equal(t, tt.expectedAmount1, processed.amount1Desired.ToString()) + uassert.Equal(t, tt.expectedTickL, processed.tickLower) + uassert.Equal(t, tt.expectedTickU, processed.tickUpper) + } + }) + } +} + +func TestMintInternal(t *testing.T) { + MakeMintPositionWithoutFee(t) + TokenFaucet(t, fooPath, alice) + TokenFaucet(t, barPath, alice) + TokenApprove(t, fooPath, alice, pool, maxApprove) + TokenApprove(t, barPath, alice, pool, maxApprove) + + tests := []struct { + name string + params MintParams + expectedTokenId uint64 + expectedLiquidity string + expectedAmount0 string + expectedAmount1 string + expectPanic bool + expectedError string + tokenId uint64 + }{ + { + name: "Successful Mint", + params: MintParams{ + token0: barPath, + token1: fooPath, + fee: fee500, + tickLower: -100, + tickUpper: 100, + amount0Desired: u256.MustFromDecimal("10000"), + amount1Desired: u256.MustFromDecimal("10000"), + amount0Min: u256.MustFromDecimal("10"), + amount1Min: u256.MustFromDecimal("10"), + caller: users.Resolve(alice), + mintTo: users.Resolve(alice), + }, + expectedTokenId: 101, + expectedLiquidity: "2005104", + expectedAmount0: "10000", + expectedAmount1: "10000", + expectPanic: false, + }, + { + name: "Position Exists", + params: MintParams{ + token0: barPath, + token1: fooPath, + fee: fee500, + tickLower: -100, + tickUpper: 100, + amount0Desired: u256.MustFromDecimal("10000"), + amount1Desired: u256.MustFromDecimal("10000"), + amount0Min: u256.MustFromDecimal("10"), + amount1Min: u256.MustFromDecimal("10"), + caller: users.Resolve(alice), + mintTo: users.Resolve(alice), + }, + expectPanic: true, + expectedError: "token id already exists", + tokenId: 1, + }, + { + name: "Zero Liquidity Mint", + params: MintParams{ + token0: barPath, + token1: fooPath, + fee: fee500, + tickLower: -200, + tickUpper: 200, + amount0Desired: u256.Zero(), + amount1Desired: u256.Zero(), + amount0Min: u256.NewUint(5), + amount1Min: u256.NewUint(5), + caller: users.Resolve(alice), + mintTo: users.Resolve(alice), + }, + expectPanic: true, + expectedError: "[GNOSWAP-POOL-010] zero liquidity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.tokenId != 0 { + nextId = tt.tokenId + } + + if !tt.expectPanic { + std.TestSetRealm(std.NewUserRealm(tt.params.mintTo)) + tokenId, liquidity, amount0, amount1 := mint(tt.params) + uassert.Equal(t, tt.expectedTokenId, tokenId) + uassert.Equal(t, tt.expectedLiquidity, liquidity.ToString()) + uassert.Equal(t, tt.expectedAmount0, amount0.ToString()) + uassert.Equal(t, tt.expectedAmount1, amount1.ToString()) + } else { + uassert.PanicsWithMessage(t, tt.expectedError, func() { + mint(tt.params) + }) + } + }) + } +} + +func TestMint(t *testing.T) { + t.Skip("TestMint not implemented") +} + +func TestIncreaseLiquidityInternal(t *testing.T) { + t.Skip("TestIncreaseLiquidityInternal not implemented") +} + +func TestIncreaseLiquidity(t *testing.T) { + t.Skip("TestIncreaseLiquidity not implemented") +} + +func TestDecreaseLiquidityInternal(t *testing.T) { + t.Skip("TestDecreaseLiquidityInternal not implemented") +} + +func TestDecreaseLiquidity(t *testing.T) { + t.Skip("TestDecreaseLiquidity not implemented") +} + +func TestCollectFees(t *testing.T) { + t.Skip("TestCollectFees not implemented") +} + +func TestReposition(t *testing.T) { + t.Skip("TestReposition not implemented") +} diff --git a/position/type.gno b/position/type.gno index 68bc00529..7a5383745 100644 --- a/position/type.gno +++ b/position/type.gno @@ -6,6 +6,8 @@ import ( u256 "gno.land/p/gnoswap/uint256" ) +// Position represents a liquidity position in a pool. +// Each position tracks the amount of liquidity, fee growth, and tokens owed to the position owner. type Position struct { nonce *u256.Uint // nonce for permits @@ -50,15 +52,22 @@ type MintParams struct { caller std.Address // address to call the function } -type AddLiquidityParams struct { - poolKey string // poolPath of the pool which has the position - tickLower int32 // lower end of the tick range for the position - tickUpper int32 // upper end of the tick range for the position - amount0Desired *u256.Uint // desired amount of token0 to be minted - amount1Desired *u256.Uint // desired amount of token1 to be minted - amount0Min *u256.Uint // minimum amount of token0 to be minted - amount1Min *u256.Uint // minimum amount of token1 to be minted - caller std.Address // address to call the function +// newMintParams creates `MintParams` from processed input data. +func newMintParams(input ProcessedMintInput, mintInput MintInput) MintParams { + return MintParams{ + token0: input.tokenPair.token0, + token1: input.tokenPair.token1, + fee: mintInput.fee, + tickLower: mintInput.tickLower, + tickUpper: mintInput.tickUpper, + amount0Desired: input.amount0Desired, + amount1Desired: input.amount1Desired, + amount0Min: input.amount0Min, + amount1Min: input.amount1Min, + deadline: mintInput.deadline, + mintTo: mintInput.mintTo, + caller: mintInput.caller, + } } type IncreaseLiquidityParams struct { @@ -78,3 +87,37 @@ type DecreaseLiquidityParams struct { deadline int64 // time by which the transaction must be included to effect the change unwrapResult bool // whether to unwrap the token if it's wrapped native token } + +type MintInput struct { + token0 string + token1 string + fee uint32 + tickLower int32 + tickUpper int32 + amount0Desired string + amount1Desired string + amount0Min string + amount1Min string + deadline int64 + mintTo std.Address + caller std.Address +} + +type TokenPair struct { + token0 string + token1 string + token0IsNative bool + token1IsNative bool + wrappedAmount uint64 +} + +type ProcessedMintInput struct { + tokenPair TokenPair + amount0Desired *u256.Uint + amount1Desired *u256.Uint + amount0Min *u256.Uint + amount1Min *u256.Uint + tickLower int32 + tickUpper int32 + poolPath string +} diff --git a/position/utils.gno b/position/utils.gno index 2121fb4fb..e072461e7 100644 --- a/position/utils.gno +++ b/position/utils.gno @@ -2,14 +2,171 @@ package position import ( "std" + "strconv" "time" + "gno.land/p/demo/grc/grc721" "gno.land/p/demo/ufmt" pusers "gno.land/p/demo/users" + "gno.land/r/gnoswap/v1/common" "gno.land/r/gnoswap/v1/consts" + "gno.land/r/gnoswap/v1/gnft" + + u256 "gno.land/p/gnoswap/uint256" ) +// GetOrigPkgAddr returns the original package address. +// In position contract, original package address is the position address. +func GetOrigPkgAddr() std.Address { + return consts.POSITION_ADDR +} + +// assertTokenExists checks if a token with the specified tokenId exists in the system. +// +// This function verifies the existence of a token by its tokenId. If the token does not exist, +// it triggers a panic with a descriptive error message. +// +// Parameters: +// - tokenId (uint64): The unique identifier of the token to check. +// +// Panics: +// - If the token does not exist, it panics with the error `errDataNotFound`. +func assertTokenExists(tokenId uint64) { + if !exists(tokenId) { + panic(newErrorWithDetail( + errDataNotFound, + ufmt.Sprintf("tokenId(%d) doesn't exist", tokenId), + )) + } +} + +// assertOnlyOwnerOfToken ensures that only the owner of the specified token can perform operations on it. +// +// This function checks if the caller is the owner of the token identified by `tokenId`. +// If the caller is not the owner, the function triggers a panic to prevent unauthorized access. +// +// Parameters: +// - tokenId (uint64): The unique identifier of the token to check ownership. +// - caller (std.Address): The address of the entity attempting to modify the token. +// +// Panics: +// - If the caller is not the owner of the token, the function panics with an `errNoPermission` error. +func assertOnlyOwnerOfToken(tokenId uint64, caller std.Address) { + owner := gnft.OwnerOf(tokenIdFrom(tokenId)) + assertCallerIsOwner(tokenId, owner, caller) +} + +func assertCallerIsOwner(tokenId uint64, owner, caller std.Address) { + if owner != caller { + panic(newErrorWithDetail( + errNoPermission, + ufmt.Sprintf("caller(%s) is not owner(%s) for tokenId(%d)", caller, owner, tokenId), + )) + } +} + +// assertOnlyUserOrStaker panics if the caller is not a user or staker. +func assertOnlyUserOrStaker(caller std.Realm) { + if !caller.IsUser() { + if err := common.StakerOnly(caller.Addr()); err != nil { + panic(newErrorWithDetail( + errNoPermission, + ufmt.Sprintf("from (%s)", caller.Addr()), + )) + } + } +} + +// assertOnlyNotHalted panics if the contract is halted. +func assertOnlyNotHalted() { + common.IsHalted() +} + +// assertOnlyValidAddress panics if the address is invalid. +func assertOnlyValidAddress(addr std.Address) { + if !addr.IsValid() { + panic(newErrorWithDetail( + errInvalidAddress, + ufmt.Sprintf("(%s)", addr), + )) + } +} + +// assertOnlyValidAddress panics if the address is invalid or previous address is not +// different from the other address. +func assertOnlyValidAddressWith(prevAddr, otherAddr std.Address) { + assertOnlyValidAddress(prevAddr) + assertOnlyValidAddress(otherAddr) + + if prevAddr != otherAddr { + panic(newErrorWithDetail( + errInvalidAddress, + ufmt.Sprintf("(%s, %s)", prevAddr, otherAddr), + )) + } +} + +// assertValidNumberString verifies that the input string represents a valid integer. +// +// This function checks each character in the string to ensure it falls within the numeric ASCII range ('0' to '9'). +// The first character is allowed to be a negative sign ('-'), indicating a negative number. If the input does not +// meet these criteria, the function panics with a detailed error message. +// +// Parameters: +// - input (string): The string to validate. +// +// Panics: +// - If the input string is empty. +// - If the input contains non-numeric characters (excluding an optional leading '-'). +// - If the input is not a valid integer representation. +// +// Example: +// +// assertValidNumberString("12345") -> Pass (valid positive number) +// assertValidNumberString("-98765") -> Pass (valid negative number) +// assertValidNumberString("12a45") -> Panic (invalid character 'a') +// assertValidNumberString("") -> Panic (empty input) +// assertValidNumberString("++123") -> Panic (invalid leading '+') +func assertValidNumberString(input string) { + if len(input) == 0 { + panic(newErrorWithDetail( + errInvalidInput, + ufmt.Sprintf("input is empty"))) + } + + bytes := []byte(input) + for i, b := range bytes { + if i == 0 && b == '-' { + continue // Allow if the first character is a negative sign (-) + } + if b < '0' || b > '9' { + panic(newErrorWithDetail( + errInvalidInput, + ufmt.Sprintf("input string : %s", input))) + } + } +} + +func assertValidLiquidityRatio(ratio uint64) { + if !(ratio >= 1 && ratio <= 100) { + panic(newErrorWithDetail( + errInvalidLiquidityRatio, + ufmt.Sprintf("liquidity ratio must in range 1 ~ 100(contain), got %d", ratio), + )) + } +} + +// [DEPRECATED] assertOnlyValidAddress panics if the address is invalid. +func assertWrapNativeToken(ugnotSent uint64, prevRealm std.Address) { + if err := wrap(ugnotSent, prevRealm); err != nil { + panic(newErrorWithDetail( + errWrapUnwrap, + ufmt.Sprintf("wrap error: %s", err.Error()), + )) + } +} + // a2u converts std.Address to pusers.AddressOrName. // pusers is a package that contains the user-related functions. // @@ -27,12 +184,6 @@ func derivePkgAddr(pkgPath string) std.Address { return std.DerivePkgAddr(pkgPath) } -// getOrigPkgAddr returns the original package address. -// In position contract, original package address is the position address. -func getOrigPkgAddr() std.Address { - return consts.POSITION_ADDR -} - // getPrevRealm returns object of the previous realm. func getPrevRealm() std.Realm { return std.PrevRealm() @@ -45,7 +196,7 @@ func getPrevAddr() std.Address { // getPrev returns the address and package path of the previous realm. func getPrevAsString() (string, string) { - prev := std.PrevRealm() + prev := getPrevRealm() return prev.Addr().String(), prev.PkgPath() } @@ -69,43 +220,143 @@ func checkDeadline(deadline int64) { } } -// assertOnlyUserOrStaker panics if the caller is not a user or staker. -func assertOnlyUserOrStaker(caller std.Realm) { - if !caller.IsUser() { - if err := common.StakerOnly(caller.Addr()); err != nil { - panic(newErrorWithDetail( - errNoPermission, - ufmt.Sprintf("from (%s)", caller.Addr()), - )) +// tokenIdFrom converts tokenId to grc721.TokenID type +// NOTE: input parameter tokenId can be string, int, uint64, or grc721.TokenID +// if tokenId is nil or not supported, it will panic +// if tokenId is not found, it will panic +// input: tokenId interface{} +// output: grc721.TokenID +func tokenIdFrom(tokenId interface{}) grc721.TokenID { + if tokenId == nil { + panic(newErrorWithDetail(errInvalidInput, "tokenId is nil")) + } + + switch tokenId.(type) { + case string: + return grc721.TokenID(tokenId.(string)) + case int: + return grc721.TokenID(strconv.Itoa(tokenId.(int))) + case uint64: + return grc721.TokenID(strconv.Itoa(int(tokenId.(uint64)))) + case grc721.TokenID: + return tokenId.(grc721.TokenID) + default: + panic(newErrorWithDetail(errInvalidInput, "unsupported tokenId type")) + } +} + +// exists checks whether tokenId exists +// If tokenId doesn't exist, return false, otherwise return true +// input: tokenId uint64 +// output: bool +func exists(tokenId uint64) bool { + return gnft.Exists(tokenIdFrom(tokenId)) +} + +// isOwner checks whether the caller is the owner of the tokenId +// If the caller is the owner of the tokenId, return true, otherwise return false +// input: tokenId uint64, addr std.Address +// output: bool +func isOwner(tokenId uint64, addr std.Address) bool { + owner := gnft.OwnerOf(tokenIdFrom(tokenId)) + if owner == addr { + return true + } + return false +} + +// isOperator checks whether the caller is the approved operator of the tokenId +// If the caller is the approved operator of the tokenId, return true, otherwise return false +// input: tokenId uint64, addr std.Address +// output: bool +func isOperator(tokenId uint64, addr std.Address) bool { + operator, ok := gnft.GetApproved(tokenIdFrom(tokenId)) + if ok && operator == addr { + return true + } + return false +} + +// isStaked checks whether tokenId is staked +// If tokenId is staked, owner of tokenId is staker contract +// If tokenId is staked, return true, otherwise return false +// input: tokenId grc721.TokenID +// output: bool +func isStaked(tokenId grc721.TokenID) bool { + exist := gnft.Exists(tokenId) + if exist { + owner := gnft.OwnerOf(tokenId) + if owner == consts.STAKER_ADDR { + return true } } + return false } -// assertOnlyNotHalted panics if the contract is halted. -func assertOnlyNotHalted() { - common.IsHalted() +// isOwnerOrOperator checks whether the caller is the owner or approved operator of the tokenId +// If the caller is the owner or approved operator of the tokenId, return true, otherwise return false +// input: addr std.Address, tokenId uint64 +// output: bool +func isOwnerOrOperator(addr std.Address, tokenId uint64) bool { + assertOnlyValidAddress(addr) + if !exists(tokenId) { + return false + } + if isOwner(tokenId, addr) || isOperator(tokenId, addr) { + return true + } + if isStaked(tokenIdFrom(tokenId)) { + position, exist := GetPosition(tokenId) + if exist && addr == position.operator { + return true + } + } + return false } -// assertOnlyValidAddress panics if the address is invalid. -func assertOnlyValidAddress(addr std.Address) { - if !addr.IsValid() { +func isAuthorizedForToken(tokenId uint64) { + caller := getPrevAddr() + if !(isOwnerOrOperator(caller, tokenId)) { panic(newErrorWithDetail( - errInvalidAddress, - ufmt.Sprintf("(%s)", addr), + errNoPermission, + ufmt.Sprintf("caller(%s) is not approved or owner of tokenId(%d)", caller, tokenId), )) } } -// assertOnlyValidAddress panics if the address is invalid or previous address is not -// different from the other address. -func assertOnlyValidAddressWith(prevAddr, otherAddr std.Address) { - assertOnlyValidAddress(prevAddr) - assertOnlyValidAddress(otherAddr) +// splitOf divides poolKey into pToken0, pToken1, and pFee +// If poolKey is invalid, it will panic +// +// input: poolKey string +// output: +// - token0Path string +// - token1Path string +// - fee uint32 +func splitOf(poolKey string) (string, string, uint32) { + res, err := common.Split(poolKey, ":", 3) + if err != nil { + panic(newErrorWithDetail(errInvalidInput, ufmt.Sprintf("invalid poolKey(%s)", poolKey))) + } + pToken0, pToken1, pFeeStr := res[0], res[1], res[2] - if prevAddr != otherAddr { + pFee, err := strconv.Atoi(pFeeStr) + if err != nil { + panic(newErrorWithDetail(errInvalidInput, ufmt.Sprintf("invalid fee(%s)", pFeeStr))) + } + return pToken0, pToken1, uint32(pFee) +} + +func verifyTokenIdAndOwnership(tokenId uint64) { + assertTokenExists(tokenId) + assertOnlyOwnerOfToken(tokenId, getPrevAddr()) +} + +func verifySlippageAmounts(amount0, amount1, amount0Min, amount1Min *u256.Uint) { + if !(amount0.Gte(amount0Min) && amount1.Gte(amount1Min)) { panic(newErrorWithDetail( - errInvalidAddress, - ufmt.Sprintf("(%s, %s)", prevAddr, otherAddr), + errSlippage, + ufmt.Sprintf("amount0(%s) >= amount0Min(%s) && amount1(%s) >= amount1Min(%s)", + amount0.ToString(), amount0Min.ToString(), amount1.ToString(), amount1Min.ToString()), )) } } diff --git a/position/utils_test.gno b/position/utils_test.gno index 1a2c80e40..04d33a469 100644 --- a/position/utils_test.gno +++ b/position/utils_test.gno @@ -1,20 +1,363 @@ package position import ( + "fmt" "std" + "strings" "testing" + "gno.land/p/demo/grc/grc721" "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" pusers "gno.land/p/demo/users" "gno.land/r/demo/users" "gno.land/r/gnoswap/v1/common" "gno.land/r/gnoswap/v1/consts" ) +func assertPanic(t *testing.T, expectedMsg string, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Errorf("expected panic but got none") + } else if r != expectedMsg { + t.Errorf("expected panic %v, got %v", expectedMsg, r) + } + }() + fn() +} + +func TestGetOrigPkgAddr(t *testing.T) { + tests := []struct { + name string + expected std.Address + }{ + { + name: "Success - getOrigPkgAddr", + expected: consts.POSITION_ADDR, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := GetOrigPkgAddr() + uassert.Equal(t, got, tc.expected) + }) + } +} + +func TestAssertTokenExists(t *testing.T) { + // Mock setup + mockTokenId := uint64(1001) + mockInvalidTokenId := uint64(9999) + + oldExists := exists + // Mock the exists function + mockExists := func(tokenId uint64) bool { + if tokenId == mockTokenId { + return true + } + return false + } + + // Replace exists with mockExists for testing + exists = mockExists + + t.Run("Token Exists - No Panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("unexpected panic for existing tokenId(%d): %v", mockTokenId, r) + } + }() + assertTokenExists(mockTokenId) // Should not panic + }) + + t.Run("Token Does Not Exist - Panic Expected", func(t *testing.T) { + expectedErr := ufmt.Sprintf("[GNOSWAP-POSITION-006] requested data not found || tokenId(%d) doesn't exist", mockInvalidTokenId) + uassert.PanicsWithMessage(t, expectedErr, func() { + assertTokenExists(mockInvalidTokenId) // Should panic + }) + }) + + t.Run("Boundary Case - TokenId Zero", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic for tokenId(0) but did not occur") + } + }() + assertTokenExists(0) // Assume tokenId 0 does not exist + }) + + exists = oldExists +} + +func TestAssertOnlyOwnerOfToken(t *testing.T) { + // Mock token ownership + mockTokenId := uint64(1) + mockOwner := users.Resolve(admin) + mockCaller := users.Resolve(alice) + //MakeMintPositionWithoutFee(t) + + t.Run("Token Owned by Caller - No Panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("unexpected panic for tokenId(%d) called by owner(%s): %v", mockTokenId, mockOwner, r) + } + }() + assertOnlyOwnerOfToken(mockTokenId, mockOwner) // Should pass without panic + }) + + t.Run("Token Not Owned by Caller - Panic Expected", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic for tokenId(%d) called by unauthorized user(%s), but no panic occurred", mockTokenId, mockCaller) + } else { + expectedErr := ufmt.Sprintf("caller(%s) is not owner(%s) for tokenId(%d)", mockCaller, mockOwner, mockTokenId) + if !strings.Contains(fmt.Sprintf("%v", r), expectedErr) { + t.Errorf("unexpected error message: %v", r) + } + } + }() + assertOnlyOwnerOfToken(mockTokenId, mockCaller) // Should panic + }) + + t.Run("Non-Existent Token - Panic Expected", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic for non-existent tokenId(9999), but no panic occurred") + } + }() + assertOnlyOwnerOfToken(9999, mockCaller) // Token 9999 does not exist, should panic + }) +} + +func TestAssertOnlyUserOrStaker(t *testing.T) { + tests := []struct { + name string + originCaller std.Address + expected bool + }{ + { + name: "Failure - Not User or Staker", + originCaller: consts.ROUTER_ADDR, + expected: false, + }, + { + name: "Success - User Call", + originCaller: consts.ADMIN, + expected: true, + }, + { + name: "Success - Staker Call", + originCaller: consts.STAKER_ADDR, + expected: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + std.TestSetOrigCaller(tc.originCaller) + assertOnlyUserOrStaker(std.PrevRealm()) + }) + } +} + +func TestAssertOnlyNotHalted(t *testing.T) { + tests := []struct { + name string + expected bool + panicMsg string + }{ + { + name: "Failure - Halted", + expected: false, + panicMsg: "[GNOSWAP-COMMON-002] halted || gnoswap halted", + }, + { + name: "Success - Not Halted", + expected: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expected { + uassert.NotPanics(t, func() { + assertOnlyNotHalted() + }) + } else { + std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) + common.SetHaltByAdmin(true) + uassert.PanicsWithMessage(t, tc.panicMsg, func() { + assertOnlyNotHalted() + }) + common.SetHaltByAdmin(false) + } + }) + } +} + +func TestAssertOnlyValidAddress(t *testing.T) { + tests := []struct { + name string + addr std.Address + expected bool + errorMsg string + }{ + { + name: "Success - valid address", + addr: consts.ADMIN, + expected: true, + }, + { + name: "Failure - invalid address", + addr: "g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8", // invalid length + expected: false, + errorMsg: "[GNOSWAP-POSITION-012] invalid address || (g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expected { + uassert.NotPanics(t, func() { + assertOnlyValidAddress(tc.addr) + }) + } else { + uassert.PanicsWithMessage(t, tc.errorMsg, func() { + assertOnlyValidAddress(tc.addr) + }) + } + }) + } +} + +func TestAssertOnlyValidAddressWith(t *testing.T) { + tests := []struct { + name string + addr std.Address + other std.Address + expected bool + errorMsg string + }{ + { + name: "Success - validation address check to compare with other address", + addr: consts.ADMIN, + other: std.Address("g17290cwvmrapvp869xfnhhawa8sm9edpufzat7d"), + expected: true, + }, + { + name: "Failure - two address is different", + addr: "g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8", + other: "g17290cwvmrapvp869xfnhhawa8sm9edpufzat7d", + expected: false, + errorMsg: "[GNOSWAP-POSITION-012] invalid address || (g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expected { + uassert.NotPanics(t, func() { + assertOnlyValidAddressWith(tc.addr, tc.other) + }) + } else { + uassert.PanicsWithMessage(t, tc.errorMsg, func() { + assertOnlyValidAddressWith(tc.addr, tc.other) + }) + } + }) + } +} + +func TestAssertValidNumberString(t *testing.T) { + tests := []struct { + name string + input string + expectPanic bool + }{ + // Valid Cases + { + name: "Valid Positive Number", + input: "12345", + expectPanic: false, + }, + { + name: "Valid Negative Number", + input: "-98765", + expectPanic: false, + }, + { + name: "Zero", + input: "0", + expectPanic: false, + }, + { + name: "Negative Zero", + input: "-0", + expectPanic: false, + }, + + // Invalid Cases + { + name: "Empty String", + input: "", + expectPanic: true, + }, + { + name: "Alphabet in String", + input: "12a45", + expectPanic: true, + }, + { + name: "Special Characters", + input: "123@45", + expectPanic: true, + }, + { + name: "Leading Plus Sign", + input: "+12345", + expectPanic: true, + }, + { + name: "Multiple Negative Signs", + input: "--12345", + expectPanic: true, + }, + { + name: "Space in String", + input: "123 45", + expectPanic: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.expectPanic { + t.Errorf("unexpected panic for input: %s, got: %v", tt.input, r) + } + } else { + if tt.expectPanic { + t.Errorf("expected panic but did not occur for input: %s", tt.input) + } + } + }() + + // Test function + assertValidNumberString(tt.input) + }) + } +} + +func TestAssertValidLiquidityRatio(t *testing.T) { + t.Skip("TODO: Implement TestAssertValidLiquidityRatio") +} + +func TestAssertWrapNativeToken(t *testing.T) { + // TODO: + +} + func TestA2u(t *testing.T) { - var ( - addr = std.Address("g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8c") - ) + addr := std.Address("g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8c") tests := []struct { name string @@ -37,9 +380,7 @@ func TestA2u(t *testing.T) { } func TestDerivePkgAddr(t *testing.T) { - var ( - pkgPath = "gno.land/r/gnoswap/v1/position" - ) + pkgPath := "gno.land/r/gnoswap/v1/position" tests := []struct { name string input string @@ -59,24 +400,6 @@ func TestDerivePkgAddr(t *testing.T) { } } -func TestGetOrigPkgAddr(t *testing.T) { - tests := []struct { - name string - expected std.Address - }{ - { - name: "Success - getOrigPkgAddr", - expected: consts.POSITION_ADDR, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := getOrigPkgAddr() - uassert.Equal(t, got, tc.expected) - }) - } -} - func TestGetPrevRealm(t *testing.T) { tests := []struct { name string @@ -192,6 +515,12 @@ func TestCheckDeadline(t *testing.T) { now: 1234567890, expected: "[GNOSWAP-POSITION-007] transaction expired || transaction too old, now(1234567890) > deadline(1234567790)", }, + { + name: "Success - deadline equals now", + deadline: 1234567890, + now: 1234567890, + expected: "", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -208,136 +537,416 @@ func TestCheckDeadline(t *testing.T) { } } -func TestAssertOnlyUserOrStaker(t *testing.T) { +func TestTokenIdFrom(t *testing.T) { tests := []struct { - name string - originCaller std.Address - expected bool + name string + input interface{} + expected string + shouldPanic bool }{ { - name: "Failure - Not User or Staker", - originCaller: consts.ROUTER_ADDR, - expected: false, + name: "Panic - nil", + input: nil, + expected: "[GNOSWAP-POSITION-005] invalid input data || tokenId is nil", + shouldPanic: true, }, { - name: "Success - User Call", - originCaller: consts.ADMIN, - expected: true, + name: "Panic - unsupported type", + input: float64(1), + expected: "[GNOSWAP-POSITION-005] invalid input data || unsupported tokenId type", + shouldPanic: true, }, { - name: "Success - Staker Call", - originCaller: consts.STAKER_ADDR, - expected: true, + name: "Success - string", + input: "1", + expected: "1", + shouldPanic: false, + }, + { + name: "Success - int", + input: int(1), + expected: "1", + shouldPanic: false, + }, + { + name: "Success - uint64", + input: uint64(1), + expected: "1", + shouldPanic: false, + }, + { + name: "Success - grc721.TokenID", + input: grc721.TokenID("1"), + expected: "1", + shouldPanic: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - std.TestSetOrigCaller(tc.originCaller) - assertOnlyUserOrStaker(std.PrevRealm()) + defer func() { + r := recover() + if r == nil { + if tc.shouldPanic { + t.Errorf(">>> %s: expected panic but got none", tc.name) + return + } + } else { + switch r.(type) { + case string: + if r.(string) != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + case error: + if r.(error).Error() != tc.expected { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expected) + } + default: + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expected) + } + } + }() + + if !tc.shouldPanic { + got := tokenIdFrom(tc.input) + uassert.Equal(t, tc.expected, string(got)) + } else { + tokenIdFrom(tc.input) + } }) } } -func TestAssertOnlyNotHalted(t *testing.T) { +func TestExists(t *testing.T) { tests := []struct { name string + tokenId uint64 expected bool - panicMsg string }{ { - name: "Failure - Halted", + name: "Fail - not exists", + tokenId: 2, expected: false, - panicMsg: "[GNOSWAP-COMMON-002] halted || gnoswap halted", }, { - name: "Success - Not Halted", - expected: true, + name: "Success - exists", + tokenId: 2, + expected: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.expected { - uassert.NotPanics(t, func() { - assertOnlyNotHalted() - }) - } else { - std.TestSetRealm(std.NewUserRealm(users.Resolve(admin))) - common.SetHaltByAdmin(true) - uassert.PanicsWithMessage(t, tc.panicMsg, func() { - assertOnlyNotHalted() - }) - common.SetHaltByAdmin(false) + } + got := exists(tc.tokenId) + uassert.Equal(t, tc.expected, got) }) } } -func TestAssertOnlyValidAddress(t *testing.T) { +func TestIsOwner(t *testing.T) { tests := []struct { name string + tokenId uint64 addr std.Address expected bool - errorMsg string }{ { - name: "Success - valid address", - addr: consts.ADMIN, + name: "Fail - is not owner", + tokenId: 1, + addr: users.Resolve(alice), + expected: false, + }, + { + name: "Success - is owner", + tokenId: 1, + addr: users.Resolve(admin), expected: true, }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + //MakeMintPositionWithoutFee(t) + got := isOwner(tc.tokenId, tc.addr) + uassert.Equal(t, tc.expected, got) + }) + } +} + +func TestIsOperator(t *testing.T) { + tests := []struct { + name string + tokenId uint64 + addr pusers.AddressOrName + expected bool + }{ { - name: "Failure - invalid address", - addr: "g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8", // invalid length + name: "Fail - is not operator", + tokenId: 1, + addr: alice, expected: false, - errorMsg: "[GNOSWAP-POSITION-012] invalid address || (g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8)", + }, + { + name: "Success - is operator", + tokenId: 1, + addr: bob, + expected: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.expected { - uassert.NotPanics(t, func() { - assertOnlyValidAddress(tc.addr) - }) - } else { - uassert.PanicsWithMessage(t, tc.errorMsg, func() { - assertOnlyValidAddress(tc.addr) - }) + LPTokenApprove(t, admin, tc.addr, tc.tokenId) } + got := isOperator(tc.tokenId, users.Resolve(tc.addr)) + uassert.Equal(t, tc.expected, got) }) } } -func TestAssertOnlyValidAddressWith(t *testing.T) { +func TestIsStaked(t *testing.T) { tests := []struct { name string - addr std.Address - other std.Address + owner pusers.AddressOrName + operator pusers.AddressOrName + tokenId uint64 expected bool - errorMsg string }{ { - name: "Success - validation address check to compare with other address", - addr: consts.ADMIN, - other: std.Address("g17290cwvmrapvp869xfnhhawa8sm9edpufzat7d"), + name: "Fail - is not staked", + owner: bob, + operator: alice, + tokenId: 1, + expected: false, + }, + { + name: "Fail - is not exist tokenId", + owner: admin, + operator: bob, + tokenId: 100, + expected: false, + }, + { + name: "Success - is staked", + owner: admin, + operator: admin, + tokenId: 1, expected: true, }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expected && tc.owner == tc.operator { + LPTokenStake(t, tc.owner, tc.tokenId) + } + got := isStaked(tokenIdFrom(tc.tokenId)) + uassert.Equal(t, tc.expected, got) + if tc.expected && tc.owner == tc.operator { + LPTokenUnStake(t, tc.owner, tc.tokenId, false) + } + }) + } +} + +func TestIsOwnerOrOperator(t *testing.T) { + tests := []struct { + name string + owner pusers.AddressOrName + operator pusers.AddressOrName + tokenId uint64 + expected bool + }{ { - name: "Failure - two address is different", - addr: "g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8", - other: "g17290cwvmrapvp869xfnhhawa8sm9edpufzat7d", + name: "Fail - is not owner or operator", + owner: admin, + operator: alice, + tokenId: 1, expected: false, - errorMsg: "[GNOSWAP-POSITION-012] invalid address || (g1lmvrrrr4er2us84h2732sru76c9zl2nvknha8)", + }, + { + name: "Success - is operator", + owner: admin, + operator: bob, + tokenId: 1, + expected: true, + }, + { + name: "Success - is owner", + owner: admin, + operator: admin, + tokenId: 1, + expected: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if tc.expected { - uassert.NotPanics(t, func() { - assertOnlyValidAddressWith(tc.addr, tc.other) - }) + if tc.expected && tc.owner != tc.operator { + LPTokenApprove(t, tc.owner, tc.operator, tc.tokenId) + } + var got bool + if tc.owner == tc.operator { + got = isOwnerOrOperator(users.Resolve(tc.owner), tc.tokenId) } else { - uassert.PanicsWithMessage(t, tc.errorMsg, func() { - assertOnlyValidAddressWith(tc.addr, tc.other) + got = isOwnerOrOperator(users.Resolve(tc.operator), tc.tokenId) + } + uassert.Equal(t, tc.expected, got) + }) + } +} + +func TestIsOwnerOrOperatorWithStake(t *testing.T) { + tests := []struct { + name string + owner pusers.AddressOrName + operator pusers.AddressOrName + tokenId uint64 + isStake bool + expected bool + }{ + { + name: "Fail - is not token staked", + owner: admin, + operator: alice, + tokenId: 1, + isStake: false, + expected: false, + }, + { + name: "Success - is token staked (position operator)", + owner: admin, + operator: admin, + tokenId: 1, + isStake: true, + expected: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.isStake { + LPTokenApprove(t, pusers.AddressOrName(tc.owner), pusers.AddressOrName(consts.STAKER_ADDR), tc.tokenId) + LPTokenStake(t, tc.owner, tc.tokenId) + } + got := isOwnerOrOperator(users.Resolve(tc.operator), tc.tokenId) + uassert.Equal(t, tc.expected, got) + }) + } +} + +func TestIsAuthorizedForToken(t *testing.T) { + t.Skip("TODO - Implement TestIsAuthorizedForToken") +} + +func TestPoolKeyDivide(t *testing.T) { + tests := []struct { + name string + poolKey string + expectedPath0 string + expectedPath1 string + expectedFee uint32 + expectedError string + shouldPanic bool + }{ + { + name: "Fail - invalid poolKey", + poolKey: "gno.land/r/onbloc", + expectedError: "[GNOSWAP-POSITION-005] invalid input data || invalid poolKey(gno.land/r/onbloc)", + shouldPanic: true, + }, + { + name: "Success - split poolKey", + poolKey: "gno.land/r/gnoswap/v1/gns:gno.land/r/demo/wugnot:500", + expectedPath0: gnsPath, + expectedPath1: wugnotPath, + expectedFee: fee500, + shouldPanic: false, + }, + { + name: "Fail -empty poolKey", + poolKey: "", + expectedError: "[GNOSWAP-POSITION-005] invalid input data || invalid poolKey()", + shouldPanic: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + if tc.shouldPanic { + t.Errorf(">>> %s: expected panic but got none", tc.name) + return + } + } else { + switch r.(type) { + case string: + if r.(string) != tc.expectedError { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expectedError) + } + case error: + if r.(error).Error() != tc.expectedError { + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r.(error).Error(), tc.expectedError) + } + default: + t.Errorf(">>> %s: got panic %v, want %v", tc.name, r, tc.expectedError) + } + } + }() + + if !tc.shouldPanic { + gotToken0, gotToken1, gotFee := splitOf(tc.poolKey) + uassert.Equal(t, tc.expectedPath0, gotToken0) + uassert.Equal(t, tc.expectedPath1, gotToken1) + uassert.Equal(t, tc.expectedFee, gotFee) + } else { + splitOf(tc.poolKey) + } + }) + } +} + +func TestSplitOf_Improved(t *testing.T) { + tests := []struct { + name string + poolKey string + expectedPath0 string + expectedPath1 string + expectedFee uint32 + expectedError string + shouldPanic bool + }{ + { + name: "Fail - empty poolKey", + poolKey: "", + expectedError: "[GNOSWAP-POSITION-005] invalid input data || invalid poolKey()", + shouldPanic: true, + }, + { + name: "Fail - invalid delimiter", + poolKey: "gno.land/r/gnoswap:v1/gns:gno.land/r/demo/wugnot-500", + expectedError: "[GNOSWAP-POSITION-005] invalid input data || invalid fee(gno.land/r/demo/wugnot-500)", + shouldPanic: true, + }, + { + name: "Success - valid poolKey", + poolKey: "gno.land/r/gnoswap/v1/gns:gno.land/r/demo/wugnot:500", + expectedPath0: "gno.land/r/gnoswap/v1/gns", + expectedPath1: "gno.land/r/demo/wugnot", + expectedFee: 500, + shouldPanic: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.shouldPanic { + assertPanic(t, tc.expectedError, func() { + splitOf(tc.poolKey) }) + } else { + gotToken0, gotToken1, gotFee := splitOf(tc.poolKey) + uassert.Equal(t, tc.expectedPath0, gotToken0) + uassert.Equal(t, tc.expectedPath1, gotToken1) + uassert.Equal(t, tc.expectedFee, gotFee) } }) } diff --git a/position/wrap_unwrap.gno b/position/wrap_unwrap.gno deleted file mode 100644 index f0fe53955..000000000 --- a/position/wrap_unwrap.gno +++ /dev/null @@ -1,49 +0,0 @@ -package position - -import ( - "std" - - "gno.land/r/demo/wugnot" - - "gno.land/p/demo/ufmt" - "gno.land/r/gnoswap/v1/consts" -) - -func wrap(ugnotAmount uint64, to std.Address) { - if ugnotAmount == 0 { - return - } - - if ugnotAmount < consts.UGNOT_MIN_DEPOSIT_TO_WRAP { - panic(addDetailToError( - errWugnotMinimum, - ufmt.Sprintf("amount(%d) < minimum(%d)", ugnotAmount, consts.UGNOT_MIN_DEPOSIT_TO_WRAP), - )) - } - - // WRAP IT - wugnotAddr := std.DerivePkgAddr(consts.WRAPPED_WUGNOT) - banker := std.GetBanker(std.BankerTypeRealmSend) - - banker.SendCoins(consts.POSITION_ADDR, wugnotAddr, std.Coins{{"ugnot", int64(ugnotAmount)}}) - wugnot.Deposit() // POSITION HAS WUGNOT - - // SEND WUGNOT: POSITION -> USER - wugnot.Transfer(a2u(to), ugnotAmount) -} - -func unwrap(wugnotAmount uint64, to std.Address) { - if wugnotAmount == 0 { - return - } - - // SEND WUGNOT: USER -> POSITION - wugnot.TransferFrom(a2u(to), a2u(consts.POSITION_ADDR), wugnotAmount) - - // UNWRAP IT - wugnot.Withdraw(wugnotAmount) - - // SEND GNOT: POSITION -> USER - banker := std.GetBanker(std.BankerTypeRealmSend) - banker.SendCoins(consts.POSITION_ADDR, to, std.Coins{{"ugnot", int64(wugnotAmount)}}) -}