Sally protocol contracts
Non-custodial DeFi router + liquidity manager on Base (8453) and BNB Smart Chain (56). Release v2.1.0 (on-chain version() → 0x76322e312e3000). Everything a client needs is reachable through two proxy addresses; the rest — implementations, read helpers and the new token-vote governance stack (Governance) — are documented below. As of v2.1.0 the controllers are owned by a 48h SallyTimelock, not a dev key.
Swaps, best-route, token-info / honeypot probe, USD pricing, referral. Also answers all Lens views via fallback.
0x7777D0e2…74e87777LP add/increase/decrease/remove, locks, fee claims. Also answers all Sidecar read-views via fallback.
0x7777d7fF…bEbe7777Both proxies share the same address on Base and BSC (CREATE2 vanity 0x7777…7777). They are UUPS-upgradeable (ERC-1967) and never change across releases.
Two proxies, fallback-routed helpers
The frontend only ever calls the two 0x7777 proxies. Read aggregators (Lens) and the liquidity read-views (Sidecar) are reachable BOTH at their own address AND through the matching proxy via a read-only fallback — so you can use one address per family with the combined ABI.
Call the Swap proxy with the SwapController ABI + Lens ABI. Lens views (getBestSwapPath, getWalletBalance, …) resolve through the proxy's fallback — no need to address the Lens directly.
Call the Liquidity proxy with the LiquidityController ABI + Sidecar ABI. Sidecar views (getV4Positions, getClaimableFees, getLocks, …) resolve through the proxy's fallback.
SallyTimelock, driven by vSALLY votes in the SallyGovernor. Every privileged action is a 48h-delayed, dev-vetoable timelock op. See Governance.proposeUpgrade(impl) → after a built-in 48h UPGRADE_DELAY, upgradeToAndCall (onlyOwner = the Timelock). Independent of token-vote timing. Inspect a queued upgrade via pendingImpl() / pendingImplReadyAt().hookData routing + an exact V4 quoter. On BSC there is no V4 — utilsV4 is unset and the V4 readers return empty.v2.1.0 addresses
Pinned to the canonical v2.1.0 deployment manifest. The two proxies are immutable (same as every prior release); the implementations, Lens, Scanner and governance stack are the v2.1.0 set. Click an address to open the explorer; copy with the button.
Entry point for swaps + swap-side reads (ERC-1967, unchanged across releases).
Entry point for LP, locks, claims + liquidity-side reads (ERC-1967, unchanged).
v2.1.0 logic behind the Swap proxy. Verified. Same address both chains.
v2.1.0 logic behind the Liquidity proxy. Verified. Now the same address on both chains.
Read aggregator (paths, balances, pricing). Callable via the Swap proxy.
Honeypot / transfer-tax scanner sidecar backing the safety probe. Same address both chains.
Liquidity read-views (positions, pool state, claimable, locks). Via the Liq proxy.
Fee sink + multi-owner ops vault. Verified. Same address both chains.
V2/V3 routing libraries (utilsV2 shown). utilsV3 differs per chain.
V4 routing library — v2.1.0 adds hookData + an exact quoter. Redeployed at activation; unset on BSC (no V4).
Uniswap V4 StateView singleton — sidecar stateView() points here.
Wraps SALLY 1:1 into checkpointed ERC20Votes power.
OZ Governor over vSALLY. Proposes + queues to the Timelock.
Owns the Swap + Liquidity proxies; executes passed proposals after 48h.
Base: live Virtuals SALLY. BSC: native SallyToken (1M, mint/burn).
0x7777D0e2…87777, Liquidity 0x7777d7fF…e7777. If an address claiming to be Sally doesn't start and end in 7777, it isn't the proxy.Three prices — pick the right one
Sally separates the display price from the execution price. All USD figures are canonically scaled to 1e18 on BOTH chains (no per-chain stable-decimals).
getUsdPrice(token) → (uint256 priceUsd18, address stableUsed)viewSpot-mid USD price — the mid of the pool, for display. This is what the UI shows as "the price". stableUsed tells you which stable the route settled against.
getUsdPriceImpact(token) → (uint256 priceUsd18, address stableUsed)v2.1.0Execution-aware USD price — the realizable price after slippage/impact for a reference size. Use it for quotes, slippage and "what you'd actually get". By construction getUsdPrice ≥ getUsdPriceImpact on a sell.
getSpotPrice(tokenIn, tokenOut) → (uint256 price18)v2.1.0Pairwise spot-mid rate — how many tokenOut one whole tokenIn is worth at the current mid, scaled 1e18. Both tokens explicit (no USD assumption).
getWethPrice(token) → (uint256, address)viewNative-denominated price (vs WETH/WBNB) when a USD stable route isn't the natural denominator.
getUsdLiquidity(token) · getUsdLiquidityV2/V3/V4(token) → uint256viewTotal USD liquidity backing a token, aggregate or per AMM version. 0 means no pool on that version.
getUsdPrice returned the execution-aware price. In v2.0.2 it returns spot-mid and the old behaviour moved to getUsdPriceImpact. The return shape is unchanged, so decoders keep working — but the meaning shifted. Show getUsdPrice; quote with getUsdPriceImpact.SwapController
84 fns · 19 eventsBehind the Swap proxy. The DexCross router — cross-DEX, multi-version routing over V2 · V3 · V4 · CL (Slipstream) · Stable pools — with best-route discovery, USD pricing, a honeypot/tax probe, and referral accounting.
executeHybridSwap(swapData, amountIn, minOutput, deadline, referral) → uint256 amountOutpayableThe swap entry point. Consumes a swapData route built from getBestSwapPath and can chain up to 3 hops across AMM versions. The 5th arg is the referral (fee-share) address — NOT the recipient; output returns to msg.sender. Send native value for native-in swaps.
getBestSwapPath(tokenIn, tokenOut, amountIn) → HybridSwapData bestviewDiscovers the best execution route. Returns up to three candidate hops (firstStep/secondStep/thirdStep) plus stepCount (see struct below) and the estimated output.
getSwapPathV2/V3/V4(tokenIn, tokenOut, amountIn) → PathviewVersion-pinned path discovery when you want to force a specific AMM family.
getTokenInfos(token) → TokenInfospayableHoneypot / transfer-tax probe. Simulates a buy and sell and reports estimate-vs-actual amounts plus buySuccess / approveSuccess / sellSuccess. State-mutating by design (it actually executes the probe in a revert-bounded simulation), so call it with eth_call / callStatic.
getWalletBalance(owner, tokens[]) → Balance[]viewBatch balances enriched with usdPrice18, usdValue, decimals and an ok flag. getWalletBalanceRaw is the price-free variant.
getUsdPrice · getUsdPriceImpact · getSpotPriceviewSee Pricing semantics. Also exposed on the Lens with identical signatures.
claimReferralFees(tokens[]) · claimBatchFees(tokens[])writePull accrued referral / batched fees for the supported tokens. Pair with the referral views below.
getReferralFeeTokens(user, start, stop) → (address[], uint256[], uint256 maxIndex)viewPaginated referral-owed enumeration; getReferralFeeTokenCount / referralFeeTokenAt / totalReferralOwed round it out.
feeBasisPoints() · feeThreshold() · getSwapFee(amount) · BPS_DENOMINATOR()viewFee model. getSwapFee returns the (fee, net) split for an amount. Owner-only setters: setFeeBasisPoints / setFeeThreshold (capped by MAX_FEE_BPS).
lens() · treasury() · wnative() · utilsV2/V3/V4() · poolManagerV4()viewWiring getters — resolve the helper addresses the proxy is bound to. Useful to verify a deployment from on-chain truth.
struct SwapPathData {
uint8 version; // V2=0 · V3=1 · V4=2
address[] path; // token hops
uint256[] dexPath; // per hop: dexId, or for
// V4 = (fee<<24)|tickSpacing
address[] pools; // resolved pools (0x0 for V4)
address baseToken; // intermediary
uint256 estimatedAmountOut;
}
struct HybridSwapData {
SwapPathData firstStep;
SwapPathData secondStep;
SwapPathData thirdStep;
uint8 stepCount; // how many are populated
}struct TokenInfos {
uint256 buyEstimate;
uint256 buyResult; // actual received on buy
uint256 sellEstimate;
uint256 sellResult; // actual received on sell
bool buySuccess;
bool approveSuccess;
bool sellSuccess; // false ⇒ honeypot signal
}Events: SwapExecuted, FeeCollected, FeeBasisPointsUpdated, PerHopSlippageUpdated, UsdStablesUpdated, UtilsUpdated, UtilsV4Updated, LensSet, WnativeSet, TreasuryUpdated, UpgradeProposed, Upgraded, OwnershipTransferred, GuardianUpdated, Paused, Unpaused, FlushFailed, FeeThresholdUpdated, Initialized.
Lens
23 fns · 0 events · unverifiedPure read aggregator for the swap side. Callable directly OR via the Swap proxy fallback (use the proxy address with the Lens ABI). No state, no events.
getBestSwapPath · getBestSwapPathDeep(tokenIn, tokenOut, amountIn)viewBest-route discovery; the Deep variant searches more intermediaries (higher gas, better routes for thin pairs).
getSwapPath · getSwapPathV2/V3/V4 · _probeSwapPathviewAggregate and per-version path probes mirroring the SwapController readers.
getTokenInfos(token) · getTokenInfosWithPaths(token, buyPath, sellPath)payableHoneypot probe; the WithPaths variant lets you pin the buy/sell route used for the simulation.
getUsdPrice · getUsdPriceImpact · getSpotPrice · getWethPriceviewSame pricing trio as the SwapController, identical signatures and semantics.
getUsdLiquidity · getUsdLiquidityV2/V3/V4(token)viewUSD liquidity, aggregate and per version.
getWalletBalance · getWalletBalanceRaw(owner, tokens[])viewBatch balances (priced / raw). Capped by MAX_WALLET_BALANCE_TOKENS / …_RAW_TOKENS per call.
LiquidityController
90 fns · 31 eventsBehind the Liquidity proxy. Full LP lifecycle across Uniswap V2 / V3 / V4 and Aerodrome Slipstream, plus time-locks, fee claiming and a unified mass-claim.
addLiquidityV2/V3/V4(params) · addLiquiditySlipstream(params) → (…, bytes32 key)payableMint/seed a position on the chosen AMM. Returns the new tokenId/LP plus a lock key when locked in the same call.
increaseLiquidityV3/V4(...) · decreaseLiquidityV3/V4(...)payableAdjust an existing NFT position. Decrease is non-payable; increase is payable for native-side adds.
removeLiquidityV2(...) → (amount0, amount1)writeBurn V2 LP and withdraw the underlying.
lockV2LP · lockV3Position · lockV4Position(asset, id, unlockTime) → bytes32 keywriteTime-lock LP or an NFT position. Bounded by MIN_LOCK_DAYS and MAX_LOCKS_PER_OWNER.
unlockV2(key) · unlockV3(npm, id) · unlockV4(pm, id)writeRelease a matured lock — emits PositionUnlocked(key, owner).
getLockKeys(owner) · locks(key) · totalLockedLp(owner) · v2LockNonce(owner)viewLock bookkeeping. Full lock structs come from the Sidecar's getLocks.
claimFeesV3/V4(npm, tokenId, recipient) · claimFeesV3Locked/V4Locked(...)writeCollect accrued LP fees for an unlocked or locked position.
massClaimFees(npms[], tokenIds[], recipient)v2.1.0Unified one-tx claim across many V3/V4 positions. Bounded by MAX_FEE_TOKENS_PER_CLAIM.
claimBatchFees(tokens[]) · claimReferralFees(tokens[])writeBatched protocol-fee and referral-fee pulls.
rebindLiqUtils(addr) · setLiqUtils(addr) · setPriceOracle · setTreasury · setWethv2.1.0Owner-only rewiring of the helpers the proxy delegates to. rebindLiqUtils swaps the Sidecar/utils binding atomically.
addV2Dex/V3Dex/V4Dex(...) · setV2/V3/V4DexEnabled(dexId, bool)writeDEX registry management; read it back with v2DexCount/v2Dexes(i) (and V3/V4).
pause() · unpause() · rescueERC20/ERC721/ETH(...)writeCircuit breaker and stuck-asset rescue (owner-only). Rescues emit Rescued*.
Key events: LiquidityAddedV2/V3/V4, LiquidityIncreased/DecreasedV3/V4, LiquidityRemovedV2, FeesClaimed, FeesClaimedV4, PositionUnlocked, V2/V3/V4DexAdded, V2/V3/V4DexEnabled, FeeBpsUpdated, FeeThresholdUpdated, GuardianUpdated, Paused, Unpaused, RescuedERC20/ERC721/ETH, OracleUpdated.
Liquidity Sidecar
15 fns · 0 events · unverifiedRead-views for the liquidity side. Call the Liquidity proxy with this ABI (fallback), or the Sidecar directly. Holds the V4 StateView pointer.
getV2Positions(owner, pairs[]) → V2Pos[]v2.1.0LP balances + reserves + per-position share for the given V2 pairs.
getV3Positions(owner, start, end) → V3Pos[]v2.1.0Enumerated V3 NFT positions (range, liquidity, owed fees, lock state). Paginated by index.
getV4Positions(owner, start, end) → V4Pos[]v2.1.0Enumerated V4 positions — the authoritative replacement for log-scan reconstruction (see struct).
getV2PoolState · getV3PoolState(pool) · getV4PoolState(poolKey) → Statev2.1.0Live pool snapshot. V4 takes a PoolKey tuple and returns sqrtPriceX96 / tick / liquidity / fees.
getClaimableFees(npm, tokenId) → (token0, amount0, token1, amount1)v2.1.0Authoritative unclaimed-fee read for a V3/V4 position — what a claim would actually collect right now.
getLocks(owner) → Lock[]v2.1.0Full lock structs (kind, asset, tokenId/amount, lock/unlock time, pair tokens, nonce).
usdValue(token, amount) → uint256v2.1.0USD value (1e18) of an arbitrary token amount via the protocol oracle.
getReferralFeeTokens(user, start, stop) → (address[], uint256[], uint256)v2.1.0Paginated referral-owed enumeration on the liquidity side.
stateView() · controller() · MAX_PAGE_SIZE() · MAX_FEE_TOKENS_PER_QUERY()viewWiring + pagination limits. stateView() returns the Uniswap V4 StateView singleton (Base). Owner-only setStateView is the lone non-view here.
struct V4Pos {
address positionManager;
uint256 tokenId;
bytes32 poolId;
address currency0; address currency1;
uint24 fee; int24 tickSpacing; address hooks;
int24 tickLower; int24 tickUpper;
uint128 liquidity;
bool locked; uint256 unlockTime;
}struct Lock {
uint8 kind; // 0=V2 1=V3 2=V4
address asset; // LP token / NFT manager
uint256 tokenIdOrAmount;
uint256 lockTime; uint256 unlockTime;
address owner;
address tokenA; address tokenB;
uint256 nonce;
}Treasury
15 fns · 8 eventsThe fee sink and multi-owner operations vault. Same address on both chains. Verified on the explorer.
getOwners() · isOwner(addr) · ownerCount() · addOwner / removeOwner(addr)viewMulti-owner access control. add/removeOwner are owner-gated and emit OwnerAdded/OwnerRemoved.
swap(...) · swapAuto(tokenIn, tokenOut, amountIn, minOut, deadline) → amountOutpayableTreasury-side swaps (e.g. consolidating collected fees) routed through the SwapController. Emits TreasurySwap.
withdraw · withdrawAll · batchWithdrawAll(tokens[], to)writeMove collected assets out to a destination. Emits Withdrawn.
execute(target, value, data) → byteswriteOwner-gated arbitrary call for operational flexibility. Emits Executed with the return data.
swapController() · setSwapController(addr) · label() · receive()viewWiring + an on-chain label. The contract accepts native value (Received).
Token-vote owned, 48h timelocked
v2.1.0 hands the Swap + Liquidity controllers to on-chain token-vote governance. The 48h SallyTimelock owns them; the SallyGovernor only proposes to it. Each chain runs its own independent stack.
Wrap SALLY 1:1 into vSALLY (ERC20Wrapper + ERC20Votes), delegate, then vote in the SallyGovernor. The Governor only proposes; the Timelock is the on-chain owner. Timelock is self-administered (DEFAULT_ADMIN = Timelock); devs hold a 1-of-3 PROPOSER+EXECUTOR+CANCELLER guard (veto), no dev holds DEFAULT_ADMIN.
SallyToken (native, 1M) is the only on-chain governance token; it can be swapped for a different underlying later via a Governor vote, leaving the Timelock and controllers untouched. Addresses are in the Governance stack cluster above.Things that bite if you skip them
getUsdPrice, usdValue, getUsdLiquidity) is canonically scaled to 1e18 on both chains — no per-chain stable decimals. formatUnits(x, 18) for display; do exact math in bigint and format only at the edge.payable/state-mutating because it actually runs a bounded buy+sell. Always invoke it read-only (eth_call / callStatic) — never as a real transaction.getV4Positions, getV4PoolState) and utilsV4are Base-only. On BSC they return empty / unset — branch on chainId, don't treat empty as an error.lens(), treasury(), owner(), sidecar stateView()) on the proxy and compare. owner() must be the SallyTimelock, and version() must be 0x76322e312e3000.getV3/V4Positions, getReferralFeeTokens) take start/stop and are bounded by MAX_PAGE_SIZE / MAX_FEE_TOKENS_PER_QUERY — page through large wallets.Read from chain — cast & viem
Call the proxy with the right ABI family. Lens views go to the Swap proxy; Sidecar views go to the Liquidity proxy; governance reads hit the Governor / Timelock directly.
# version() on the swap proxy (both chains) cast call 0x7777D0e2e2d772b5D750540B3932261574e87777 \ "version()(bytes7)" --rpc-url $BASE_RPC # → 0x76322e312e3000 # spot-mid vs execution price (WETH on Base) cast call $SWAP "getUsdPrice(address)(uint256,address)" \ 0x4200000000000000000000000000000000000006 --rpc-url $BASE_RPC cast call $SWAP "getUsdPriceImpact(address)(uint256,address)" \ 0x4200000000000000000000000000000000000006 --rpc-url $BASE_RPC # sidecar view via the LIQUIDITY proxy (fallback) cast call 0x7777d7fFF155B043CE0A0785dd8c8c42bEbe7777 \ "getLocks(address)" $WALLET --rpc-url $BASE_RPC # ── governance (Base) ────────────────────────────────── GOV=0x777771f98153d85799360d65dde84e5cd3d17777 # Governor TL=0x777793a53e3d6069686d3e2f87f4df0a4b027777 # Timelock 48h # confirm the Timelock owns the swap proxy cast call $SWAP "owner()(address)" --rpc-url $BASE_RPC # → $TL cast call $TL "getMinDelay()(uint256)" --rpc-url $BASE_RPC # → 172800 # state(id) → 0 Pending 1 Active 2 Canceled 3 Defeated # 4 Succeeded 5 Queued 6 Expired 7 Executed cast call $GOV "state(uint256)(uint8)" $ID --rpc-url $BASE_RPC cast call $GOV "proposalVotes(uint256)(uint256,uint256,uint256)" \ $ID --rpc-url $BASE_RPC # → (against, for, abstain)
import { createPublicClient, http } from 'viem'
import { base } from 'viem/chains'
const c = createPublicClient({ chain: base, transport: http() })
const SWAP = '0x7777D0e2e2d772b5D750540B3932261574e87777'
const LIQ = '0x7777d7fFF155B043CE0A0785dd8c8c42bEbe7777'
// Lens view via the swap proxy (proxy addr + Lens ABI)
const [price] = await c.readContract({
address: SWAP, abi: LENS_ABI,
functionName: 'getUsdPrice', args: [weth],
})
// Sidecar view via the liquidity proxy (proxy addr + Sidecar ABI)
const v4 = await c.readContract({
address: LIQ, abi: SIDECAR_ABI,
functionName: 'getV4Positions', args: [wallet, 0n, 50n],
})
// Governance: proposal state + tally (Base Governor)
const GOV = '0x777771f98153d85799360d65dde84e5cd3d17777'
const state = await c.readContract({
address: GOV, abi: GOVERNOR_ABI,
functionName: 'state', args: [proposalId],
}) // 0 Pending · 1 Active · … · 7 Executed
const [against, forVotes, abstain] = await c.readContract({
address: GOV, abi: GOVERNOR_ABI,
functionName: 'proposalVotes', args: [proposalId],
})Language SDKs
Prefer a typed client over raw eth_calls? The SDKs wrap this whole contract surface — best-route swaps, liquidity, pricing, honeypot screening, referrals — behind one safe-by-default object.
pip install sally-defi-py-sdk. One typed SallyClient — simulate-before-execute with a post-trade balance assertion, async, permit, MEV protection. Python ≥3.12, MIT.
npm install sally-defi-ts-sdk. The same safe-by-default SallyClientfor Node & the browser — simulate-before-execute with a post-trade balance assertion, async, permit, MEV protection. Built on ethers v6, MIT.
Embed the swap widget
Drop the non-custodial DexCross swap into any site with one iframe — built-in token select, live quotes, wallet connect, and your referral on every swap.
One <iframe> with your wallet as ?ref= earns you 50% of the protocol fee on every swap it routes. Full snippet, URL params, wallet notes and a live demo:
These addresses and ABIs are generated from the canonical v2.1.0 deployment manifest — the same source the app builds against, so they never drift. See the security overview, the FAQ, or the source on GitHub ↗.