Skip to content

Feat PancakeSwap V3 MasterChef NFT Staking#647

Open
VeXHarbinger wants to merge 9 commits into
hummingbot:developmentfrom
High-Falootin:feat-masterchef-nft-staking
Open

Feat PancakeSwap V3 MasterChef NFT Staking#647
VeXHarbinger wants to merge 9 commits into
hummingbot:developmentfrom
High-Falootin:feat-masterchef-nft-staking

Conversation

@VeXHarbinger

@VeXHarbinger VeXHarbinger commented May 28, 2026

Copy link
Copy Markdown

feat: PancakeSwap V3 MasterChef NFT Staking — 4 routes, ABI, unit tests

Dependency

Full integration testing requires gateway#646 to be merged first.
PR #646 provides the PancakeSwap CLMM foundation this branch builds on:
the pancakeswapV3MasterchefAddress contract addresses, the positions-owned
address-order fix, the executeSwap BigInt fix, and the quotePosition precision
fix. The MasterChef staking routes are non-functional without those pieces in place.


Related PRs

  • gateway#638 — original combined PR; fengtality requested a scope split; this branch is the MasterChef-only piece
  • gateway#646 — required parent branch (PancakeSwap V3 CLMM fixes + contract addresses)

Why This Exists — The Problem Being Solved

PancakeSwap V3 LP positions earn trading fees passively, but that is only half the
yield available on BSC. PancakeSwap's MasterChef V3 contract is the yield-farming
layer on top: by depositing a V3 NFT position into MasterChef, an LP also earns
CAKE token rewards for the entire duration the position stays staked.

Without this PR, a Hummingbot strategy using Gateway can open and manage V3 positions
but has no way to stake them — it leaves CAKE rewards on the table every block. This
PR adds the complete lifecycle:

  1. Check — verify the pool is registered in MasterChef before wasting gas
  2. Stake — deposit the NFT to start earning CAKE
  3. Unstake — withdraw the NFT and harvest all accumulated CAKE in one transaction
  4. Unstake-and-close — convenience route that harvests, withdraws, removes liquidity, collects fees, and burns the NFT atomically

How MasterChef V3 Staking Works

MasterChef V3 is an ERC721 receiver contract. The deposit mechanism is
safeTransferFrom — you do not call a deposit() function. You transfer the
NFT to the MasterChef contract, which triggers its onERC721Received callback and
registers the deposit. This is the correct Uniswap V3 fork staking pattern and is
why stakeNft() calls nftManager.safeTransferFrom(wallet, masterChefAddress, tokenId)
rather than any MasterChef-specific deposit method.

Withdrawal is the reverse: masterChef.withdraw(tokenId, walletAddress) transfers
the NFT back and harvests all accumulated CAKE in a single on-chain call, which is
why unstake is a single transaction (no separate harvest step needed).


Why Each Design Decision Was Made

1 — masterchef-knows-pool is a separate read-only endpoint

Not every V3 pool is registered in MasterChef. The MasterChef owner adds pools
explicitly; a valid NFT position in an unregistered pool will revert on stake with
no useful error. A Hummingbot strategy needs to check eligibility before paying
gas. This endpoint is read-only (no wallet, no gas) and intentionally separate from
masterchef-stake so strategies can query it independently and build decision logic
around it.

The pid-0 ambiguity: v3PoolAddressPid is a Solidity
mapping(address => uint256) that returns 0 for both unregistered addresses
and the legitimately registered pool at pid 0 (typically CAKE/WBNB on BSC).
A naive poolId === 0 → not registered check would permanently lock out the first
registered pool. The fix is to call poolInfo(0).v3Pool when the mapping returns 0
and match the address — only a mismatch means unregistered.

2 — httpErrors instead of reply.status(500).send

Hummingbot Python strategies parse Gateway error responses to decide whether to
retry (server error, transient) or abort and alert (bad request, caller must
fix something). Returning HTTP 500 for "MasterChef not approved" or "NFT not staked"
makes these indistinguishable from a crashed server. All four handlers now use
fastify.httpErrors.badRequest() for caller-fixable preconditions and
fastify.httpErrors.internalServerError() for unexpected failures, matching the
repo convention in CLAUDE.md and giving Hummingbot the signal it needs.

3 — ownerOf precondition check before withdraw

Calling masterChef.withdraw(tokenId, ...) when the NFT is not staked causes an
opaque ethers revert — the on-chain error propagates as an unstructured string that
tells the caller nothing actionable. The check costs one eth_call and eliminates
a class of confusing failures before they reach the chain.

