Sally
SALLY
Contracts · v2.1.0

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.

Swap proxy

Swaps, best-route, token-info / honeypot probe, USD pricing, referral. Also answers all Lens views via fallback.

0x7777D0e2…74e87777
Liquidity proxy

LP add/increase/decrease/remove, locks, fee claims. Also answers all Sidecar read-views via fallback.

0x7777d7fF…bEbe7777

Both proxies share the same address on Base and BSC (CREATE2 vanity 0x7777…7777). They are UUPS-upgradeable (ERC-1967) and never change across releases.

Architecture

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.

Swap-side reads

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.

Liquidity-side reads

Call the Liquidity proxy with the LiquidityController ABI + Sidecar ABI. Sidecar views (getV4Positions, getClaimableFees, getLocks, …) resolve through the proxy's fallback.

Token-vote governed. As of v2.1.0 the controllers are owned by a 48h SallyTimelock, driven by vSALLY votes in the SallyGovernor. Every privileged action is a 48h-delayed, dev-vetoable timelock op. See Governance.
UUPS upgrades. ERC-1967 UUPS, two-step: 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().
Verification policy. Swap-impl, Liq-impl, the Treasury and the full governance stack (vSALLY / Governor / Timelock / SallyToken) are source-verified on the explorer (with the Sally header). The Lens, Scanner, Sidecar and Utils helpers are intentionally NOT verified — treat their addresses as opaque read endpoints.
V4 is Base-only. Uniswap V4 (PoolManager / PositionManager / StateView) is live on Base, and v2.1.0 adds hookData routing + an exact V4 quoter. On BSC there is no V4 — utilsV4 is unset and the V4 readers return empty.
Deployment

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.

Swap proxy

Entry point for swaps + swap-side reads (ERC-1967, unchanged across releases).

Liquidity proxy

Entry point for LP, locks, claims + liquidity-side reads (ERC-1967, unchanged).

Swap implementation

v2.1.0 logic behind the Swap proxy. Verified. Same address both chains.

Liquidity implementation

v2.1.0 logic behind the Liquidity proxy. Verified. Now the same address on both chains.

Lensunverified by policy

Read aggregator (paths, balances, pricing). Callable via the Swap proxy.

Token Scannerunverified by policy

Honeypot / transfer-tax scanner sidecar backing the safety probe. Same address both chains.

Liquidity Sidecarunverified by policy

Liquidity read-views (positions, pool state, claimable, locks). Via the Liq proxy.

Treasury

Fee sink + multi-owner ops vault. Verified. Same address both chains.

utilsV2 / utilsV3unverified by policy

V2/V3 routing libraries (utilsV2 shown). utilsV3 differs per chain.

utilsV4 (Base only)unverified by policy

V4 routing library — v2.1.0 adds hookData + an exact quoter. Redeployed at activation; unset on BSC (no V4).

Base0x1e757245…a53d1f36unverifiedBSC— not deployed
V4 StateView (Base)

Uniswap V4 StateView singleton — sidecar stateView() points here.

Base0xA3c0c9b6…4b867A71BSC— not deployed
Governance stack · per-chain, token-vote ownership
vSALLY (vote token)

Wraps SALLY 1:1 into checkpointed ERC20Votes power.

SallyGovernor

OZ Governor over vSALLY. Proposes + queues to the Timelock.

SallyTimelock (48h)

Owns the Swap + Liquidity proxies; executes passed proposals after 48h.

Underlying SALLY

Base: live Virtuals SALLY. BSC: native SallyToken (1M, mint/burn).

The two proxies use a CREATE2 vanity prefix: Swap 0x7777D0e2…87777, Liquidity 0x7777d7fF…e7777. If an address claiming to be Sally doesn't start and end in 7777, it isn't the proxy.
Pricing semantics

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)view

Spot-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.0

Execution-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.0

Pairwise 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)view

Native-denominated price (vs WETH/WBNB) when a USD stable route isn't the natural denominator.

getUsdLiquidity(token) · getUsdLiquidityV2/V3/V4(token) → uint256view

Total USD liquidity backing a token, aggregate or per AMM version. 0 means no pool on that version.

Migration note. Pre-v2.0.2, 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 events

Behind 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 amountOutpayable

The 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 bestview

Discovers 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) → Pathview

