Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions lerna.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
{
"packages": [
"packages/*"
],
"version": "11.2.0",
"packages": ["packages/*"],
"version": "11.2.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"command": {
"run": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/common",
"version": "11.2.0",
"version": "11.2.1",
"description": "Common utilities and types used by streamflow packages.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/distributor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/distributor",
"version": "11.2.0",
"version": "11.2.1",
"description": "JavaScript SDK to interact with Streamflow Airdrop protocol.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"main": "./dist/cjs/index.cjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/eslint-config",
"version": "11.2.0",
"version": "11.2.1",
"description": "ESLint configuration for Streamflow protocol.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/launchpad/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/launchpad",
"version": "11.2.0",
"version": "11.2.1",
"description": "JavaScript SDK to interact with Streamflow Launchpad protocol.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"main": "./dist/cjs/index.cjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/staking/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/staking",
"version": "11.2.0",
"version": "11.2.1",
"description": "JavaScript SDK to interact with Streamflow Staking protocol.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"main": "./dist/cjs/index.cjs",
Expand Down
195 changes: 195 additions & 0 deletions packages/stream/__tests__/solana/contractUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import BN from "bn.js";
import { describe, expect, test } from "vitest";

import { buildStreamType, isTokenLock } from "../../solana/contractUtils.js";
import { StreamType } from "../../solana/types.js";

const baseLockPermissions = {
canTopup: false,
automaticWithdrawal: false,
cancelableBySender: false,
cancelableByRecipient: false,
transferableBySender: false,
transferableByRecipient: false,
};

const baseVestingPermissions = {
canTopup: true,
automaticWithdrawal: false,
cancelableBySender: true,
cancelableByRecipient: false,
transferableBySender: false,
transferableByRecipient: false,
};

describe("isTokenLock", () => {
test("legitimate time-based lock (cliff close to end) returns true", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000001,
});
expect(result).toBe(true);
});

test("exploit lock (cliff far before end) returns false", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(1_000_000),
cliff: 1700000000,
end: 1700000000 + 86400 * 30,
});
expect(result).toBe(false);
});

test("exploit lock with small gap (2s) returns false", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000002,
});
expect(result).toBe(false);
});

test("lock at exact threshold boundary (1s gap) returns true", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000001,
});
expect(result).toBe(true);
});

test("lock with zero gap (cliff equals end) returns true", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000000,
});
expect(result).toBe(true);
});

test("vesting contract (cliff amount << deposited) returns false", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(100_000),
cliff: 1700000000,
end: 1700086400,
});
expect(result).toBe(false);
});

test("dynamic lock (isDynamicLock path) returns true regardless of cliff/end gap", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(100_000),
cliff: 1700000000,
end: 1700000000 + 86400 * 365,
minPrice: 1,
maxPrice: 1.5,
minPercentage: 0,
maxPercentage: 100,
});
expect(result).toBe(true);
});

test("returns false when any permission is enabled", () => {
const result = isTokenLock({
...baseLockPermissions,
canTopup: true,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000001,
});
expect(result).toBe(false);
});

test("backward compat: no cliff/end provided, cliff amount close to deposited returns true", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
});
expect(result).toBe(true);
});

test("backward compat: no cliff/end provided, cliff amount far from deposited returns false", () => {
const result = isTokenLock({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(100_000),
});
expect(result).toBe(false);
});
});

describe("buildStreamType", () => {
test("legitimate lock returns StreamType.Lock", () => {
const result = buildStreamType({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
cliff: 1700000000,
end: 1700000001,
});
expect(result).toBe(StreamType.Lock);
});

test("exploit lock returns StreamType.Vesting", () => {
const result = buildStreamType({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(1_000_000),
cliff: 1700000000,
end: 1700000000 + 86400 * 30,
});
expect(result).toBe(StreamType.Vesting);
});

test("vesting contract returns StreamType.Vesting", () => {
const result = buildStreamType({
...baseVestingPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(100_000),
cliff: 1700000000,
end: 1700086400,
});
expect(result).toBe(StreamType.Vesting);
});

test("dynamic lock returns StreamType.Lock", () => {
const result = buildStreamType({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(100_000),
cliff: 1700000000,
end: 1700000000 + 86400 * 365,
minPrice: 1,
maxPrice: 1.5,
minPercentage: 0,
maxPercentage: 100,
});
expect(result).toBe(StreamType.Lock);
});

test("backward compat: no cliff/end, legitimate lock returns StreamType.Lock", () => {
const result = buildStreamType({
...baseLockPermissions,
depositedAmount: new BN(1_000_000),
cliffAmount: new BN(999_999),
});
expect(result).toBe(StreamType.Lock);
});
});
2 changes: 1 addition & 1 deletion packages/stream/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamflow/stream",
"version": "11.2.0",
"version": "11.2.1",
"description": "JavaScript SDK to interact with Streamflow protocol.",
"homepage": "https://github.com/streamflow-finance/js-sdk/",
"main": "./dist/cjs/index.cjs",
Expand Down
36 changes: 27 additions & 9 deletions packages/stream/solana/contractUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "./types.js";

const MAX_SAFE_UNIX_TIME_VALUE = 8640000000000;
const MAX_CLIFF_END_GAP_SECONDS = 1;

interface ICalculateUnlockedAmount {
depositedAmount: BN;
Expand Down Expand Up @@ -94,20 +95,35 @@ export const isTokenLock = (streamData: {
transferableByRecipient: boolean;
depositedAmount: BN;
cliffAmount: BN;
cliff?: number;
end?: number;
minPrice?: number;
maxPrice?: number;
minPercentage?: number;
maxPercentage?: number;
}): boolean => {
return (
!streamData.canTopup &&
!streamData.automaticWithdrawal &&
!streamData.cancelableBySender &&
!streamData.cancelableByRecipient &&
!streamData.transferableBySender &&
(isCliffCloseToDepositedAmount(streamData) ||
isDynamicLock(streamData.minPrice, streamData.maxPrice, streamData.minPercentage, streamData.maxPercentage))
);
if (
streamData.canTopup ||
streamData.automaticWithdrawal ||
streamData.cancelableBySender ||
streamData.cancelableByRecipient ||
streamData.transferableBySender
) {
return false;
}

if (isDynamicLock(streamData.minPrice, streamData.maxPrice, streamData.minPercentage, streamData.maxPercentage)) {
return true;
}

if (isCliffCloseToDepositedAmount(streamData)) {
if (streamData.cliff !== undefined && streamData.end !== undefined) {
return streamData.end - streamData.cliff <= MAX_CLIFF_END_GAP_SECONDS;
}
return true;
}

return false;
};

export const buildStreamType = (streamData: {
Expand All @@ -119,6 +135,8 @@ export const buildStreamType = (streamData: {
transferableByRecipient: boolean;
depositedAmount: BN;
cliffAmount: BN;
cliff?: number;
end?: number;
minPrice?: number;
maxPrice?: number;
minPercentage?: number;
Expand Down
Loading