4 — tx.wait(1) instead of setTimeout(2000) in unstake-and-close

BSC block time is approximately 3 seconds. A fixed 2-second sleep is shorter than
one block, meaning the NFT would frequently still be owned by MasterChef when
closePosition is called, causing the close to revert. tx.wait(1) blocks until
the unstake transaction is included in a confirmed block — the NFT is guaranteed to
be back in the wallet before close is attempted, regardless of network conditions.

5 — Address-order comparison for isBaseToken0

The previous code used a WETH special-case to determine which token is "base" — a
pattern imported from Ethereum mainnet code that breaks on BSC where the wrapped
native is WBNB, not WETH. Uniswap V3 forks define token0 canonically as the
lexicographically lower address. Using token0.address.toLowerCase() < token1.address.toLowerCase()
matches exactly how the pool contract itself determines token ordering and works
correctly on every EVM network without special-casing any token.

6 — TypeBox schemas in schemas.ts, not inline

Inline schema definitions in route files create inconsistency and make it harder
to audit the full API surface. Centralising all four schema pairs in
src/connectors/pancakeswap/schemas.ts is consistent with every other connector in
this repo and makes the TypeBox → Swagger generation path uniform.


New Endpoints

All routes are registered under /connectors/pancakeswap/nft-staking/.

Route Method Purpose
masterchef-knows-pool POST Check whether a V3 pool is registered in MasterChef V3; returns registered boolean and pid
masterchef-stake POST Transfer a V3 NFT position into MasterChef via safeTransferFrom to begin CAKE reward accrual
masterchef-unstake POST Call withdraw(tokenId, wallet) to return the NFT and harvest all accumulated CAKE in one transaction
masterchef-unstake-and-close POST Convenience: unstake (confirmed on-chain) then immediately close the V3 position, removing liquidity and collecting fees

Pre-flight Requirements for Staking

Before calling masterchef-stake the caller must:

  1. Verify the pool is registered — call masterchef-knows-pool first.
  2. Approve MasterChef for NFT transfers — call setApprovalForAll(masterchefAddress, true) on the NonfungiblePositionManager contract (0xEfF92A263d31888d860bD50809A8D171709b7b1c on BSC). This is a one-time approval per wallet.
  3. Confirm the position has non-zero liquidity — staking an empty position reverts.

Changed Files

New Files

File Purpose
src/connectors/pancakeswap/PancakeswapV3Masterchef.abi.json Complete 363-entry ABI including poolInfo, userPositionInfos, withdraw, v3PoolAddressPid, all events
src/connectors/pancakeswap/nft-staking/index.ts Route registration barrel
src/connectors/pancakeswap/nft-staking/masterchef-knows-pool.ts POST /masterchef-knows-pool handler
src/connectors/pancakeswap/nft-staking/masterchef-stake.ts POST /masterchef-stake handler
src/connectors/pancakeswap/nft-staking/masterchef-unstake.ts POST /masterchef-unstake handler
src/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.ts POST /masterchef-unstake-and-close handler
test/connectors/pancakeswap/nft-staking/masterchef-knows-pool.test.ts Unit tests — knows-pool
test/connectors/pancakeswap/nft-staking/masterchef-stake.test.ts Unit tests — stake
test/connectors/pancakeswap/nft-staking/masterchef-unstake.test.ts Unit tests — unstake
test/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close.test.ts Unit tests — unstake-and-close

Modified Files

File Change
src/connectors/pancakeswap/pancakeswap.ts Added masterChef Contract property; init() instantiates it; added getV3PoolIdFromMasterChef(), getPoolMasterchefData(), stakeNft(), unstakeNft() methods
src/connectors/pancakeswap/pancakeswap.routes.ts Added pancakeswapNftStakingRoutesWrapper and nftStaking key in exports
src/connectors/pancakeswap/schemas.ts Added all four request/response TypeBox schema pairs for MasterChef endpoints

Swagger / OpenAPI Documentation

All four endpoints are fully documented in the auto-generated Swagger UI at /docs.

POST /connectors/pancakeswap/nft-staking/masterchef-knows-pool

Summary: Check whether a V3 pool is registered in MasterChef

Field Type Default Description
network string bsc EVM network — bsc, mainnet, arbitrum, base
poolAddress string PancakeSwap V3 pool address to check
registered boolean Whether the pool is registered in MasterChef V3
pid number? MasterChef pool ID (only present when registered: true)

POST /connectors/pancakeswap/nft-staking/masterchef-stake

