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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Detailed guides live in [`docs/`](./docs):
- [Getting started](./docs/getting-started.md)
- [Multichain architecture](./docs/multichain-architecture.md)
- [Generated binding provenance](./docs/generated-bindings.md)
- [Router, Stream NFT, and Paymaster development](./docs/router-paymaster.md)

## Install

Expand Down Expand Up @@ -61,10 +62,11 @@ const sent = await transaction.signAndSend();
console.log(sent.result);
```

Fundable's NFT-backed creation workflow goes through the Router contract. That
orchestration and sponsored Paymaster execution remain application-owned in
`0.1.0` and will move into dedicated SDK capabilities next; consumers should
not use the engine-level `create` method when an NFT receipt is required.
Fundable's NFT-backed creation workflow goes through the Router contract.
Router, Stream NFT, and Paymaster capability groups are under development for
the next prerelease; see the development guide for the current surface and
release gates. Consumers of `0.1.0-alpha.1` should not use the engine-level
`create` method when an NFT receipt is required.

## Multichain boundary

Expand Down
38 changes: 38 additions & 0 deletions docs/router-paymaster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Router, Stream NFT, and Paymaster development

These capabilities are implemented on the `feat/router-nft-paymaster`
development branch and are not part of `0.1.0-alpha.1`.

Configure their contract IDs to enable the optional capability groups:

```ts
const fundable = createFundableClient({
chain: "stellar",
network: "testnet",
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
contracts: {
flow: "C...",
router: "C...",
streamNft: "C...",
paymaster: "C...",
},
});
```

The initial surface includes:

- `router.createFlow`, `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.

## Release gates

Before publishing these capabilities, regenerate all three bindings from clean,
tagged contract artifacts and record their commit, WASM hash, Stellar CLI
version, and exact generation commands. Add integration coverage against a
deployed testnet set before promoting the next prerelease.
38 changes: 38 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@ export interface AdjustFlowRateInput extends FlowActorInput {
ratePerSecond: bigint;
}

export const STREAM_KINDS = {
FLOW: "flow",
LOCKUP: "lockup",
} as const;

export type StreamKind = (typeof STREAM_KINDS)[keyof typeof STREAM_KINDS];

export interface RouterWithdrawInput {
tokenId: string | bigint;
caller: string;
to: string;
amount: bigint;
}

export interface StreamNftRecord {
tokenId: string;
streamId: string;
streamKind: StreamKind;
}

export interface TransferStreamNftInput {
tokenId: string | bigint;
from: string;
to: string;
}

export interface PaymasterForwardInput {
user: string;
feeToken: string;
feeAmount: bigint;
maxFeeAmount: bigint;
expirationLedger: number;
feeRecipient: string;
targetContract: string;
functionName: string;
args: readonly unknown[];
}