Version-pinned path discovery when you want to force a specific AMM family.

getTokenInfos(token) → TokenInfospayable

Honeypot / 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[]view

Batch balances enriched with usdPrice18, usdValue, decimals and an ok flag. getWalletBalanceRaw is the price-free variant.

getUsdPrice · getUsdPriceImpact · getSpotPriceview

See Pricing semantics. Also exposed on the Lens with identical signatures.

claimReferralFees(tokens[]) · claimBatchFees(tokens[])write

Pull accrued referral / batched fees for the supported tokens. Pair with the referral views below.

getReferralFeeTokens(user, start, stop) → (address[], uint256[], uint256 maxIndex)view

Paginated referral-owed enumeration; getReferralFeeTokenCount / referralFeeTokenAt / totalReferralOwed round it out.

feeBasisPoints() · feeThreshold() · getSwapFee(amount) · BPS_DENOMINATOR()view

Fee 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()view

Wiring getters — resolve the helper addresses the proxy is bound to. Useful to verify a deployment from on-chain truth.

HybridSwapData — getBestSwapPath output
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
}
TokenInfos — getTokenInfos output
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 · unverified

Pure 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)view

Best-route discovery; the Deep variant searches more intermediaries (higher gas, better routes for thin pairs).

getSwapPath · getSwapPathV2/V3/V4 · _probeSwapPathview

Aggregate and per-version path probes mirroring the SwapController readers.

getTokenInfos(token) · getTokenInfosWithPaths(token, buyPath, sellPath)payable

Honeypot probe; the WithPaths variant lets you pin the buy/sell route used for the simulation.

getUsdPrice · getUsdPriceImpact · getSpotPrice · getWethPriceview

Same pricing trio as the SwapController, identical signatures and semantics.

getUsdLiquidity · getUsdLiquidityV2/V3/V4(token)view

USD liquidity, aggregate and per version.

getWalletBalance · getWalletBalanceRaw(owner, tokens[])view

Batch balances (priced / raw). Capped by MAX_WALLET_BALANCE_TOKENS / …_RAW_TOKENS per call.

LiquidityController

90 fns · 31 events

Behind the Liquidity proxy. Full LP lifecycle across Uniswap V2 / V3 / V4 and Aerodrome Slipstream, plus time-locks, fee claiming and a unified mass-claim.

Add / manage liquidity
addLiquidityV2/V3/V4(params) · addLiquiditySlipstream(params) → (…, bytes32 key)payable

Mint/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(...)payable

Adjust an existing NFT position. Decrease is non-payable; increase is payable for native-side adds.

removeLiquidityV2(...) → (amount0, amount1)write

Burn V2 LP and withdraw the underlying.

Locks
lockV2LP · lockV3Position · lockV4Position(asset, id, unlockTime) → bytes32 keywrite

Time-lock LP or an NFT position. Bounded by MIN_LOCK_DAYS and MAX_LOCKS_PER_OWNER.

unlockV2(key) · unlockV3(npm, id) · unlockV4(pm, id)write

Release a matured lock — emits PositionUnlocked(key, owner).

getLockKeys(owner) · locks(key) · totalLockedLp(owner) · v2LockNonce(owner)view

Lock bookkeeping. Full lock structs come from the Sidecar's getLocks.

Fee claims
claimFeesV3/V4(npm, tokenId, recipient) · claimFeesV3Locked/V4Locked(...)write

Collect accrued LP fees for an unlocked or locked position.

massClaimFees(npms[], tokenIds[], recipient)v2.1.0

Unified one-tx claim across many V3/V4 positions. Bounded by MAX_FEE_TOKENS_PER_CLAIM.

claimBatchFees(tokens[]) · claimReferralFees(tokens[])write

Batched protocol-fee and referral-fee pulls.

Admin / wiring
rebindLiqUtils(addr) · setLiqUtils(addr) · setPriceOracle · setTreasury · setWethv2.1.0

Owner-only rewiring of the helpers the proxy delegates to. rebindLiqUtils swaps the Sidecar/utils binding atomically.

addV2Dex/V3Dex/V4Dex(...) · setV2/V3/V4DexEnabled(dexId, bool)write

DEX registry management; read it back with v2DexCount/v2Dexes(i) (and V3/V4).

pause() · unpause() · rescueERC20/ERC721/ETH(...)write