Summary: Stake a V3 NFT position into MasterChef for CAKE rewards

Field Type Default Description
network string bsc EVM network
walletAddress string Wallet that owns the NFT
tokenId string NFT position token ID to stake
signature string Transaction hash
status number 1 = success
fee string Gas fee paid in wei

POST /connectors/pancakeswap/nft-staking/masterchef-unstake

Summary: Unstake a V3 NFT position from MasterChef and harvest CAKE

Field Type Default Description
network string bsc EVM network
walletAddress string Wallet address to receive the NFT and rewards
tokenId string NFT position token ID to unstake
signature string Transaction hash
status number 1 = success
fee string Gas fee paid in wei

POST /connectors/pancakeswap/nft-staking/masterchef-unstake-and-close

Summary: Unstake a V3 NFT from MasterChef and close the position

Field Type Default Description
network string bsc EVM network
walletAddress string Wallet address
tokenId string NFT position token ID
unstakeSignature string Transaction hash of the unstake step
closeSignature string Transaction hash of the close step
status number Final status (1 = success)
fee string Gas fee paid for the unstake step in wei

Unit Tests

35 tests across 4 suites. All passing.

GATEWAY_TEST_MODE=dev npx jest --runInBand test/connectors/pancakeswap/nft-staking/
Suite Tests Coverage
masterchef-knows-pool.test.ts 8 Happy path (pid > 0); pid-0 registered; pid-0 unregistered; network default; missing poolAddress; empty body; network forwarded correctly
masterchef-stake.test.ts 9 Happy path; network default; missing tokenId; missing walletAddress; empty body; NFT not owned (400); MasterChef not approved (400); pool not registered (400); zero liquidity (400); RPC error (500)
masterchef-unstake.test.ts 9 Happy path; zero CAKE reward; network default; missing tokenId; missing walletAddress; empty body; NFT not staked (400); wallet not found (400); RPC error (500)
masterchef-unstake-and-close.test.ts 9 Happy path; call order enforced (unstake before close, no setTimeout); network default; missing tokenId; missing walletAddress; empty body; NFT not staked — close not called (400); unstake OK but close fails — error includes unstake tx hash (500); wallet not found (400)

Integration Testing

The following manual steps require gateway#646 merged and a running Gateway instance pointed at BSC.

Prerequisites

# 1. Start Gateway
pnpm start --passphrase=<PASSPHRASE> --dev

# 2. Add wallet
curl -X POST http://localhost:15888/wallet/add \
  -H "Content-Type: application/json" \
  -d '{"chain":"ethereum","network":"bsc","privateKey":"<YOUR_KEY>"}'

# 3. One-time: approve MasterChef for NFT transfers
#    Call setApprovalForAll(0x556B9306565093C855AEA9AE92A594704c2Cd59e, true)
#    on the NonfungiblePositionManager (0xEfF92A263d31888d860bD50809A8D171709b7b1c)

Test 1 — Check pool registration

curl -X POST http://localhost:15888/connectors/pancakeswap/nft-staking/masterchef-knows-pool \
  -H "Content-Type: application/json" \
  -d '{"network":"bsc","poolAddress":"<YOUR_POOL_ADDRESS>"}'
# Expected: {"registered":true,"pid":N}  or  {"registered":false}

Test 2 — Stake NFT

curl -X POST http://localhost:15888/connectors/pancakeswap/nft-staking/masterchef-stake \
  -H "Content-Type: application/json" \
  -d '{"network":"bsc","walletAddress":"<YOUR_WALLET>","tokenId":"<YOUR_NFT_ID>"}'
# Expected: {"signature":"0x...","status":1,"tokenId":"...","fee":"..."}

Test 3 — Unstake NFT

curl -X POST http://localhost:15888/connectors/pancakeswap/nft-staking/masterchef-unstake \
  -H "Content-Type: application/json" \
  -d '{"network":"bsc","walletAddress":"<YOUR_WALLET>","tokenId":"<YOUR_NFT_ID>"}'
# Expected: {"signature":"0x...","status":1,"tokenId":"...","fee":"..."}

Test 4 — Unstake and close in one call

curl -X POST http://localhost:15888/connectors/pancakeswap/nft-staking/masterchef-unstake-and-close \
  -H "Content-Type: application/json" \
  -d '{"network":"bsc","walletAddress":"<YOUR_WALLET>","tokenId":"<YOUR_NFT_ID>"}'
# Expected: {"unstakeSignature":"0x...","closeSignature":"0x...","status":1,...}

Expected Error Scenarios

