diff --git a/README.md b/README.md index 2ca028a..e1d793b 100644 --- a/README.md +++ b/README.md @@ -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, +}); +``` + 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 diff --git a/docs/generated-bindings.md b/docs/generated-bindings.md index 4756d8b..9642f41 100644 --- a/docs/generated-bindings.md +++ b/docs/generated-bindings.md @@ -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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6f8dcd4..be06564 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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. diff --git a/docs/router-paymaster.md b/docs/router-paymaster.md index 71d0d8b..78c3453 100644 --- a/docs/router-paymaster.md +++ b/docs/router-paymaster.md @@ -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: @@ -21,7 +22,8 @@ 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. @@ -29,11 +31,28 @@ The initial surface includes: 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 diff --git a/package.json b/package.json index 2dbe856..6b5528c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/amounts.ts b/src/core/amounts.ts index 17e7417..a4f16c8 100644 --- a/src/core/amounts.ts +++ b/src/core/amounts.ts @@ -53,7 +53,10 @@ 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; } @@ -61,7 +64,7 @@ export function toUnixSeconds(value?: bigint | Date): 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; @@ -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)); diff --git a/src/core/types.ts b/src/core/types.ts index dc7ad1c..0cf56d1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 9e546e2..fdfabf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/stellar/protocol-clients.test.ts b/src/stellar/protocol-clients.test.ts index e687e40..40fb242 100644 --- a/src/stellar/protocol-clients.test.ts +++ b/src/stellar/protocol-clients.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const generated = vi.hoisted(() => ({ createFlow: vi.fn(), + createLockup: vi.fn(), getStreamData: vi.fn(), forward: vi.fn(), })); @@ -11,6 +12,7 @@ const generated = vi.hoisted(() => ({ vi.mock("../generated/router/src/index.js", () => ({ Client: class { create_flow_stream = generated.createFlow; + create_lockup_stream = generated.createLockup; }, })); @@ -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({ diff --git a/src/stellar/router-client.ts b/src/stellar/router-client.ts index 9ffde33..55231eb 100644 --- a/src/stellar/router-client.ts +++ b/src/stellar/router-client.ts @@ -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, @@ -49,6 +56,76 @@ export class StellarRouterClient { ); } + async createLockup( + input: CreateLockupInput, + options?: StellarMethodOptions, + ): Promise> { + 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, diff --git a/src/stellar/testnet.integration.test.ts b/src/stellar/testnet.integration.test.ts index b28ca66..c641b7d 100644 --- a/src/stellar/testnet.integration.test.ts +++ b/src/stellar/testnet.integration.test.ts @@ -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)); + }); }); diff --git a/src/stellar/validation.ts b/src/stellar/validation.ts index dfdab97..2efcca7 100644 --- a/src/stellar/validation.ts +++ b/src/stellar/validation.ts @@ -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({