Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/web/src/hooks/use-distribution-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
*/
async function accountExists(address: string): Promise<boolean> {
try {
const horizon = new Horizon.Server(HORIZON_URL, { allowHttp: true });
const horizon = new Horizon.Server(HORIZON_URL);
await withTimeout(horizon.loadAccount(address), ACCOUNT_CHECK_TIMEOUT_MS, address);
return true;
} catch (err) {
Expand All @@ -55,7 +55,7 @@ async function checkSenderBalance(
requiredAmount: bigint
): Promise<{ ok: boolean; reason?: string }> {
try {
const horizon = new Horizon.Server(HORIZON_URL, { allowHttp: true });
const horizon = new Horizon.Server(HORIZON_URL);
const account = await horizon.loadAccount(senderAddress);

if (tokenAddress === 'native') {
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/services/contract.deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,29 @@ interface DeployConfig {
networkPassphrase: string;
}

const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);

function allowLocalHttp(url: string): boolean {
if (process.env.NODE_ENV === 'production') {
return false;
}

try {
const parsed = new URL(url);
return parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}

export class ContractDeployer {
private rpcServer: RpcServer;
private networkPassphrase: string;

constructor(config: DeployConfig) {
this.rpcServer = new RpcServer(config.rpcUrl, { allowHttp: true });
this.rpcServer = new RpcServer(config.rpcUrl, {
allowHttp: allowLocalHttp(config.rpcUrl),
});
this.networkPassphrase = config.networkPassphrase;
}

Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/services/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ const DEFAULT_TIMEOUT = 30; // seconds
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BASE_FEE = '100'; // stroops

const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);

function allowLocalHttp(url: string): boolean {
if (process.env.NODE_ENV === 'production') {
return false;
}

try {
const parsed = new URL(url);
return parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand All @@ -80,8 +95,12 @@ export class StellarService {
private readonly maxRetries: number;

constructor(config: StellarServiceConfig) {
this.rpcServer = new RpcServer(config.network.rpcUrl, { allowHttp: true });
this.horizonServer = new Horizon.Server(config.network.horizonUrl, { allowHttp: true });
this.rpcServer = new RpcServer(config.network.rpcUrl, {
allowHttp: allowLocalHttp(config.network.rpcUrl),
});
this.horizonServer = new Horizon.Server(config.network.horizonUrl, {
allowHttp: allowLocalHttp(config.network.horizonUrl),
});
this.networkPassphrase = config.network.networkPassphrase;
this.paymentStreamContractId = config.contracts.paymentStream;
this.distributorContractId = config.contracts.distributor;
Expand Down
20 changes: 19 additions & 1 deletion packages/sdk/DEVELOPMENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ pnpm build

This runs `tsc` to compile TypeScript to the `dist/` directory.

### Local RPC over HTTP

Production and public testnet/mainnet RPC URLs must use HTTPS. Plain HTTP is
only accepted for local loopback development endpoints, and the SDK requires an
explicit opt-in:

```typescript
const deployer = new ContractDeployer({
rpcUrl: 'http://localhost:8000',
networkPassphrase: 'Standalone Network ; February 2017',
allowHttp: true,
});
```

Non-loopback HTTP URLs are rejected even when `allowHttp` is set. This keeps
signed transactions and authentication payloads from being sent over cleartext
connections outside local development.

### 3. Watch Mode (Development)

For incremental builds during development:
Expand Down Expand Up @@ -222,4 +240,4 @@ pnpm test

- [README.md](packages/sdk/README.md) — SDK usage and API reference
- [STYLE_GUIDE.md](../../STYLE_GUIDE.md) — General coding standards
- [TESTING_GUIDE.md](../../TESTING_GUIDE.md) — Testing best practices
- [TESTING_GUIDE.md](../../TESTING_GUIDE.md) — Testing best practices
32 changes: 32 additions & 0 deletions packages/sdk/src/__tests__/ContractDeployer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,38 @@ describe('ContractDeployer', () => {
});
});

describe('RPC URL safety', () => {
it('rejects non-loopback HTTP RPC URLs by default', () => {
expect(
() =>
new ContractDeployer({
rpcUrl: 'http://example.com',
networkPassphrase: 'Test SDF Network ; September 2015',
})
).toThrow(DeployerError);
});

it('requires explicit opt-in for local HTTP RPC URLs', () => {
expect(
() =>
new ContractDeployer({
rpcUrl: 'http://localhost:8000',
networkPassphrase: 'Test SDF Network ; September 2015',
})
).toThrow(DeployerError);
});

it('allows explicitly opted-in loopback HTTP RPC URLs for local development', () => {
const localDeployer = new ContractDeployer({
rpcUrl: 'http://127.0.0.1:8000',
networkPassphrase: 'Standalone Network ; February 2017',
allowHttp: true,
});

expect(localDeployer).toBeInstanceOf(ContractDeployer);
});
});

// ── WASM validation ────────────────────────────────────────────────────────
describe('WASM validation', () => {
it('rejects empty WASM buffer', async () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/sdk/src/deployer/ContractDeployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
FeeEstimationError,
DeploymentTimeoutError,
} from './errors';
import { shouldAllowLocalHttp } from '../utils/httpSecurity.js';

const DEFAULT_BASE_FEE = '100';
const DEFAULT_TIMEOUT = 60;
Expand Down Expand Up @@ -70,12 +71,26 @@ export class ContractDeployer {
private passphrasePromise: Promise<string> | undefined;

constructor(config: DeployerConfig) {
this.rpc = new Server(config.rpcUrl, { allowHttp: true });
this.rpc = new Server(config.rpcUrl, {
allowHttp: this.getAllowHttp(config.rpcUrl, config.allowHttp),
});
this.networkPassphrase = config.networkPassphrase;
this.baseFee = config.baseFee ?? DEFAULT_BASE_FEE;
this.timeoutSeconds = config.timeoutSeconds ?? DEFAULT_TIMEOUT;
}

private getAllowHttp(rpcUrl: string, allowHttp?: boolean): boolean {
try {
return shouldAllowLocalHttp(rpcUrl, allowHttp);
} catch (error) {
throw new DeployerError(
(error as Error).message,
'UNSAFE_HTTP_RPC_URL',
error as Error
);
}
}

// ─── Async factory ─────────────────────────────────────────────────────────

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/deployer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export interface DeployerConfig {
* - Local: `http://localhost:8000`
*/
rpcUrl: string;
/**
* Allow plain-HTTP RPC URLs for local development only.
*
* This is intentionally off by default and only works with loopback hosts
* such as `localhost`, `127.0.0.1`, or `[::1]`.
*/
allowHttp?: boolean;
/**
* Network passphrase for transaction signing.
*
Expand Down
31 changes: 19 additions & 12 deletions packages/sdk/src/utils/GasEstimator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { xdr } from "@stellar/stellar-sdk";
import { Server, Api } from "@stellar/stellar-sdk/rpc";
import { shouldAllowLocalHttp } from "./httpSecurity.js";

const DEFAULT_BASE_FEE = "100";
const DEFAULT_RESOURCE_BUFFER = 1.2;
Expand All @@ -20,6 +21,8 @@ export interface GasEstimatorOptions {
rpc?: GasEstimatorRpc;
/** Soroban RPC endpoint URL. */
rpcUrl?: string;
/** Allow plain-HTTP RPC URLs for local loopback development. */
allowHttp?: boolean;
/** Base Stellar inclusion fee in stroops. Defaults to 100. */
baseFee?: string;
/** Multiplier applied to simulated Soroban resource limits. Defaults to 1.2. */
Expand Down Expand Up @@ -73,17 +76,6 @@ export interface GasEstimate {
feeStats?: unknown;
}

/**
* Estimates Soroban transaction fees and resource limits from simulation data
* plus current RPC fee statistics when available.
*/
export class GasEstimator {
private readonly rpc: GasEstimatorRpc;
private readonly baseFee: string;
private readonly resourceBuffer: number;
private readonly congestionBuffer: number;
private readonly highCongestionBuffer: number;

function assertStroopString(value: string, name: string): string {
if (!/^\d+$/.test(value)) {
throw new Error(`${name} must be a non-negative integer string in stroops`);
Expand All @@ -98,12 +90,27 @@ function assertFinitePositiveNumber(value: number, name: string): number {
return value;
}

/**
* Estimates Soroban transaction fees and resource limits from simulation data
* plus current RPC fee statistics when available.
*/
export class GasEstimator {
private readonly rpc: GasEstimatorRpc;
private readonly baseFee: string;
private readonly resourceBuffer: number;
private readonly congestionBuffer: number;
private readonly highCongestionBuffer: number;

constructor(options: GasEstimatorOptions) {
if (!options.rpc && !options.rpcUrl) {
throw new Error("GasEstimator requires either rpc or rpcUrl");
}

this.rpc = options.rpc ?? new Server(options.rpcUrl!, { allowHttp: true });
this.rpc =
options.rpc ??
new Server(options.rpcUrl!, {
allowHttp: shouldAllowLocalHttp(options.rpcUrl!, options.allowHttp),
});
this.baseFee = assertStroopString(
options.baseFee ?? DEFAULT_BASE_FEE,
"baseFee"
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/src/utils/httpSecurity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);

export function shouldAllowLocalHttp(url: string, allowHttp = false): boolean {
let parsed: URL;

try {
parsed = new URL(url);
} catch {
return false;
}

if (parsed.protocol !== 'http:') {
return false;
}

if (!allowHttp || !LOOPBACK_HOSTS.has(parsed.hostname)) {
throw new Error(
'HTTP Stellar RPC URLs are only allowed for loopback development endpoints when allowHttp is set to true'
);
}

return true;
}