Split LP token into a standalone SEP-41 contract - #104
Merged
0takuc0mrade merged 10 commits intoJul 10, 2026
Conversation
New nodus-protocol-lp-token workspace member. Registered but empty -- the actual contract comes in following commits.
Topics include the emitting contract's address, matching the pool contract's own event convention, so an indexer watching every LP token instance across every pool can attribute each event correctly.
Standard SEP-41 token interface (transfer/transfer_from/approve/ allowance/burn/burn_from/balance/name/symbol/decimals) plus a pool-gated mint that isn't part of that standard -- SEP-41 deliberately leaves minting out since it's issuer-specific. mint takes the caller explicitly and compares it against the stored pool address, rather than always requiring the stored address's own auth unconditionally -- matches how the pool contract itself gates set_fee_to/pause, and is testable under mock_all_auths() without per-address auth mocking. burn requires from's own auth (not the pool's): when the pool calls this during remove_liquidity, from already authorized that top-level call, and that same authorization covers this nested one too. A holder can also burn directly, bypassing the pool -- forfeiting their claim on the underlying reserves with no payout. That only benefits every other LP holder proportionally, which is unusual but not unsafe. transfer's "to" parameter is a MuxedAddress per the real SEP-41 interface (not a plain Address), so a payment can carry a muxed id for the recipient's own bookkeeping; the balance itself is always credited to the underlying Address.
Replaces the pool's internal LP ledger (contracts/pool/src/lp_token.rs, now deleted) with cross-contract calls into the new nodus-protocol-lp-token instance. The pool no longer stores LP balances/allowances/supply itself -- DataKey::LpBalance/LpAllowance/LpTotalSupply are gone, replaced by a single DataKey::LpToken address set at initialize(). The pool imports the LP token contract's interface via contractimport! against its compiled WASM, not a regular Cargo path dependency on the crate. A plain dependency would link the LP token crate's own #[contractimpl]-generated WASM exports into the pool's binary too -- confirmed empirically, since both crates export an `initialize` function, which fails the link with a duplicate-symbol error. contractimport! only pulls in the client type and call signatures. This does mean build order now matters: contracts/lp-token must be built to WASM before the pool, since contractimport! reads that file at compile time (handled in the next tooling commit). initialize() now takes the LP token's address as a parameter rather than deploying or initializing it itself -- that stays the factory's job (planned, not yet built). The direct lp_balance_of/lp_total_supply/ transfer_lp/approve_lp/lp_allowance/transfer_lp_from methods are dropped in favor of a single lp_token() accessor; callers query/transact against the LP token contract directly now.
Every call site of initialize() picks up the new lp_token parameter. lp_balance_starts_zero and lp_total_supply_starts_zero (integration tests) are removed outright rather than adjusted -- that state now lives on the LP token contract, not the pool, so there's nothing left on the pool side to assert. The equivalent unit test is renamed to lp_token_readable_after_init and now just checks the stored address round-trips. Adds one new integration test that exercises the real cross-contract wiring end to end: a genuine NodusLpToken instance as the pool's LP token, and two more NodusLpToken instances standing in for token_0/token_1 (close enough to a real SEP-41 token via mint/balance/transfer_from) to prove add_liquidity/remove_liquidity actually move real balances through real cross-contract calls, not just internal bookkeeping like the tests it replaces did.
Covers the standalone nodus-protocol-lp-token contract in isolation (no pool involved): initialize sets metadata and rejects a second call, metadata queries fail before initialize, pool-gated mint accepts the stored pool address and rejects everyone else, mint/burn reject non-positive amounts, transfer and transfer_from move balances and enforce insufficient-balance/allowance errors, approve enforces expiration_ledger except when revoking with amount 0, and burn_from spends the allowance while reducing total_supply.
contractimport! reads the LP token contract's compiled WASM at compile time (see the wiring commit), so that WASM must exist before anything touches the pool crate -- build, test, clippy, all of it. A single `cargo build --workspace` from a clean target/ won't reliably do this: Cargo has no dependency-graph edge between the two crates (that's the whole point of contractimport! over a regular path dependency), so within one workspace invocation it's free to compile them in parallel and sometimes does, racing the pool's build against an LP token WASM that doesn't exist yet. Hit that race locally, which is what prompted splitting every job into an explicit "build LP token first" step. CI: build/test/lint jobs all gain a `-p nodus-protocol-lp-token` step before their existing workspace-wide command, and the workspace-wide commands themselves switch from implicit-single-crate to explicit --workspace/--all so they keep covering both crates as more get added. Build job additionally uploads the LP token WASM as its own artifact alongside the pool's. Makefile: new build-lp-token target encapsulates the ordering; build/ test/lint all depend on it. test-math's three cargo test invocations gain -p nodus-protocol-amm since cargo test math_tests unqualified would now ambiguously match tests in either crate. scripts/build.sh and deploy.sh: updated for the wasm32v1-none path (the repo had already moved off wasm32-unknown-unknown) and to build/ upload/deploy both WASMs instead of one.
README now describes the two-contract architecture: the pool talks to its LP token over contractimport! rather than a regular crate dependency, why (duplicate-symbol link error otherwise), and the build ordering that requires (spelled out in both Architecture and Build sections, since running cargo directly instead of through make needs the same two-step build). Adds an LP Token Contract section documenting its interface, and updates the pool's LP token interface table down to the single lp_token() accessor now that balance/transfer/approve/allowance live on the token contract itself. initialize()'s new fee_to_setter and lp_token parameters are reflected in both the function table and the deploy examples. Notes the planned factory (deploy + wire a pool/LP-token pair per token combination) and router (multi-hop swaps) contracts as follow-up work, since today's manual one-pair-per-deployment flow and single hard-coded pair per pool instance are call-outs worth being upfront about rather than silently living only in the deploy script.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
nodus-protocol-lp-tokenSEP-41-compatible contract, one instance per pool.contractimport!against the compiled WASM (a regular crate dependency isn't possible here — both crates exportinitialize, which fails the link with a duplicate-symbol error), which introduces a build-order requirement: LP token WASM before pool.make build/test/lintall encode this ordering now; CI and the deploy/build scripts are updated to match.initialize()on the pool gainsfee_to_setterandlp_tokenparameters; the pool's directlp_balance_of/transfer_lp/approve_lp/lp_allowance/transfer_lp_frommethods are replaced by a singlelp_token()accessor since callers now interact with the token contract directly.Test plan
make test— 24 pool tests (incl. a new end-to-endadd_liquidity/remove_liquidityround trip through a realNodusLpTokeninstance) + 20 LP token unit/integration tests, all passingmake lint— clippy clean across the workspace,cargo fmt --all --checkclean