Circuit 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 · unverified

Read-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.0

LP balances + reserves + per-position share for the given V2 pairs.

getV3Positions(owner, start, end) → V3Pos[]v2.1.0

Enumerated V3 NFT positions (range, liquidity, owed fees, lock state). Paginated by index.

getV4Positions(owner, start, end) → V4Pos[]v2.1.0

Enumerated V4 positions — the authoritative replacement for log-scan reconstruction (see struct).

getV2PoolState · getV3PoolState(pool) · getV4PoolState(poolKey) → Statev2.1.0

Live pool snapshot. V4 takes a PoolKey tuple and returns sqrtPriceX96 / tick / liquidity / fees.

getClaimableFees(npm, tokenId) → (token0, amount0, token1, amount1)v2.1.0

Authoritative unclaimed-fee read for a V3/V4 position — what a claim would actually collect right now.

getLocks(owner) → Lock[]v2.1.0

Full lock structs (kind, asset, tokenId/amount, lock/unlock time, pair tokens, nonce).

usdValue(token, amount) → uint256v2.1.0

USD value (1e18) of an arbitrary token amount via the protocol oracle.

getReferralFeeTokens(user, start, stop) → (address[], uint256[], uint256)v2.1.0

Paginated referral-owed enumeration on the liquidity side.

stateView() · controller() · MAX_PAGE_SIZE() · MAX_FEE_TOKENS_PER_QUERY()view

Wiring + pagination limits. stateView() returns the Uniswap V4 StateView singleton (Base). Owner-only setStateView is the lone non-view here.

V4Pos — getV4Positions output (per item)
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;
}
Lock — getLocks output (per item)
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 events

The fee sink and multi-owner operations vault. Same address on both chains. Verified on the explorer.

getOwners() · isOwner(addr) · ownerCount() · addOwner / removeOwner(addr)view

Multi-owner access control. add/removeOwner are owner-gated and emit OwnerAdded/OwnerRemoved.

swap(...) · swapAuto(tokenIn, tokenOut, amountIn, minOut, deadline) → amountOutpayable

Treasury-side swaps (e.g. consolidating collected fees) routed through the SwapController. Emits TreasurySwap.

withdraw · withdrawAll · batchWithdrawAll(tokens[], to)write

Move collected assets out to a destination. Emits Withdrawn.

execute(target, value, data) → byteswrite

Owner-gated arbitrary call for operational flexibility. Emits Executed with the return data.

swapController() · setSwapController(addr) · label() · receive()view

Wiring + an on-chain label. The contract accepts native value (Received).

Governance

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.

Live dashboard →
Voting delay
6h
Voting period
3d
Quorum
2%
Proposal threshold
0 vSALLY
Timelock delay
2d
How a change reaches the protocol
SALLYvSALLYdelegatevotequeue · 48hexecute

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.

Per-chain, independent stacks. Base and BSC each run their own vSALLY / Governor / Timelock — BSC controllers are never owned by the Base Timelock. The BSC 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.
Conventions & gotchas

Things that bite if you skip them

USD is always 1e18. Every USD figure (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.
View tuples decode by name. Position / lock / pool-state readers return named-field tuples (see struct boxes). Field order is stable across V2.0.x — decode by the structs above, never by positional guesswork.
getTokenInfos mutates. The honeypot probe is payable/state-mutating because it actually runs a bounded buy+sell. Always invoke it read-only (eth_call / callStatic) — never as a real transaction.
V4 only on Base. V4 readers (getV4Positions, getV4PoolState) and utilsV4are Base-only. On BSC they return empty / unset — branch on chainId, don't treat empty as an error.
Verify from chain, not docs. To confirm a deployment, read the wiring getters (lens(), treasury(), owner(), sidecar stateView()) on the proxy and compare. owner() must be the SallyTimelock, and version() must be 0x76322e312e3000.
Pagination limits. Enumerated readers (getV3/V4Positions, getReferralFeeTokens) take start/stop and are bounded by MAX_PAGE_SIZE / MAX_FEE_TOKENS_PER_QUERY — page through large wallets.
Calling the contracts

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.

cast · foundry
# 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)
viem · typescript
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],
})
Build with code

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.

Integration

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:

Embed widget docs →

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 ↗.

Command palette

Jump to a page or run an action