export interface FundableFlowClient<TTransaction> {
create(input: CreateFlowInput): Promise<TTransaction>;
createAndDeposit(input: CreateAndDepositFlowInput): Promise<TTransaction>;
Expand Down
14 changes: 1 addition & 13 deletions src/generated/paymaster/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,6 @@ export * from "@stellar/stellar-sdk";
export * as contract from "@stellar/stellar-sdk/contract";
export * as rpc from "@stellar/stellar-sdk/rpc";

if (typeof window !== "undefined") {
//@ts-ignore Buffer exists
window.Buffer = window.Buffer || Buffer;
}








export interface Client {
/**
* Construct and simulate a sweep transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object.
Expand Down Expand Up @@ -183,4 +171,4 @@ export class Client extends ContractClient {
is_fee_token_allowed: this.txFromJSON<boolean>,
collect_fee_and_invoke: this.txFromJSON<any>
}
}
}
11 changes: 1 addition & 10 deletions src/generated/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ export * from "@stellar/stellar-sdk";
export * as contract from "@stellar/stellar-sdk/contract";
export * as rpc from "@stellar/stellar-sdk/rpc";

if (typeof window !== "undefined") {
//@ts-ignore Buffer exists
window.Buffer = window.Buffer || Buffer;
}





/**
* Core data structure for a Flow (open-ended, rate-per-second) stream.
*
Expand Down Expand Up @@ -567,4 +558,4 @@ export class Client extends ContractClient {
create_flow_stream: this.txFromJSON<i128>,
create_lockup_stream: this.txFromJSON<i128>
}
}
}
11 changes: 1 addition & 10 deletions src/generated/stream_nft/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ export * from "@stellar/stellar-sdk";
export * as contract from "@stellar/stellar-sdk/contract";
export * as rpc from "@stellar/stellar-sdk/rpc";

if (typeof window !== "undefined") {
//@ts-ignore Buffer exists
window.Buffer = window.Buffer || Buffer;
}





/**
* Core data structure for a Flow (open-ended, rate-per-second) stream.
*
Expand Down Expand Up @@ -593,4 +584,4 @@ export class Client extends ContractClient {
initialize: this.txFromJSON<null>,
get_stream_data: this.txFromJSON<readonly [StreamType, u64]>
}
}
}
19 changes: 19 additions & 0 deletions src/stellar/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,23 @@ describe("createFundableClient", () => {
}),
).toThrow(FundableError);
});

it("enables optional Router, Stream NFT, and Paymaster capabilities", () => {
const client = createFundableClient({
chain: "stellar",
network: "testnet",
rpcUrl: "https://rpc.example.com",
networkPassphrase: "Test SDF Network ; September 2015",
contracts: {
flow: contractId(),
router: contractId(),
streamNft: contractId(),
paymaster: contractId(),
},
});

expect(client.router).toBeDefined();
expect(client.streamNft).toBeDefined();
expect(client.paymaster).toBeDefined();
});
});
28 changes: 28 additions & 0 deletions src/stellar/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { FUNDABLE_ERROR_CODES, FundableError } from "../core/index.js";
import { StellarFlowClient } from "./flow-client.js";
import { StellarPaymasterClient } from "./paymaster-client.js";
import { StellarRouterClient } from "./router-client.js";
import { StellarStreamNftClient } from "./stream-nft-client.js";
import type { StellarFundableClientConfig } from "./types.js";
import { assertContractId } from "./validation.js";

export class StellarFundableClient {
readonly chain = "stellar" as const;
readonly flows: StellarFlowClient;
readonly router?: StellarRouterClient;
readonly streamNft?: StellarStreamNftClient;
readonly paymaster?: StellarPaymasterClient;

constructor(readonly config: StellarFundableClientConfig) {
if (!config.rpcUrl || !config.networkPassphrase) {
Expand All @@ -17,5 +23,27 @@ export class StellarFundableClient {
}
assertContractId(config.contracts.flow, "Flow contract");
this.flows = new StellarFlowClient(config);

if (config.contracts.router) {
assertContractId(config.contracts.router, "Router contract");
this.router = new StellarRouterClient({
...config,
contracts: { ...config.contracts, router: config.contracts.router },
});
}
if (config.contracts.streamNft) {
assertContractId(config.contracts.streamNft, "Stream NFT contract");
this.streamNft = new StellarStreamNftClient({
...config,
contracts: { ...config.contracts, streamNft: config.contracts.streamNft },
});
}
if (config.contracts.paymaster) {
assertContractId(config.contracts.paymaster, "Paymaster contract");
this.paymaster = new StellarPaymasterClient({
...config,
contracts: { ...config.contracts, paymaster: config.contracts.paymaster },
});
}
}
}
17 changes: 7 additions & 10 deletions src/stellar/flow-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
import {
CHAIN_FAMILIES,
FUNDABLE_ERROR_CODES,
FLOW_STATUSES,
FundableError,
toFundableError,
toUnixSeconds,
type AdjustFlowRateInput,
Expand All @@ -22,7 +20,12 @@ import {
type FlowStream as GeneratedFlowStream,
} from "../generated/flow/src/index.js";
import type { StellarFundableClientConfig, StellarMethodOptions } from "./types.js";
import { assertPositive, assertStellarAddress, toStreamId } from "./validation.js";
import {
assertPositive,
assertStellarAddress,
assertTokenDecimals,
toStreamId,
} from "./validation.js";

export type StellarTransaction<TResult> = AssembledTransaction<TResult>;

Expand Down Expand Up @@ -291,13 +294,7 @@ export class StellarFlowClient {
assertStellarAddress(input.recipient, "Recipient");
assertStellarAddress(input.token.address, "Token");
assertPositive(input.ratePerSecond, "Rate per second");
if (!Number.isInteger(input.token.decimals) || input.token.decimals < 0 || input.token.decimals > 18) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Flow token decimals must be an integer between 0 and 18.",
chain: "stellar",
});
}
assertTokenDecimals(input.token.decimals, "Flow token decimals");
}

private async readAmount(
Expand Down
3 changes: 3 additions & 0 deletions src/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from "./client.js";
export * from "./flow-client.js";
export * from "./paymaster-client.js";
export * from "./router-client.js";
export * from "./stream-nft-client.js";
export * from "./types.js";
88 changes: 88 additions & 0 deletions src/stellar/paymaster-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
FUNDABLE_ERROR_CODES,
FundableError,
toFundableError,
type PaymasterForwardInput,
} from "../core/index.js";
import { Client as GeneratedPaymasterClient } from "../generated/paymaster/src/index.js";
import type { StellarFundableClientConfig, StellarMethodOptions } from "./types.js";
import type { StellarTransaction } from "./flow-client.js";
import { assertPositive, assertStellarAddress } from "./validation.js";

export class StellarPaymasterClient {
private readonly client: GeneratedPaymasterClient;

constructor(config: StellarFundableClientConfig & { contracts: { paymaster: string } }) {
this.client = new GeneratedPaymasterClient({
contractId: config.contracts.paymaster,
networkPassphrase: config.networkPassphrase,
rpcUrl: config.rpcUrl,
publicKey: config.publicKey,
allowHttp: config.allowHttp,
headers: config.headers,
signTransaction: config.signTransaction,
signAuthEntry: config.signAuthEntry,
});
}

async isFeeTokenAllowed(token: string): Promise<boolean> {
assertStellarAddress(token, "Fee token");
try {
const transaction = await this.client.is_fee_token_allowed({ token });
return transaction.result;
} catch (error) {
throw toFundableError(error, "Failed to check the Paymaster fee token.", "stellar");
}
}

async forward(
input: PaymasterForwardInput,
options?: StellarMethodOptions,
): Promise<StellarTransaction<unknown>> {
this.validateForwardInput(input);
return this.client.forward(
{
user: input.user,
fee_token: input.feeToken,
fee_amount: input.feeAmount,
max_fee_amount: input.maxFeeAmount,
expiration_ledger: input.expirationLedger,
fee_recipient: input.feeRecipient,
target_contract: input.targetContract,
function_name: input.functionName,
args: [...input.args],
},
options,
);
}

private validateForwardInput(input: PaymasterForwardInput): void {
assertStellarAddress(input.user, "Paymaster user");
assertStellarAddress(input.feeToken, "Fee token");
assertStellarAddress(input.feeRecipient, "Fee recipient");
assertStellarAddress(input.targetContract, "Target contract");
assertPositive(input.feeAmount, "Fee amount");
assertPositive(input.maxFeeAmount, "Maximum fee amount");
if (input.feeAmount > input.maxFeeAmount) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Fee amount cannot exceed the authorized maximum fee amount.",
chain: "stellar",
});
}
if (!Number.isInteger(input.expirationLedger) || input.expirationLedger < 0) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Expiration ledger must be a non-negative integer.",
chain: "stellar",
});
}
if (!input.functionName.trim()) {
throw new FundableError({
code: FUNDABLE_ERROR_CODES.INVALID_ARGUMENT,
message: "Target function name is required.",
chain: "stellar",
});
}
}
}
Loading
Loading