NFT not staked:

{"statusCode":400,"error":"Bad Request","message":"NFT 1234 is not staked in MasterChef (current owner: 0xWallet...). You must stake the position first."}

Pool not registered:

{"statusCode":400,"error":"Bad Request","message":"Pool 0x... is not registered in MasterChef V3"}

MasterChef not approved:

{"statusCode":400,"error":"Bad Request","message":"Insufficient NFT approval. Please approve the position NFT (1234) for the Pancakeswap Position Manager (0x556B9306565093C855AEA9AE92A594704c2Cd59e)"}

Backwards Compatibility

All changes are additive:

  • New routes under a new /nft-staking/ sub-path — no existing routes modified.
  • pancakeswap.routes.ts adds nftStaking key alongside router, amm, clmm — no key renamed or removed.
  • pancakeswap.ts adds new public methods and a private masterChef property — no existing method signatures changed.
  • schemas.ts additions only — no existing schema types mutated.

Developer Notes — .github/copilot-instructions.md

.github/copilot-instructions.md was added in this branch and contains identical
content to CLAUDE.md. Both files carry the same agent directives, including an
explicit instruction to keep them in sync with each other.

Why it exists:
VS Code automatically reads .github/copilot-instructions.md for any workspace
where a GitHub Copilot subscription is active — including when Claude is accessed
via the GitHub Copilot API. This means contributors who work in VS Code get the
same agent context (lenses, coding style, architecture rules) that CLAUDE.md
provides to Claude directly, without needing to reference CLAUDE.md explicitly
in every prompt.

How sync is maintained:
Both files contain the directive:

Keep this file in sync with CLAUDE.md — changes to one must be reflected in the other.

Any agent (Copilot, Claude, Gemini) that edits project rules will read this
directive and update both files in the same commit.

What the lenses add:
The lenses are named, focused review constraints that any AI code-review agent
applies before proposing a solution. Each lens narrows the acceptable answer space
from a specific perspective:

  • Hummingbot lens — enforces backwards compatibility at every API boundary; prevents silent breakage of Python strategy parsing
  • Blockchain lens — keeps chain / network / chainNetwork semantics consistent; prevents wallet-scoping errors
  • System Architect lens — enforces REST conventions, TypeBox schemas, singleton patterns, and httpErrors usage
  • Bitcoin lens — ensures cryptographic primitives stay chain-agnostic and no secrets leak
  • Jest lens — mandates three test categories (happy paths, edge cases, missing/invalid params) and HTTP status code assertions on every route test
  • QA lens — validates backwards compatibility at every response boundary; covers legacy-file migration scenarios
  • Security lens — prevents private key / passphrase exposure; requires getSafeWalletFilePath() for all file I/O
  • Markdown lens — keeps all .md files render-clean (headings, lists, fenced blocks, no bare URLs)
  • Documentation lens — requires every public API change to update TypeBox description fields, add Swagger examples, and update CLAUDE.md / copilot-instructions.md
  • OpenAPI lens — enforces valid OpenAPI 3.0 output: operationId, summary, tags, response schemas matching handler return types
  • GitHub lens — enforces conventional commits, atomic PRs, and no force-pushes to protected branches

The lenses were designed so that an agent reviewing a PR sees the same checklist
a human reviewer would apply — reducing back-and-forth on style and convention
issues before a maintainer looks at the code.

- Add PancakeswapV3Masterchef.abi.json (complete, with poolInfo/userPositionInfos)
- Add Pancakeswap.getV3PoolIdFromMasterChef() with pid-0 fix (verifies via
  poolInfo(0).v3Pool so the CAKE/WBNB pool at pid 0 is not falsely rejected)
- Add Pancakeswap.unstakeNft() with precondition: checks NFT is currently owned by
  MasterChef before calling withdraw(), giving a friendly 400 instead of a revert
- Add Pancakeswap.stakeNft() with pure address-order isBaseToken0 (no WETH heuristic)
- Add 4 nft-staking route handlers (masterchef-knows-pool, masterchef-stake,
  masterchef-unstake, masterchef-unstake-and-close)
- All handlers use fastify.httpErrors.* — no reply.status(500).send()
- unstake-and-close uses await tx.wait(1) confirmation, not setTimeout(2000)
- Schemas live in pancakeswap/schemas.ts (not inline in route files)
- Register nftStaking wrapper in pancakeswap.routes.ts
- 35 unit tests across 4 suites: happy paths, edge cases, missing params

Fixes fengtality review comments (blocking + important) from PR hummingbot#638
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