Audit any liquidity lock
Every Sally liquidity lock is public and verifiable. The lock — not the wallet — is the subject: ask who locked what, where, when, and how long— plus the live USD value, the pool's TVL, and this lock's share of the pool. Resolve it from a wallet or from a single lock id. All values are read live from the chain — no indexer, no subgraph, no backend in the data path.
Five questions, one payload
Each lock answers the same five questions, regardless of whether it's a V2 LP token lock or a V3/V4 concentrated-liquidity NFT lock.
owner, recorded with the lock on-chain. The custodian of the NFT is the Sally controller; the locker is who can unlock.pair.label, kind (V2/V3/V4) and, for NFTs, the tokenId.chainId (56 BSC · 8453 Base) and the resolved pool.address, with explorer + DexScreener links.lockedAt → unlockTime (unix seconds) and a live secondsRemaining countdown.GET /api/locks
Public, CORS-open, edge-cached ~45s. Provide either a wallet or a lock key. Returns live on-chain values.
wallet=0x… (address)readList every lock owned by a wallet. One of wallet or key is required.
key=0x… (bytes32)readResolve a SINGLE lock by its on-chain lock id — no owner address needed. This is the lock-centric lookup.
chain=56 | 8453 | allreadRestrict to one chain, or scan both (all, the default).
format=full | compact | widgetreadfull (default) groups locks per chain; compact is a flat array; widget is a one-line summary.
curl "https://sally.tools/api/locks?wallet=0xYourWallet&chain=all"
curl "https://sally.tools/api/locks?key=0xLOCK_ID_BYTES32&chain=8453"
const locks = await fetch(
"https://sally.tools/api/locks?wallet=0xYourWallet&format=compact"
).then((r) => r.json())
for (const l of locks) {
console.log(l.pair, l.usdValue, "of", l.poolTvlUsd,
"TVL ·", l.lockedSupplyPct + "% of pool ·",
l.unlocked ? "UNLOCKED" : l.secondsRemaining + "s left")
}The lock object (format=full)
chains[].locks[] carries the full lock. Money fields are decimal-USD strings (or null when the oracle can't price a token); timestamps are unix-second strings.
{
"wallet": "0x… (the locker)",
"totalLocks": 1, "lockedCount": 1, "unlockedCount": 0,
"totalLockedUsd": "29.04",
"chains": [{
"chainId": 8453,
"locks": [{
"kind": "V3",
"owner": "0x… (who)",
"tokenId": "5252792",
"key": "0x… (the bytes32 lock id)",
"pair": { "symbol0": "WETH", "symbol1": "PRXVT", "label": "WETH/PRXVT",
"token0": "0x42…06", "token1": "0x4b…A9" },
"liquidity": "3887694161053516084",
"usdValue": "29.04", // locked value (live)
"pool": {
"version": "V3",
"address": "0x6897…5Da0", // resolved pool (where)
"tvlUsd": "29.13", // real reserves (balanceOf the pool)
"lockedSupplyPct": 99.70, // this lock's share of pool value
"explorerUrl": "https://basescan.org/address/0x6897…",
"dexscreenerUrl": "https://dexscreener.com/base/0x6897…"
},
"lockedAt": "1750000000", // when
"unlockTime": "1752592000", // how long → until
"unlocked": false,
"secondsRemaining": 1860000
}]
}]
}ownerwhoThe locker. The address credited if a viewer trades through the lock's referral link.
kind · tokenId · pairwhatLock variant (V2 LP / V3 NFT / V4 NFT), the NFT id, and the token pair with symbols + logos.
chainId · pool.addresswhereChain and the resolved AMM pool, plus explorer + DexScreener deep links.
lockedAt · unlockTime · secondsRemainingwhen / how longLock start, unlock time (unix seconds) and the live countdown. unlocked flips true at unlockTime.
usdValuevalueThe locked position's live USD value. V2: its share of pool reserves. V3: amount0/amount1 derived from liquidity at the pool's current price, valued via the on-chain oracle.
pool.tvlUsdvalueThe pool's real TVL — the pool contract's actual token0/token1 balances, valued in USD (one multicall). Exact, not estimated.
pool.lockedSupplyPctvalueThis lock's USD value as a percentage of pool TVL — robust whether the position is in or out of range.
keyidentityThe bytes32 lock id. Feed it back as ?key= to re-resolve this exact lock without the owner address.
Read it straight from the contract
The API is a convenience layer over public view calls. Skip it entirely and read the LiquidityController yourself — the same numbers, from any RPC.
0x7777d7fFF155B043CE0A0785dd8c8c42bEbe7777 on Base (8453) and BSC (56).getLocks(address owner) → Lock[]readEvery lock for a wallet. Lock = { kind, asset, tokenIdOrAmount, lockTime, unlockTime, owner, tokenA, tokenB, nonce }.
getLockKeys(address owner) → bytes32[]readThe lock ids for a wallet, index-aligned with getLocks. Each id is a shareable handle to one lock.
locks(bytes32 key) → LockreadResolve a single lock by its id — no owner needed. Returns the all-zero struct for an unknown / released key. This backs ?key=.
import { createPublicClient, http } from "viem"
import { base } from "viem/chains"
const client = createPublicClient({ chain: base, transport: http() })
const CONTROLLER = "0x7777d7fFF155B043CE0A0785dd8c8c42bEbe7777"
const ABI = [{
type: "function", name: "locks", stateMutability: "view",
inputs: [{ type: "bytes32" }],
outputs: [
{ name: "kind", type: "uint8" }, { name: "asset", type: "address" },
{ name: "tokenIdOrAmount", type: "uint256" }, { name: "lockTime", type: "uint256" },
{ name: "unlockTime", type: "uint256" }, { name: "owner", type: "address" },
{ name: "tokenA", type: "address" }, { name: "tokenB", type: "address" },
{ name: "nonce", type: "uint256" },
],
}]
const lock = await client.readContract({
address: CONTROLLER, abi: ABI, functionName: "locks",
args: ["0xLOCK_ID_BYTES32"],
})
// lock.owner (who) · lock.tokenA/tokenB (what) · lock.lockTime → lock.unlockTime (how long)Pricing, caching & scope
getUsdPrice oracle (canonical 1e18). A token the oracle can't price yields null rather than a fabricated number.stale-while-revalidate. For block-fresh values, read the contract directly with the snippet above.usdValue (locked value); pool.tvlUsd is null./api/locks?key=… lets anyone audit one lock from its id alone — no wallet, no account. Drop the card straight into your site with the lock embed builder.