Skip to content

Commit d37680d

Browse files
Merge pull request #344 from streamflow-finance/fix/add-additional-checks-for-locks
fix: improve lock vs vesting classification
2 parents 011fc46 + bda96ee commit d37680d

9 files changed

Lines changed: 230 additions & 19 deletions

File tree

lerna.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
{
2-
"packages": [
3-
"packages/*"
4-
],
5-
"version": "11.2.0",
2+
"packages": ["packages/*"],
3+
"version": "11.2.1",
64
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
75
"command": {
86
"run": {

packages/common/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/common",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "Common utilities and types used by streamflow packages.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"type": "module",

packages/distributor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/distributor",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "JavaScript SDK to interact with Streamflow Airdrop protocol.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"main": "./dist/cjs/index.cjs",

packages/eslint-config/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/eslint-config",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "ESLint configuration for Streamflow protocol.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"engines": {

packages/launchpad/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/launchpad",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "JavaScript SDK to interact with Streamflow Launchpad protocol.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"main": "./dist/cjs/index.cjs",

packages/staking/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/staking",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "JavaScript SDK to interact with Streamflow Staking protocol.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"main": "./dist/cjs/index.cjs",
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import BN from "bn.js";
2+
import { describe, expect, test } from "vitest";
3+
4+
import { buildStreamType, isTokenLock } from "../../solana/contractUtils.js";
5+
import { StreamType } from "../../solana/types.js";
6+
7+
const baseLockPermissions = {
8+
canTopup: false,
9+
automaticWithdrawal: false,
10+
cancelableBySender: false,
11+
cancelableByRecipient: false,
12+
transferableBySender: false,
13+
transferableByRecipient: false,
14+
};
15+
16+
const baseVestingPermissions = {
17+
canTopup: true,
18+
automaticWithdrawal: false,
19+
cancelableBySender: true,
20+
cancelableByRecipient: false,
21+
transferableBySender: false,
22+
transferableByRecipient: false,
23+
};
24+
25+
describe("isTokenLock", () => {
26+
test("legitimate time-based lock (cliff close to end) returns true", () => {
27+
const result = isTokenLock({
28+
...baseLockPermissions,
29+
depositedAmount: new BN(1_000_000),
30+
cliffAmount: new BN(999_999),
31+
cliff: 1700000000,
32+
end: 1700000001,
33+
});
34+
expect(result).toBe(true);
35+
});
36+
37+
test("exploit lock (cliff far before end) returns false", () => {
38+
const result = isTokenLock({
39+
...baseLockPermissions,
40+
depositedAmount: new BN(1_000_000),
41+
cliffAmount: new BN(1_000_000),
42+
cliff: 1700000000,
43+
end: 1700000000 + 86400 * 30,
44+
});
45+
expect(result).toBe(false);
46+
});
47+
48+
test("exploit lock with small gap (2s) returns false", () => {
49+
const result = isTokenLock({
50+
...baseLockPermissions,
51+
depositedAmount: new BN(1_000_000),
52+
cliffAmount: new BN(999_999),
53+
cliff: 1700000000,
54+
end: 1700000002,
55+
});
56+
expect(result).toBe(false);
57+
});
58+
59+
test("lock at exact threshold boundary (1s gap) returns true", () => {
60+
const result = isTokenLock({
61+
...baseLockPermissions,
62+
depositedAmount: new BN(1_000_000),
63+
cliffAmount: new BN(999_999),
64+
cliff: 1700000000,
65+
end: 1700000001,
66+
});
67+
expect(result).toBe(true);
68+
});
69+
70+
test("lock with zero gap (cliff equals end) returns true", () => {
71+
const result = isTokenLock({
72+
...baseLockPermissions,
73+
depositedAmount: new BN(1_000_000),
74+
cliffAmount: new BN(999_999),
75+
cliff: 1700000000,
76+
end: 1700000000,
77+
});
78+
expect(result).toBe(true);
79+
});
80+
81+
test("vesting contract (cliff amount << deposited) returns false", () => {
82+
const result = isTokenLock({
83+
...baseLockPermissions,
84+
depositedAmount: new BN(1_000_000),
85+
cliffAmount: new BN(100_000),
86+
cliff: 1700000000,
87+
end: 1700086400,
88+
});
89+
expect(result).toBe(false);
90+
});
91+
92+
test("dynamic lock (isDynamicLock path) returns true regardless of cliff/end gap", () => {
93+
const result = isTokenLock({
94+
...baseLockPermissions,
95+
depositedAmount: new BN(1_000_000),
96+
cliffAmount: new BN(100_000),
97+
cliff: 1700000000,
98+
end: 1700000000 + 86400 * 365,
99+
minPrice: 1,
100+
maxPrice: 1.5,
101+
minPercentage: 0,
102+
maxPercentage: 100,
103+
});
104+
expect(result).toBe(true);
105+
});
106+
107+
test("returns false when any permission is enabled", () => {
108+
const result = isTokenLock({
109+
...baseLockPermissions,
110+
canTopup: true,
111+
depositedAmount: new BN(1_000_000),
112+
cliffAmount: new BN(999_999),
113+
cliff: 1700000000,
114+
end: 1700000001,
115+
});
116+
expect(result).toBe(false);
117+
});
118+
119+
test("backward compat: no cliff/end provided, cliff amount close to deposited returns true", () => {
120+
const result = isTokenLock({
121+
...baseLockPermissions,
122+
depositedAmount: new BN(1_000_000),
123+
cliffAmount: new BN(999_999),
124+
});
125+
expect(result).toBe(true);
126+
});
127+
128+
test("backward compat: no cliff/end provided, cliff amount far from deposited returns false", () => {
129+
const result = isTokenLock({
130+
...baseLockPermissions,
131+
depositedAmount: new BN(1_000_000),
132+
cliffAmount: new BN(100_000),
133+
});
134+
expect(result).toBe(false);
135+
});
136+
});
137+
138+
describe("buildStreamType", () => {
139+
test("legitimate lock returns StreamType.Lock", () => {
140+
const result = buildStreamType({
141+
...baseLockPermissions,
142+
depositedAmount: new BN(1_000_000),
143+
cliffAmount: new BN(999_999),
144+
cliff: 1700000000,
145+
end: 1700000001,
146+
});
147+
expect(result).toBe(StreamType.Lock);
148+
});
149+
150+
test("exploit lock returns StreamType.Vesting", () => {
151+
const result = buildStreamType({
152+
...baseLockPermissions,
153+
depositedAmount: new BN(1_000_000),
154+
cliffAmount: new BN(1_000_000),
155+
cliff: 1700000000,
156+
end: 1700000000 + 86400 * 30,
157+
});
158+
expect(result).toBe(StreamType.Vesting);
159+
});
160+
161+
test("vesting contract returns StreamType.Vesting", () => {
162+
const result = buildStreamType({
163+
...baseVestingPermissions,
164+
depositedAmount: new BN(1_000_000),
165+
cliffAmount: new BN(100_000),
166+
cliff: 1700000000,
167+
end: 1700086400,
168+
});
169+
expect(result).toBe(StreamType.Vesting);
170+
});
171+
172+
test("dynamic lock returns StreamType.Lock", () => {
173+
const result = buildStreamType({
174+
...baseLockPermissions,
175+
depositedAmount: new BN(1_000_000),
176+
cliffAmount: new BN(100_000),
177+
cliff: 1700000000,
178+
end: 1700000000 + 86400 * 365,
179+
minPrice: 1,
180+
maxPrice: 1.5,
181+
minPercentage: 0,
182+
maxPercentage: 100,
183+
});
184+
expect(result).toBe(StreamType.Lock);
185+
});
186+
187+
test("backward compat: no cliff/end, legitimate lock returns StreamType.Lock", () => {
188+
const result = buildStreamType({
189+
...baseLockPermissions,
190+
depositedAmount: new BN(1_000_000),
191+
cliffAmount: new BN(999_999),
192+
});
193+
expect(result).toBe(StreamType.Lock);
194+
});
195+
});

packages/stream/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@streamflow/stream",
3-
"version": "11.2.0",
3+
"version": "11.2.1",
44
"description": "JavaScript SDK to interact with Streamflow protocol.",
55
"homepage": "https://github.com/streamflow-finance/js-sdk/",
66
"main": "./dist/cjs/index.cjs",

packages/stream/solana/contractUtils.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "./types.js";
1010

1111
const MAX_SAFE_UNIX_TIME_VALUE = 8640000000000;
12+
const MAX_CLIFF_END_GAP_SECONDS = 1;
1213

1314
interface ICalculateUnlockedAmount {
1415
depositedAmount: BN;
@@ -94,20 +95,35 @@ export const isTokenLock = (streamData: {
9495
transferableByRecipient: boolean;
9596
depositedAmount: BN;
9697
cliffAmount: BN;
98+
cliff?: number;
99+
end?: number;
97100
minPrice?: number;
98101
maxPrice?: number;
99102
minPercentage?: number;
100103
maxPercentage?: number;
101104
}): boolean => {
102-
return (
103-
!streamData.canTopup &&
104-
!streamData.automaticWithdrawal &&
105-
!streamData.cancelableBySender &&
106-
!streamData.cancelableByRecipient &&
107-
!streamData.transferableBySender &&
108-
(isCliffCloseToDepositedAmount(streamData) ||
109-
isDynamicLock(streamData.minPrice, streamData.maxPrice, streamData.minPercentage, streamData.maxPercentage))
110-
);
105+
if (
106+
streamData.canTopup ||
107+
streamData.automaticWithdrawal ||
108+
streamData.cancelableBySender ||
109+
streamData.cancelableByRecipient ||
110+
streamData.transferableBySender
111+
) {
112+
return false;
113+
}
114+
115+
if (isDynamicLock(streamData.minPrice, streamData.maxPrice, streamData.minPercentage, streamData.maxPercentage)) {
116+
return true;
117+
}
118+
119+
if (isCliffCloseToDepositedAmount(streamData)) {
120+
if (streamData.cliff !== undefined && streamData.end !== undefined) {
121+
return streamData.end - streamData.cliff <= MAX_CLIFF_END_GAP_SECONDS;
122+
}
123+
return true;
124+
}
125+
126+
return false;
111127
};
112128

113129
export const buildStreamType = (streamData: {
@@ -119,6 +135,8 @@ export const buildStreamType = (streamData: {
119135
transferableByRecipient: boolean;
120136
depositedAmount: BN;
121137
cliffAmount: BN;
138+
cliff?: number;
139+
end?: number;
122140
minPrice?: number;
123141
maxPrice?: number;
124142
minPercentage?: number;

0 commit comments

Comments
 (0)