@degen-insurance/sdk 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/limits.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * What a user is actually allowed to buy.
3
+ *
4
+ * Three separate limits decide it and they are denominated in three different things: the
5
+ * registry's minimum and maximum are notional wei, remaining capacity is a payout, and the
6
+ * share cap counts tokens. A user typing one number into one box cannot reconcile those, so a
7
+ * partner that does not do this arithmetic can only relay refusals after the fact -- which is
8
+ * what "This transaction would fail on chain (0xeddee5b2)" looked like before the front end
9
+ * grew the same helpers.
10
+ */
11
+ export const BPS = 10000n;
12
+ /**
13
+ * Robinhood Chain's rate card, and nobody else's.
14
+ *
15
+ * Named for the chain it was measured on, because that is the whole of its validity. The
16
+ * premiums come from a calibration run over 4,679 pools and 301,612 buys on Robinhood Chain;
17
+ * `Chains.card()` reverts `NoRateCard` for every other chain rather than reuse them, and this
18
+ * SDK holds the same line. A premium is a claim about how often a token collapses, and that
19
+ * is a property of a chain's flow, not of the product.
20
+ *
21
+ * Use it to render a quote before you have a connection. Once you have one, read the card off
22
+ * the registry with `readRateCard` -- a deployment may have been reconfigured, and on any
23
+ * chain but Robinhood these numbers were never true to begin with.
24
+ */
25
+ export const ROBINHOOD_RATE_CARD = [
26
+ { id: 0, name: "Basic", premiumBps: 300n, payoutBps: 1500n, minEntryBps: 12500n },
27
+ { id: 1, name: "Standard", premiumBps: 500n, payoutBps: 2500n, minEntryBps: 15000n },
28
+ { id: 2, name: "Degen Max", premiumBps: 1000n, payoutBps: 5000n, minEntryBps: 25000n },
29
+ ];
30
+ /** Tier names are presentation, which the contracts do not own. Everything else is read. */
31
+ const TIER_NAMES = { 0: "Basic", 1: "Standard", 2: "Degen Max" };
32
+ export function tierById(card, id) {
33
+ const t = card.find((x) => x.id === id);
34
+ if (!t)
35
+ throw new Error(`unknown tier ${id}`);
36
+ return t;
37
+ }
38
+ /**
39
+ * Read the live rate card off a deployment's registry.
40
+ *
41
+ * The premium, the payout and the minimum entry are all product terms the contract enforces,
42
+ * so all three are read. Only the display name is local.
43
+ */
44
+ export async function readRateCard(reader, registry, abi, ids = [0, 1, 2]) {
45
+ const rows = await Promise.all(ids.map((id) => reader.readContract({ address: registry, abi, functionName: "tiers", args: [BigInt(id)] })));
46
+ return rows.map((row, i) => {
47
+ const [premiumBps, payoutBps, minEntryBps, enabled] = row;
48
+ if (!enabled)
49
+ throw new Error(`tier ${ids[i]} is disabled on this deployment`);
50
+ return {
51
+ id: ids[i],
52
+ name: TIER_NAMES[ids[i]] ?? `Tier ${ids[i]}`,
53
+ premiumBps: BigInt(premiumBps),
54
+ payoutBps: BigInt(payoutBps),
55
+ minEntryBps: BigInt(minEntryBps),
56
+ };
57
+ });
58
+ }
59
+ /**
60
+ * Throw if a local card disagrees with the chain's.
61
+ *
62
+ * For callers who quote from `ROBINHOOD_RATE_CARD` before connecting: this is what turns a
63
+ * silent mispricing into a loud failure the first time it happens. Quoting one chain's
64
+ * premiums against another chain's risk is the failure mode worth being noisy about, because
65
+ * nothing downstream of it looks wrong -- the trade succeeds, at the wrong price.
66
+ */
67
+ export function assertRateCard(local, onChain) {
68
+ for (const chain of onChain) {
69
+ const mine = local.find((t) => t.id === chain.id);
70
+ if (!mine)
71
+ throw new Error(`local card is missing tier ${chain.id}`);
72
+ for (const field of ["premiumBps", "payoutBps", "minEntryBps"]) {
73
+ if (mine[field] !== chain[field]) {
74
+ throw new Error(`rate card mismatch on tier ${chain.id}: local ${field}=${mine[field]}, chain says ${chain[field]}`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ /** Split a total payment into the trade and the premium, exactly as the router does. */
80
+ export function splitValue(total, premiumBps) {
81
+ const notional = (total * BPS) / (BPS + premiumBps);
82
+ return { notional, premium: (notional * premiumBps) / BPS };
83
+ }
84
+ /** A notional, grossed up to the total a user pays -- trade plus premium. */
85
+ export function totalForNotional(notional, tier) {
86
+ return (notional * (BPS + tier.premiumBps)) / BPS;
87
+ }
88
+ /**
89
+ * Largest total spendable before the payout exceeds what is left on a token.
90
+ *
91
+ * Remaining capacity is a *payout* figure, and a user types a *trade size*. They differ by the
92
+ * tier -- 2.2x at Degen Max, 6.9x at Basic -- so relaying the capacity number itself tells a
93
+ * user to spend far less than they could.
94
+ */
95
+ export function maxTotalForCapacity(capacity, tier) {
96
+ if (capacity <= 0n || tier.payoutBps === 0n)
97
+ return 0n;
98
+ return totalForNotional((capacity * BPS) / tier.payoutBps, tier);
99
+ }
100
+ /** Tokens a payment buys, from the pool's own constant-product maths. */
101
+ export function quoteOut(q, notional) {
102
+ if (notional <= 0n || q.tokenReserve === 0n || q.quoteReserve === 0n)
103
+ return 0n;
104
+ const inAfterFee = notional - (notional * q.feeBps) / BPS;
105
+ if (inAfterFee <= 0n)
106
+ return 0n;
107
+ return (q.tokenReserve * inAfterFee) / (q.quoteReserve + inAfterFee);
108
+ }
109
+ /**
110
+ * The inverse of `quoteOut`: the largest payment that buys no more than `tokens`.
111
+ *
112
+ * The position-share cap is a share of supply, so it is expressed in tokens while the user
113
+ * pays in the quote asset. Rounds down, and settles the last wei against `quoteOut` rather
114
+ * than trusting the algebra -- integer division lands either side of the true answer depending
115
+ * on the residue, and "the maximum" being one wei too large is the whole bug this prevents.
116
+ */
117
+ export function notionalForTokens(q, tokens) {
118
+ if (tokens <= 0n || q.tokenReserve === 0n || q.quoteReserve === 0n)
119
+ return 0n;
120
+ if (tokens >= q.tokenReserve)
121
+ return 0n; // the curve cannot deliver its whole reserve
122
+ const afterFee = (tokens * q.quoteReserve) / (q.tokenReserve - tokens);
123
+ const feeDenom = BPS - q.feeBps;
124
+ if (feeDenom <= 0n)
125
+ return 0n;
126
+ let notional = (afterFee * BPS) / feeDenom;
127
+ while (notional > 0n && quoteOut(q, notional) > tokens)
128
+ notional -= 1n;
129
+ while (quoteOut(q, notional + 1n) <= tokens)
130
+ notional += 1n;
131
+ return notional;
132
+ }
133
+ /**
134
+ * The band of totals a buy will actually be accepted at.
135
+ *
136
+ * A zero limit means "not known", never "capped at zero" -- treating an unloaded bound as a
137
+ * hard zero collapses the band and refuses everything.
138
+ *
139
+ * The share bound is shaved by 1%. It counts tokens while the user pays in the quote asset,
140
+ * and the rate between them moves on every trade, so a price fall between quoting a maximum
141
+ * and signing it means the same payment buys more. Sitting exactly on the limit is how a
142
+ * stated maximum still gets refused.
143
+ */
144
+ export function amountRange(capacity, tier, limits) {
145
+ const min = limits.minTradeWei > 0n ? totalForNotional(limits.minTradeWei, tier) : 1n;
146
+ let max = maxTotalForCapacity(capacity, tier);
147
+ const cap = (other) => {
148
+ if (other < max)
149
+ max = other;
150
+ };
151
+ if (limits.perTradeMaxWei > 0n)
152
+ cap(totalForNotional(limits.perTradeMaxWei, tier));
153
+ if (limits.maxShareNotionalWei !== undefined) {
154
+ cap(totalForNotional((limits.maxShareNotionalWei * 99n) / 100n, tier));
155
+ }
156
+ return { min, max, usable: max >= min };
157
+ }
158
+ /**
159
+ * Tokens a wallet may still insure on a token, before either share cap binds.
160
+ *
161
+ * Both caps are cumulative and both are a share of supply: one per wallet, one across all
162
+ * wallets. Whichever has less room left decides.
163
+ */
164
+ export function shareHeadroom(supply, walletTokensInsured, tokenTokensInsured, limits) {
165
+ if (supply <= 0n)
166
+ return 0n;
167
+ const walletRoom = (supply * limits.maxPositionShareBps) / BPS - walletTokensInsured;
168
+ const tokenRoom = (supply * limits.maxTokenInsuredShareBps) / BPS - tokenTokensInsured;
169
+ const room = walletRoom < tokenRoom ? walletRoom : tokenRoom;
170
+ return room > 0n ? room : 0n;
171
+ }
172
+ /** Decode the `getParams()` tuple into the limits this module works in. */
173
+ export function riskLimitsFromParams(p) {
174
+ return {
175
+ maxPositionShareBps: BigInt(p[6]),
176
+ maxTokenInsuredShareBps: BigInt(p[7]),
177
+ minTradeWei: BigInt(p[9]),
178
+ perTradeMaxWei: BigInt(p[10]),
179
+ };
180
+ }
@@ -0,0 +1,14 @@
1
+ import type { Alert, PolicyView } from "../alerts/state.js";
2
+ /** Wei to a short decimal string. Kept local so the SDK has no formatting dependency. */
3
+ export declare function formatEth(wei: bigint, dp?: number): string;
4
+ export declare function formatCountdown(seconds: number): string;
5
+ /** Telegram MarkdownV2 reserves a lot of punctuation; unescaped text silently fails to send. */
6
+ export declare function escapeMarkdown(text: string): string;
7
+ export declare function renderAlert(alert: Alert, policy: PolicyView, nowSeconds: number): string;
8
+ /** Inline keyboard for a claimable policy. Partners render this against their own signer. */
9
+ export declare function claimKeyboard(policyId: string, deepLink: string): {
10
+ inline_keyboard: {
11
+ text: string;
12
+ url: string;
13
+ }[][];
14
+ };
@@ -0,0 +1,44 @@
1
+ /** Wei to a short decimal string. Kept local so the SDK has no formatting dependency. */
2
+ export function formatEth(wei, dp = 4) {
3
+ const neg = wei < 0n;
4
+ const v = neg ? -wei : wei;
5
+ const whole = v / 10n ** 18n;
6
+ const frac = (v % 10n ** 18n).toString().padStart(18, "0").slice(0, dp).replace(/0+$/, "");
7
+ return `${neg ? "-" : ""}${frac ? `${whole}.${frac}` : whole}`;
8
+ }
9
+ export function formatCountdown(seconds) {
10
+ if (seconds <= 0)
11
+ return "expired";
12
+ const m = Math.floor(seconds / 60);
13
+ const s = seconds % 60;
14
+ return `${m}m ${String(s).padStart(2, "0")}s`;
15
+ }
16
+ /** Telegram MarkdownV2 reserves a lot of punctuation; unescaped text silently fails to send. */
17
+ export function escapeMarkdown(text) {
18
+ return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (c) => `\\${c}`);
19
+ }
20
+ export function renderAlert(alert, policy, nowSeconds) {
21
+ const sym = escapeMarkdown(policy.symbol);
22
+ const cover = escapeMarkdown(formatEth(policy.maxPayoutWei));
23
+ const left = escapeMarkdown(formatCountdown(policy.expiresAt - nowSeconds));
24
+ switch (alert.kind) {
25
+ case "cover_active":
26
+ return `🛡 *Cover active* on ${sym}\nPays up to ${cover} ETH if it falls back to launch price\\. ${left} left\\.`;
27
+ case "triggered_claimable":
28
+ return `🔔 *${sym} hit the floor* \\- your cover is claimable now\nPayout: ${cover} ETH`;
29
+ case "window_closing":
30
+ return `⏳ *${sym} cover expires in ${left}*\nNo payout unless it falls back to launch price before then\\.`;
31
+ case "claim_paid":
32
+ return `✅ *Claim paid* on ${sym}\n${cover} ETH sent to your wallet\\.`;
33
+ case "expired_unused":
34
+ return `${sym} cover expired without a collapse\\. Nothing to claim \\- that is the good outcome\\.`;
35
+ case "cover_ended":
36
+ return `Cover on ${sym} ended \\- you took the position out early\\. Nothing left to claim on it\\.`;
37
+ }
38
+ }
39
+ /** Inline keyboard for a claimable policy. Partners render this against their own signer. */
40
+ export function claimKeyboard(policyId, deepLink) {
41
+ return {
42
+ inline_keyboard: [[{ text: "Claim now", url: `${deepLink}?claim=${encodeURIComponent(policyId)}` }]],
43
+ };
44
+ }
@@ -0,0 +1,51 @@
1
+ import { type Address, type Hex } from "viem";
2
+ import type { Deployment } from "../deployment.js";
3
+ /** A transaction request a partner signs with the user's own key. Never a private key here. */
4
+ export interface TxRequest {
5
+ to: Address;
6
+ data: Hex;
7
+ value: bigint;
8
+ }
9
+ export interface BuyWithCoverParams {
10
+ /** Which deployment, on which chain. Carries the addresses that have to agree. */
11
+ deployment: Deployment;
12
+ token: Address;
13
+ /** Total the user commits: trade plus premium. */
14
+ totalValue: bigint;
15
+ /** Slippage bound in token base units. */
16
+ minOut: bigint;
17
+ tier: number;
18
+ /** Partner address earning the revenue share. Omit for an unattributed trade. */
19
+ referrer?: Address;
20
+ }
21
+ /**
22
+ * Build the buy-with-cover call.
23
+ *
24
+ * The SDK never holds keys and never submits. Partners keep custody and their own UX; this
25
+ * returns a request for them to sign. That is the difference between integrating with a bot
26
+ * and becoming one, and it keeps the security surface of a custodial exchange out of scope.
27
+ */
28
+ export declare function buildBuyWithCover(params: BuyWithCoverParams): TxRequest;
29
+ export declare function buildBuyWithoutCover(deployment: Deployment, token: Address, totalValue: bigint, minOut: bigint): TxRequest;
30
+ /** Latch the trigger and claim in one call, which is what a UI should always send. */
31
+ export declare function buildClaim(deployment: Deployment, policyId: bigint): TxRequest;
32
+ /**
33
+ * Sell the insured position and end the cover, in one transaction.
34
+ *
35
+ * The core escrows the position for the life of the policy, so a holder cannot sell it the
36
+ * ordinary way. Without this a partner can only offer "wait for expiry", which is not a
37
+ * product -- the whole point of the escrow design was that leaving stays a single click.
38
+ *
39
+ * `minOut` is a slippage bound on the sale proceeds in wei, not in tokens.
40
+ */
41
+ export declare function buildSellAndVoid(deployment: Deployment, policyId: bigint, minOut: bigint): TxRequest;
42
+ /**
43
+ * Take the position back out of escrow and end the cover, without selling.
44
+ *
45
+ * For a holder who wants to keep the tokens. Also the only exit when no venue is registered
46
+ * for the token, since `sellAndVoid` needs somewhere to route the sale.
47
+ */
48
+ export declare function buildVoidAndRelease(deployment: Deployment, policyId: bigint): TxRequest;
49
+ export declare function buildPartnerClaim(deployment: Deployment, to: Address): TxRequest;
50
+ /** Slippage bound from an expected output. `bps` is tolerance, so 100 is 1%. */
51
+ export declare function minOutWithSlippage(expectedOut: bigint, bps: number): bigint;
@@ -0,0 +1,92 @@
1
+ import { encodeFunctionData } from "viem";
2
+ import { universalRouterAbi, insuranceCoreAbi, partnerRegistryAbi } from "../abi.js";
3
+ /**
4
+ * Build the buy-with-cover call.
5
+ *
6
+ * The SDK never holds keys and never submits. Partners keep custody and their own UX; this
7
+ * returns a request for them to sign. That is the difference between integrating with a bot
8
+ * and becoming one, and it keeps the security surface of a custodial exchange out of scope.
9
+ */
10
+ export function buildBuyWithCover(params) {
11
+ const { deployment, token, totalValue, minOut, tier, referrer } = params;
12
+ if (totalValue <= 0n)
13
+ throw new Error("totalValue must be positive");
14
+ if (!Number.isInteger(tier) || tier < 0)
15
+ throw new Error("tier must be a non-negative integer");
16
+ const data = referrer
17
+ ? encodeFunctionData({
18
+ abi: universalRouterAbi,
19
+ functionName: "buyWithCoverVia",
20
+ args: [token, minOut, tier, referrer],
21
+ })
22
+ : encodeFunctionData({
23
+ abi: universalRouterAbi,
24
+ functionName: "buyWithCover",
25
+ args: [token, minOut, tier],
26
+ });
27
+ return { to: deployment.router, data, value: totalValue };
28
+ }
29
+ export function buildBuyWithoutCover(deployment, token, totalValue, minOut) {
30
+ if (totalValue <= 0n)
31
+ throw new Error("totalValue must be positive");
32
+ return {
33
+ to: deployment.router,
34
+ data: encodeFunctionData({ abi: universalRouterAbi, functionName: "buy", args: [token, minOut] }),
35
+ value: totalValue,
36
+ };
37
+ }
38
+ /** Latch the trigger and claim in one call, which is what a UI should always send. */
39
+ export function buildClaim(deployment, policyId) {
40
+ return {
41
+ to: deployment.core,
42
+ data: encodeFunctionData({ abi: insuranceCoreAbi, functionName: "latchAndClaim", args: [policyId] }),
43
+ value: 0n,
44
+ };
45
+ }
46
+ /**
47
+ * Sell the insured position and end the cover, in one transaction.
48
+ *
49
+ * The core escrows the position for the life of the policy, so a holder cannot sell it the
50
+ * ordinary way. Without this a partner can only offer "wait for expiry", which is not a
51
+ * product -- the whole point of the escrow design was that leaving stays a single click.
52
+ *
53
+ * `minOut` is a slippage bound on the sale proceeds in wei, not in tokens.
54
+ */
55
+ export function buildSellAndVoid(deployment, policyId, minOut) {
56
+ if (minOut < 0n)
57
+ throw new Error("minOut must not be negative");
58
+ return {
59
+ to: deployment.core,
60
+ data: encodeFunctionData({ abi: insuranceCoreAbi, functionName: "sellAndVoid", args: [policyId, minOut] }),
61
+ value: 0n,
62
+ };
63
+ }
64
+ /**
65
+ * Take the position back out of escrow and end the cover, without selling.
66
+ *
67
+ * For a holder who wants to keep the tokens. Also the only exit when no venue is registered
68
+ * for the token, since `sellAndVoid` needs somewhere to route the sale.
69
+ */
70
+ export function buildVoidAndRelease(deployment, policyId) {
71
+ return {
72
+ to: deployment.core,
73
+ data: encodeFunctionData({ abi: insuranceCoreAbi, functionName: "voidAndRelease", args: [policyId] }),
74
+ value: 0n,
75
+ };
76
+ }
77
+ export function buildPartnerClaim(deployment, to) {
78
+ if (!deployment.partnerRegistry) {
79
+ throw new Error(`${deployment.name} has no partner registry`);
80
+ }
81
+ return {
82
+ to: deployment.partnerRegistry,
83
+ data: encodeFunctionData({ abi: partnerRegistryAbi, functionName: "claim", args: [to] }),
84
+ value: 0n,
85
+ };
86
+ }
87
+ /** Slippage bound from an expected output. `bps` is tolerance, so 100 is 1%. */
88
+ export function minOutWithSlippage(expectedOut, bps) {
89
+ if (bps < 0 || bps > 10_000)
90
+ throw new Error("slippage must be between 0 and 10000 bps");
91
+ return (expectedOut * BigInt(10_000 - bps)) / 10000n;
92
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@degen-insurance/sdk",
3
+ "version": "0.2.0",
4
+ "description": "Build Degen Trade Insurance transactions, quotes and alerts. Never holds keys.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE"
20
+ ],
21
+ "sideEffects": false,
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
27
+ "test": "vitest run",
28
+ "typecheck": "tsc --noEmit",
29
+ "verify-package": "node scripts/verify-package.mjs",
30
+ "prepublishOnly": "npm run typecheck && npm test && npm run build && npm run verify-package"
31
+ },
32
+ "peerDependencies": {
33
+ "viem": ">=2.21.0 <3"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^26.1.2",
37
+ "typescript": "^5.6.3",
38
+ "viem": "^2.21.0",
39
+ "vitest": "^2.1.3"
40
+ }
41
+ }