@lienfi/sdk
v0.1.0
Published
Price, screen and buy LienFi tax lien certificates from your own code — the calculator, the net-yield scorer, the quote client and the onchain call builders.
Maintainers
Readme
@lienfi/sdk
Price, screen and buy LienFi tax lien certificates from your own code — for vault builders, keepers, analysts and agents.
A read-and-buy client for the LienFi marketplace: the public REST surface, the signed-quote purchase flow, and the addresses, ABIs and EIP-712 definitions needed to send a purchase. It is the same purchase flow the LienFi marketplace itself runs, so you get identical behaviour instead of a re-implementation that drifts.
Valuations are not computed here — the API returns LienFi's own, on every row. See "Every yield the API publishes is GROSS" below.
npm i @lienfi/sdk
npm i viem # only if you need the onchain helpersESM only. Node ≥ 20. viem is an optional peer dependency: the whole REST
client runs without it, and even the call builders return plain descriptors —
nothing in this package imports viem at runtime.
Licence: PolyForm Shield 1.0.0 — source-available, not open source, and it
does not expire. Any purpose is permitted except providing a product that
competes with LienFi, so running a keeper, vault, fund, analytics product or
agent against LienFi is fully licensed — including modifying this package and
redistributing it — while building a rival lien marketplace or valuation product
is not. If you redistribute any part of it, carry forward the Required Notice:
and Licensor Line of Business: lines. See LICENSE.
Three things to read before you write a buy loop
1. Every yield the API publishes is GROSS
When a lien redeems, LienVault._redeemLien takes interestFeeBps — 10%
today — of your gain over what you paid. You keep 90% of the gain.
costBasis = what you paid
interestEarned = max(redemptiveValueAtRedemption - costBasis, 0)
platformFee = interestEarned * interestFeeBps / 10_000
youReceive = redemptiveValueAtRedemption - platformFeeThat rate is read at redemption, not snapshotted at purchase. A fee change hits open positions, so a net figure is today's rate applied to a fee levied years from now. Say so when you display one.
Valuations come off the API. LienFi computes them server-side and returns them in
each row's calculated block, so you are reading our own numbers rather than
reproducing them:
import { LienFiClient } from '@lienfi/sdk';
const client = new LienFiClient();
const { liens, feeConfig } = await client.listLiens({ limit: 100, states: 'FL,TX' });
// null means the API could not read FeeManager. It does NOT mean zero — score
// gross and label it, rather than ranking every lien too high.
if (!feeConfig) throw new Error('no fee rate available; do not rank on net');
const lien = liens[0]!;
const basis = lien.calculated!.current_listing_price!; // live, recomputed
const projection = lien.calculated!.redemptive_value_projection!;
const atMaturity = projection[projection.length - 1]!.value;
const days = lien.calculated!.days_to_maturity!;
const gain = Math.max(atMaturity - basis, 0);
const netGain = gain * (1 - feeConfig.interest_fee_bps / 10_000);
const netApy = (netGain / basis) * (365 / days) * 100;Four rows to refuse rather than rank. Each produces a confident number from data that does not exist, and three of them sort to the top of a naive ranking:
| condition | why |
| --- | --- |
| calculated.degraded | the calculator did not run; apy: 0 and days_to_maturity: 0 are placeholders indistinguishable from a real 0% / matures-today |
| redemption_lapsed | visible in the list, but getBuyPrice refuses every one — a top pick here is a quote that 400s |
| days_to_maturity <= 0 | dividing by it yields Infinity, and Infinity sorts first |
| no projection, or current_listing_price <= 0 | no maturity value, or a zero denominator |
Count what you drop rather than filtering silently — a shortlist that quietly
shrank reads as "that is all there was". examples/keeper.ts in the LienFi
monorepo implements exactly this, refusals included.
Gross vs net, on a real Florida row. Redemptive value 1895.60 today → 1983.77 at maturity, 302 days out, listed at par:
| figure | value | what it is |
| --- | --- | --- |
| gross | 5.62% | RV growth over the REMAINING term, before the fee |
| net | 5.06% | the same, after 10% of the gain. Rank on this. |
| calculated.dynamic_blended_apy | 5.81% | the API's own figure |
The fee costs 0.56pp. ⚠️ dynamic_blended_apy is a genuinely different
derivation — interest over the full term from issue, excluding fees and subsequent
taxes — so presenting "5.81 → 5.06" as the cost of the fee blames it for 0.75pp it
does not cost. Show all three, labelled, rather than picking a misleading pair.
Texas makes this unavoidable: its dynamic_blended_apy is hardcoded 0.
Why the calculator is not in this package. An earlier version exported
RedemptiveValueCalculator,STATE_CONFIGSand ascoreLien/buildShortlistpair. They are LienFi's valuation model, this package is public, and a bundle cannot hide what it ships. Nothing is lost: the API already computes every figure they produced, andCalculatedLienFinancialsdocuments each field — including which are placeholders on a degraded row.
2. A quote is valid for 300 seconds, is bound to one buyer, and a relist voids it
GET /liens/:id/buy-price?buyer= returns an EIP-712 signature over the price.
Four properties, all enforced on chain:
- 300 seconds. Past
deadline,_verifyPriceSignaturerevertsSignatureExpired. buyer == msg.sender. The signature is verified against the sender, so a quote fetched for one address and sent from another reverts.- It embeds the token's current
listingNonce. Listing increments it, so a relist between your quote and your send silently invalidates the signature. ReadlistingNonce(inmarketplaceAbi) if you hold a quote for any length of time. maturitymust equal the NFT's onchainMaturity, which is written once at mint and has no setter. The API signs the stored redemption deadline for exactly this reason. Never recompute or "freshen" it —_buyNFTcompares for equality and reverts"Maturity mismatch".
Fetch a quote immediately before sending. Do not quote a basket and then execute it; the clock is already running while your approve mines.
3. Only part of a quote is authenticated — approve from the signed part
The marketplace's PRICE_TYPEHASH covers exactly eight fields:
tokenId, lienPrice, maturity, deadline, baseValue, feeVersion, buyer, noncetotalAmount, sellerSurcharge and platformFee are not among them, and
nothing on chain ever reads them. They are display values: anyone able to alter
the response can set them to anything without invalidating the signature. The
test is the typehash, not where the API happens to compute them — they sat below
the signing call when this was written and sit above it today, which changes
nothing.
So the approve is built from lienPrice:
const calls = buildPurchaseCalls(quote, { chainId: 8453 });
calls.approvalAmount; // 1895.60 — lienPrice, and what you approve
calls.amountActuallySpent; // 1895.60 — identical, by design
calls.quotedTotalAmount; // 1945.60 — the quote's own total, for reconciliation only_collectFeesAndPayout moves exactly lienPrice (platform fee + capital-gains
fee + seller proceeds), so this is provably sufficient. The difference is the
deprecated seller surcharge — storage slot __deprecated_sellerSurcharge, written
0 on every listing, never transferred.
Approving the larger totalAmount would leave a surplus allowance behind any
buyNFT that reverts, and the amount would come from an unauthenticated field.
The LienFi marketplace frontend does approve totalAmount, but it sends the
pair as one atomic sendCalls userOperation specifically so a reverted buy cannot
strand an allowance. Two separate transactions have no such protection.
Set a ceiling. maxApprovalAmount is the only thing that bounds the approve,
and the reason is worth being exact about: lienPrice being inside the signature
bounds what can be spent, not what can be approved. This package does not
verify the quote's signature, so a tampered response can name any lienPrice and
have that amount approved — buyNFT then reverts InvalidSignature, and the
surplus allowance outlives it. That allowance is dormant rather than drainable
(the marketplace's only paymentToken pulls are in _collectFeesAndPayout,
whose sole caller passes msg.sender as the buyer), but do not rely on that:
buildPurchaseCalls(quote, { chainId: 8453 }, { maxApprovalAmount: 2_000_000_000n });If you want the stronger check, the EIP-712 pieces are exported: recover the
quote's signer from buyPriceTypes + buildBuyPriceDomain (with the buyer you
requested and listingNonce(tokenId)) and compare it against the marketplace's
priceSigner.
Both builders throw RangeError before constructing anything if a quote's
totalAmount does not equal lienPrice + sellerSurcharge. That is a tamper and
corruption signal, not a bound — sellerSurcharge is unauthenticated too, so
anyone rewriting one can rewrite the other to match. Treat it as an untrustworthy
response rather than retrying it.
Buying
Two transactions, in order. There is no permit path on this marketplace.
A wallet that has never accepted the legal documents cannot get a signed quote
at all — getBuyPrice answers 403 consent_required, and this package has no
route to clear it. Read "What getBuyPrice cannot yet do for you" below before
you wire this up; that is a prerequisite, not an error to handle.
import {
LienFiClient,
buildPurchaseCalls,
quoteSecondsRemaining,
simulatePurchase,
} from '@lienfi/sdk';
const quote = await client.getBuyPrice(lienId, account.address);
const calls = buildPurchaseCalls(quote, { chainId: 8453 });
// Optional pre-flight. The buy leg reports skipped: 'allowance' until the
// approve has mined — buyNFT pulls USDC, so simulating it earlier always fails
// on the allowance, and that failure says nothing about your quote.
const check = await simulatePurchase({
publicClient, account: account.address, quote, chainId: 8453,
});
await wallet.writeContract({ ...calls.approve, account, chain });
// …wait for the receipt…
if (quoteSecondsRemaining(quote) <= 0) return reQuote(); // cheaper than reverting
await wallet.writeContract({ ...calls.buy, account, chain });The builders return plain { address, abi, functionName, args } descriptors —
the shape writeContract, simulateContract, encodeFunctionData and any
4337 batcher already take. Nothing in this package imports viem at runtime.
Baskets
buildBulkPurchaseCalls(quotes, { chainId }) gives one approve for the sum of
every lienPrice — the signed amounts, for the reason in §3 — and one
bulkBuyNFT. The sum of the quotes' own totalAmounts comes back as
quotedTotalAmount, for reconciliation only. Max 50 orders (BatchTooLarge()
above that — refused client-side with the limit named).
It matters more here than in the single case: bulkBuyNFT skips stale orders and
spends less than the sum, so any surplus in the approved amount outlives the
transaction by construction.
⚠️
bulkBuyNFTsucceeds partially. It skips any order whose listing is no longer active or whosefeeVersionis stale, and returns how many it bought. A mined receipt can mean three of your five settled, with no event and no error for the other two. Check ownership afterwards. Orders that fail for any other reason (bad signature, expired deadline, maturity mismatch) revert the whole transaction instead.
Holding liens from a contract
If a vault, a multisig or any contract is going to hold LienFi certificates, four facts decide the design:
There are no transfer restrictions.
LienNFTis a plainERC721URIStorageUpgradeable— no allowlist, no soulbinding, no transfer hook. Any address can hold one.Delivery uses
transferFrom, notsafeTransferFrom. Both the vault unlock on purchase (lienVault.unlockNFT→lienNFT.transferFrom) and the redemption pull use the unsafe variant, so your contract does not needonERC721Received. ImplementingIERC721Receiveris harmless; omitting it will not block delivery.Redemption proceeds REQUIRE a one-time
setApprovalForAll. The holder must approve the LienVault for the whole collection orredeemLienrevertsVaultNotApproved:import { buildVaultApprovalCall } from '@lienfi/sdk'; await wallet.writeContract({ ...buildVaultApprovalCall({ chainId: 8453 }), account, chain });Collection-wide, not per-token: ERC-721 clears a token-level approval inside
_updateon every transfer, so a per-token grant evaporates the moment the lien moves. Idempotent, so calling it twice is free. Your contract must be able to make this call — expose it, or make it part of the deposit path.Redemption is operator-triggered, not self-serve. LienFi calls
redeemLienwhen the county pays; there is no function your contract calls to redeem on demand. So there is no moment at which a missing vault approval can be noticed and fixed by the holder — it has to be in place beforehand. Grant it right after the first lien arrives.
The REST client
LienFiClient covers the public, unauthenticated read surface and nothing else.
fetch only, no runtime dependencies.
| method | route |
| --- | --- |
| listLiens(params?, signal?) | GET /liens with the full filter set |
| getFacets(signal?) | GET /liens/facets — distinct values and real bounds |
| getLien(id, signal?) | GET /liens/:id |
| getBuyPrice(id, buyer, signal?) | GET /liens/:id/buy-price?buyer= |
new LienFiClient({
baseUrl: 'https://…/api/v1', // defaults to DEFAULT_API_BASE_URL; include /api/v1
fetch: myFetchWithRetries, // defaults to global fetch
headers: { 'user-agent': 'my-keeper/1.0' },
timeoutMs: 10_000,
});Failures throw LienFiApiError with status and path. The message is the
API's own sentence, verbatim — a lapsed lien answers
Redemption window closed on 2026-07-31. This lien is no longer for sale., which
tells you the retry can never succeed. status: 0 means the request never got an
answer (DNS, TLS, an aborted signal) and may or may not have reached the API.
Authenticated routes — agent registration, purchase consent, portfolio, relisting
— are deliberately absent: they need a Privy session or an operator signature,
and a half-implemented auth flow is worse than none. The EIP-712 builders for the
operator-authorization documents are exported
(buildAgentAuthorizationTypedData and friends), so you can produce a signature
the API accepts without copying the type definitions.
What getBuyPrice cannot yet do for you
Signing a quote is the one point where the API refuses a purchase, so it is where every gate lives. Four of them can answer this call, and this client does not carry the two inputs that clear the first two:
- 403
consent_required— the buyer wallet must hold acceptances of the current legal documents. There is no SDK route to record them: for a human wallet it happens in the app; for an automated one it happens throughregister_agenton the hosted MCP server atPOST /api/v1/mcp, which takes the operator-authorization signature these EIP-712 builders produce. Clear this before you build a buy loop — it is not a transient error, and it refuses every wallet that has never been through one of those two flows. - 401
authorization_required— a wallet already bound to an agent authorization may only be quoted by the holder of that authorization blob, presented asAuthorization: Bearer <blob>(the same header the MCP endpoint takes), so that nobody else can occupy that wallet's one in-flight quote.LienFiClientsends no such header, so a bound wallet cannot be quoted through it. An unbound keeper wallet is unaffected. - 451
sanctioned_address— the buyer is on OFAC's SDN list per Chainalysis' on-chain oracle, or the request came from a blocked jurisdiction. Terminal. - 503
sanctions_screening_unavailableorspend_ledger_unavailable— a gate could not be read. Both fail CLOSED by design, so this is the one refusal here that is worth retrying.
The route also takes ?intent=indicative|preflight|signed, which this client does
not expose: it always asks for a signed quote. indicative prices a lien without
signing, reserving the wallet's quote slot or running the gates above;
preflight runs the gates and still signs nothing. Until the client exposes
them, reach them with your own fetch — listLiens' calculated block is
enough for ranking, and this call is only needed to actually buy.
max_price filters the stored listing_price column, while a quote
recomputes the price from live redemptive value — so a lien inside your budget
can still quote above it. Carry your own ceiling.
Also exported
BASE_BUILDER_CODE/BASE_BUILDER_DATA_SUFFIX— the ERC-8021 Base Builder Code attribution. The call builders attach it asdataSuffixautomatically, and viem reads that field in bothwriteContractandsimulateContract, so spreading a descriptor carries it with no extra work. These are exported for the case the builders cannot cover — a wallet client's owndataSuffix, or a 4337 bundler concatenating ontouserOp.callData. Pass{ builderCode: false }to any builder to opt out; the suffix is trailing bytes no contract in this system reads, so nothing else changes.formatCurrency,formatAddress— display formatting, nothing more.isRedemptionLapsed,isSaleBlockedByLapse,isRedeemableDeedand the two policy booleans. A lapsed redeemable deed stays visible and is not purchasable — what a buyer would be acquiring is the property itself, which the platform can neither convey nor price.addresses/getAddresses/CHAIN_IDS— Base mainnet (8453) and Base Sepolia (84532).lfiis absent on testnet on purpose:eth_getCodeat that address on Sepolia returns0x, so abalanceOfthere does not return zero, it fails to decode. Branch onundefined.buyPriceTypes/buildBuyPriceDomain— the EIP-712 definition, so you can recover a quote's signer locally and compare it against the marketplace'spriceSignerbefore spending on it.marketplaceAbi/lienNftAbiMinimal/usdcAbi— narrowed from the generated ABIs (not copied), coveringbuyNFT,bulkBuyNFT,listingNonce,setApprovalForAll,isApprovedForAll,lienDetails,ownerOf,approve,allowance,balanceOf,decimals. The operator and fee-admin surface is withheld.
Example
A full worked keeper — screen → rank on net yield → quote → pre-flight →
approve → buy → vault-approve, with the validity window handled — lives at
examples/keeper.ts in the LienFi monorepo. It is env-gated and writes nothing
without LIENFI_EXECUTE=1. It is not part of this tarball, because it imports
the package from source; every step it takes is shown above.
Risk
Buying a lien spends real USDC on a public blockchain and there is no undo. This SDK has no spend cap: your wallet balance is the mandate. Every yield here is an estimate under today's fee rate, redemption timing is not guaranteed, and nothing in this package is investment, tax or legal advice.
License
PolyForm Shield 1.0.0 — see
LICENSE, which carries the two notice lines a redistributor must
preserve. Source-available, not OSI open source: if your procurement process
requires an OSI-approved licence on production dependencies, talk to us rather
than vendoring around it.
