Skip to content

Split LP token into a standalone SEP-41 contract - #104

Merged
0takuc0mrade merged 10 commits into
Nodus-protocol:mainfrom
Jaydbrown:feat/lp-token-contract
Jul 10, 2026
Merged

Split LP token into a standalone SEP-41 contract#104
0takuc0mrade merged 10 commits into
Nodus-protocol:mainfrom
Jaydbrown:feat/lp-token-contract

Conversation

@Jaydbrown

Copy link
Copy Markdown
Contributor

Summary

  • Moves LP token accounting out of the pool's internal storage into a standalone nodus-protocol-lp-token SEP-41-compatible contract, one instance per pool.
  • Pool wires to it via contractimport! against the compiled WASM (a regular crate dependency isn't possible here — both crates export initialize, which fails the link with a duplicate-symbol error), which introduces a build-order requirement: LP token WASM before pool. make build/test/lint all encode this ordering now; CI and the deploy/build scripts are updated to match.
  • initialize() on the pool gains fee_to_setter and lp_token parameters; the pool's direct lp_balance_of/transfer_lp/approve_lp/lp_allowance/transfer_lp_from methods are replaced by a single lp_token() accessor since callers now interact with the token contract directly.
  • Deploy script uploads/deploys both contracts and initializes the LP token (which needs to know its pool) before the pool.
  • README updated: new two-contract architecture diagram, LP Token Contract interface section, build/deploy instructions, and a note on planned factory/router contracts as follow-up work.

Test plan

  • make test — 24 pool tests (incl. a new end-to-end add_liquidity/remove_liquidity round trip through a real NodusLpToken instance) + 20 LP token unit/integration tests, all passing
  • make lint — clippy clean across the workspace, cargo fmt --all --check clean

Jaydbrown added 10 commits July 10, 2026 16:24
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.
@0takuc0mrade
0takuc0mrade merged commit 58af19b into Nodus-protocol:main Jul 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants