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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ const stream = await fundable.flows.getStream("42");
const withdrawable = await fundable.flows.getWithdrawableAmount("42");
```

Create NFT-backed Flow and Lockup streams through the Router:

```ts
const lockup = await fundable.router?.createLockup({
sender: "G...",
recipient: "G...",
token: { address: "C...", decimals: 7 },
totalAmount: 1_000_000_000n,
startTime: new Date(),
endTime: new Date(Date.now() + 30 * 24 * 60 * 60 * 1_000),
cancelable: true,
});
```

Comment on lines +47 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the Router contract ID to the README example configuration.

The preceding configuration only supplies contracts.flow, but this example calls fundable.router?.createLockup. Without contracts.router, the optional chain returns undefined instead of a transaction. Add a router contract ID to the setup example or make this example’s configuration self-contained.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 47 - 60, Update the README configuration example
preceding the createLockup call to include a contracts.router contract ID
alongside contracts.flow, ensuring fundable.router is initialized and the
example remains executable.

Read methods return decoded domain values. Flow write methods currently target
the Flow engine contract directly and return the Stellar SDK's
`AssembledTransaction`, preserving simulation, signing, serialization, and
Expand Down
3 changes: 3 additions & 0 deletions docs/generated-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ pnpm generate:paymaster -- /absolute/path/to/paymaster.wasm

The shared generator removes the generated `window.Buffer` mutation. All four
bindings are compiled into the SDK behind chain-neutral high-level clients.

`0.1.0-alpha.3` adds the high-level Lockup Router mapping without changing the
generated bindings or contract artifacts, so it retains this exact provenance.
6 changes: 4 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,7 @@ const transaction = await fundable.flows.deposit({
const result = await transaction.signAndSend();
```

Router-based NFT creation and sponsored Paymaster execution are not yet part
of the public SDK workflow. They remain release-roadmap items.
Router-based NFT creation, Stream NFT reads, and sponsored Paymaster execution
are available when their contract IDs are supplied in the client configuration.
See [Router, Stream NFT, and Paymaster](./router-paymaster.md) for the complete
surface.
27 changes: 23 additions & 4 deletions docs/router-paymaster.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Router, Stream NFT, and Paymaster development

These capabilities are available from `0.1.0-alpha.2`.
These capabilities are available from `0.1.0-alpha.2`. Router Lockup creation
is available from `0.1.0-alpha.3`.

Configure their contract IDs to enable the optional capability groups:

Expand All @@ -21,19 +22,37 @@ const fundable = createFundableClient({

The initial surface includes:

- `router.createFlow`, `router.withdraw`, and `router.withdrawMax`;
- `router.createFlow`, `router.createLockup`, `router.withdraw`, and
`router.withdrawMax`;
- `streamNft.ownerOf`, `streamNft.balanceOf`, `streamNft.getStreamData`, and
`streamNft.transfer`;
- `paymaster.isFeeTokenAllowed` and bounded `paymaster.forward` calls.

All write methods return Stellar `AssembledTransaction` objects. Applications
retain control over simulation, Soroban authorization, signing, and submission.

`router.createLockup` accepts chain-neutral schedule values and validates the
time range, optional cliff, unlock amounts, and granularity before simulation:

```ts
const transaction = await fundable.router?.createLockup({
sender: "G...",
recipient: "G...",
token: { address: "C...", decimals: 7 },
totalAmount: 10_000_000_000n,
startTime: 1_800_000_000n,
endTime: 1_802_592_000n,
cliffTime: 1_800_604_800n,
granularitySeconds: 3_600n,
cancelable: true,
});
```

## Testnet integration

The integration suite reads Stream NFT and Paymaster state and simulates Router
flow creation against the recorded testnet deployment without submitting a
transaction:
Flow and Lockup creation against the recorded testnet deployment without
submitting a transaction:

```bash
pnpm test:testnet
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fundable/sdk",
"version": "0.1.0-alpha.2",
"version": "0.1.0-alpha.3",
"private": false,
"description": "Multichain TypeScript SDK for Fundable Protocol; Stellar adapter included",
"license": "MIT",
Expand Down
9 changes: 6 additions & 3 deletions src/core/amounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,18 @@ export function formatUnits(value: bigint, decimals: number): string {
return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`;
}

export function toUnixSeconds(value?: bigint | Date): bigint {
export function toUnixSeconds(
value?: bigint | Date,
label = "Start time",
): bigint {
if (value === undefined) {
return 0n;
}
if (typeof value === "bigint") {
if (value < 0n) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Start time cannot be negative.",
message: `${label} cannot be negative.`,
});
}
return value;
Expand All @@ -71,7 +74,7 @@ export function toUnixSeconds(value?: bigint | Date): bigint {
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Start time must be a valid date.",
message: `${label} must be a valid date.`,
});
}
return BigInt(Math.floor(milliseconds / 1_000));
Expand Down
14 changes: 14 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export interface CreateAndDepositFlowInput extends CreateFlowInput {
amount: bigint;
}

export interface CreateLockupInput {
sender: string;
recipient: string;
token: TokenReference;
totalAmount: bigint;
startTime: bigint | Date;
endTime: bigint | Date;
cliffTime?: bigint | Date;
startUnlockAmount?: bigint;
cliffUnlockAmount?: bigint;
granularitySeconds?: bigint;
cancelable?: boolean;
}

export interface FlowAmountInput {
streamId: string | bigint;
amount: bigint;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Fundable Protocol multichain SDK. Stellar is the first implemented adapter. */
export const VERSION = "0.1.0-alpha.2";
export const VERSION = "0.1.0-alpha.3";

export * from "./client.js";
export * from "./core/index.js";
Expand Down
66 changes: 66 additions & 0 deletions src/stellar/protocol-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const generated = vi.hoisted(() => ({
createFlow: vi.fn(),
createLockup: vi.fn(),
getStreamData: vi.fn(),
forward: vi.fn(),
}));

vi.mock("../generated/router/src/index.js", () => ({
Client: class {
create_flow_stream = generated.createFlow;
create_lockup_stream = generated.createLockup;
},
}));

Expand Down Expand Up @@ -79,6 +81,70 @@ describe("Stellar protocol clients", () => {
);
});

it("routes validated Lockup creation through the Router contract", async () => {
const sender = Keypair.random().publicKey();
const recipient = Keypair.random().publicKey();
const token = contractId(2);
generated.createLockup.mockResolvedValue({ result: 10n });
const client = new StellarRouterClient({
...baseConfig,
contracts: { ...baseConfig.contracts, router: contractId(1) },
});

await client.createLockup({
sender,
recipient,
token: { address: token, decimals: 7 },
totalAmount: 100n,
startTime: 1_000n,
endTime: 2_000n,
cliffTime: 1_200n,
startUnlockAmount: 10n,
cliffUnlockAmount: 20n,
granularitySeconds: 60n,
cancelable: true,
});

expect(generated.createLockup).toHaveBeenCalledWith(
{
params: {
sender,
recipient,
token,
total_amount: 100n,
start_time: 1_000n,
end_time: 2_000n,
cliff_time: 1_200n,
start_unlock_amount: 10n,
cliff_unlock_amount: 20n,
granularity: 60n,
cancelable: true,
},
},
undefined,
);
});

it("rejects invalid Lockup schedules before contract simulation", async () => {
const sender = Keypair.random().publicKey();
const client = new StellarRouterClient({
...baseConfig,
contracts: { ...baseConfig.contracts, router: contractId(1) },
});

await expect(
client.createLockup({
sender,
recipient: Keypair.random().publicKey(),
token: { address: contractId(2), decimals: 7 },
totalAmount: 100n,
startTime: 2_000n,
endTime: 1_000n,
}),
).rejects.toThrow("End time must be later than start time");
expect(generated.createLockup).not.toHaveBeenCalled();
});

it("normalizes Stream NFT metadata", async () => {
generated.getStreamData.mockResolvedValue({ result: [0, 42n] });
const client = new StellarStreamNftClient({
Expand Down
81 changes: 79 additions & 2 deletions src/stellar/router-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { CreateFlowInput, RouterWithdrawInput } from "../core/index.js";
import { toUnixSeconds } from "../core/index.js";
import {
FUNDABLE_ERROR_CODES,
FundableError,
toUnixSeconds,
type CreateFlowInput,
type CreateLockupInput,
type RouterWithdrawInput,
} from "../core/index.js";
import { Client as GeneratedRouterClient } from "../generated/router/src/index.js";
import type { StellarFundableClientConfig, StellarMethodOptions } from "./types.js";
import type { StellarTransaction } from "./flow-client.js";
import {
assertPositive,
assertNonNegative,
assertStellarAddress,
assertTokenDecimals,
toTokenId,
Expand Down Expand Up @@ -49,6 +56,76 @@ export class StellarRouterClient {
);
}

async createLockup(
input: CreateLockupInput,
options?: StellarMethodOptions,
): Promise<StellarTransaction<bigint>> {
assertStellarAddress(input.sender, "Sender");
assertStellarAddress(input.recipient, "Recipient");
assertStellarAddress(input.token.address, "Token");
assertTokenDecimals(input.token.decimals, "Lockup token decimals");
assertPositive(input.totalAmount, "Total amount");

if (input.sender === input.recipient) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Sender and recipient must be different addresses.",
chain: "stellar",
});
}

const startTime = toUnixSeconds(input.startTime, "Start time");
const endTime = toUnixSeconds(input.endTime, "End time");
const cliffTime = toUnixSeconds(input.cliffTime, "Cliff time");
const startUnlockAmount = input.startUnlockAmount ?? 0n;
const cliffUnlockAmount = input.cliffUnlockAmount ?? 0n;
const granularity = input.granularitySeconds ?? 1n;

if (endTime <= startTime) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "End time must be later than start time.",
chain: "stellar",
});
}
if (cliffTime > 0n && (cliffTime <= startTime || cliffTime >= endTime)) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Cliff time must be later than start time and earlier than end time.",
chain: "stellar",
});
}
assertNonNegative(startUnlockAmount, "Start unlock amount");
assertNonNegative(cliffUnlockAmount, "Cliff unlock amount");
assertPositive(granularity, "Granularity");
if (startUnlockAmount + cliffUnlockAmount > input.totalAmount) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Start and cliff unlock amounts cannot exceed the total amount.",
chain: "stellar",
});
}

return this.client.create_lockup_stream(
{
params: {
sender: input.sender,
recipient: input.recipient,
token: input.token.address,
total_amount: input.totalAmount,
start_time: startTime,
end_time: endTime,
cliff_time: cliffTime,
start_unlock_amount: startUnlockAmount,
cliff_unlock_amount: cliffUnlockAmount,
granularity,
cancelable: input.cancelable ?? false,
},
},
options,
);
}

async withdraw(
input: RouterWithdrawInput,
options?: StellarMethodOptions,
Expand Down
18 changes: 18 additions & 0 deletions src/stellar/testnet.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,22 @@ describe.runIf(runIntegration)("Fundable tagged testnet deployment", () => {

expect(transaction?.result).toEqual(expect.any(BigInt));
});

it("simulates Router Lockup creation without submitting a transaction", async () => {
const startTime = BigInt(Math.floor(Date.now() / 1_000) + 30);
const transaction = await client.router?.createLockup({
sender: deployment.admin,
recipient: Keypair.random().publicKey(),
token: {
address: Asset.native().contractId(Networks.TESTNET),
decimals: 7,
},
totalAmount: 1n,
startTime,
endTime: startTime + 3_600n,
granularitySeconds: 1n,
});

expect(transaction?.result).toEqual(expect.any(BigInt));
});
});
10 changes: 10 additions & 0 deletions src/stellar/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ export function assertPositive(value: bigint, label: string): void {
}
}

export function assertNonNegative(value: bigint, label: string): void {
if (value < 0n) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: `${label} cannot be negative.`,
chain: "stellar",
});
}
}

export function assertTokenDecimals(value: number, label = "Token decimals"): void {
if (!Number.isInteger(value) || value < 0 || value > 18) {
throw new FundableError({
Expand Down
Loading