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
4 changes: 2 additions & 2 deletions app/create/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async function fillDeposit(container: HTMLElement, amount: string) {
describe('CreatePage — zero-rate guard (issue #243)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateStream.mockResolvedValue('tx_hash_abc');
mockCreateStream.mockResolvedValue({ hash: 'tx_hash_abc', streamId: 7n });
mockRefreshStreamData.mockResolvedValue(undefined);
});

Expand Down Expand Up @@ -180,7 +180,7 @@ describe('CreatePage — SEP-41 allowance check before deposit (issue #218)', ()
beforeEach(() => {
vi.clearAllMocks();
mockIsMock.mockReturnValue(false);
mockCreateStream.mockResolvedValue('tx_hash_abc');
mockCreateStream.mockResolvedValue({ hash: 'tx_hash_abc', streamId: 7n });
mockRefreshStreamData.mockResolvedValue(undefined);
});

Expand Down
59 changes: 45 additions & 14 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { refreshStreamData } from '@/lib/queryClient';
import { getFactoryContractId } from '@/lib/env';
import { getTokenAllowanceGateway } from '@/lib/token-allowance-gateway';
import styles from './CreateStream.module.css';
import { toStroops, wouldRateTruncateToZero } from '@/lib/format';
import { isValidStellarAddress } from '@/lib/stellar-address';
import { toStroops, fromStroops, wouldRateTruncateToZero } from '@/lib/format';
import { isValidStellarPublicKey } from '@/lib/stellar-address';


const schema = z.object({
Expand Down Expand Up @@ -177,12 +177,27 @@ export default function CreatePage() {
// token's own decimals rather than assume one for all of them.
const tokenDecimals = TOKENS_TESTNET.find(t => t.symbol === token)?.decimals ?? 7;

const rate = deposit && duration
? (parseFloat(deposit) * 10 ** tokenDecimals / duration).toFixed(2)
: '—';
// #364 — mirror onSubmit's exact bigint pipeline (toStroops then truncating
// BigInt division) instead of float math. parseFloat(deposit) * 10 **
// tokenDecimals loses precision for large deposits / high-decimal tokens,
// and float division rounds where the contract call truncates, so the
// preview could show a different rate than what actually gets submitted.
const previewRateStroops = deposit && duration
? (() => {
try {
const depositStroops = toStroops(deposit, tokenDecimals);
if (depositStroops <= 0n || !Number.isFinite(duration) || duration <= 0) return null;
return depositStroops / BigInt(Math.floor(duration));
} catch {
return null;
}
})()
: null;

const rate = previewRateStroops !== null ? previewRateStroops.toString() : '—';

const ratePerDay = deposit && duration
? (parseFloat(deposit) / (duration / 86400)).toFixed(4)
const ratePerDay = previewRateStroops !== null
? fromStroops(previewRateStroops * 86400n, tokenDecimals)
: null;

// Live check, mirrors the exact bigint math onSubmit uses (see #243):
Expand All @@ -199,14 +214,18 @@ export default function CreatePage() {
setError('Connect your wallet first.');
return;
}
// Reject if the on-chain check confirmed the account does not exist.
// (A status of 'idle' or 'checking' means the address is incomplete or
// the check is still in-flight — Zod guards the shape; we only hard-block
// on a definitive not-found result.)
// #363 — block on 'not-found' AND 'checking': the debounced RPC check
// can still be in flight when the user clicks Submit, and without this
// guard the not-found check below is bypassed entirely, letting a
// stream get created for a nonexistent recipient.
if (recipientStatus === 'not-found') {
setError('Recipient account does not exist on-chain. Please check the address.');
return;
}
if (recipientStatus === 'checking') {
setError('Still verifying the recipient address — please wait a moment and try again.');
return;
}
setPending(true);
setError(null);

Expand Down Expand Up @@ -273,7 +292,7 @@ export default function CreatePage() {
setAllowanceStage(null);
}

const hash = await withTimeout(
const { hash, streamId } = await withTimeout(
createStream({
sender: publicKey,
recipient: data.recipient,
Expand All @@ -293,7 +312,13 @@ export default function CreatePage() {
await refreshStreamData();

setTxHash(hash);
setTimeout(() => router.push('/streams'), 3000);
// #362 — createStream now decodes the confirmed transaction's return
// value, so we can deep-link straight to the new stream instead of
// redirecting to /streams and hoping the user finds it. Fall back to
// /streams only if the RPC/node didn't report a return value.
setTimeout(() => {
router.push(streamId !== null ? `/stream/${streamId}` : '/streams');
}, 3000);
} catch (e) {
setError(e instanceof Error ? e.message : 'Transaction failed');
} finally {
Expand Down Expand Up @@ -471,7 +496,13 @@ export default function CreatePage() {
{/* Submit */}
<button
type="submit"
disabled={pending || !connected || rateWouldBeZero || recipientStatus === 'not-found'}
disabled={
pending ||
!connected ||
rateWouldBeZero ||
recipientStatus === 'not-found' ||
recipientStatus === 'checking'
}
className="btn-primary w-full"
>
{pending
Expand Down
11 changes: 10 additions & 1 deletion app/stream/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,16 @@ export default function StreamPage() {

const isSender = !!publicKey && publicKey === info.sender;
const isRecipient = !!publicKey && publicKey === info.recipient;
const totalDeposited = info.withdrawn + withdrawable;
// #361 — withdrawn + withdrawable is the amount streamed so far (already
// claimed plus currently claimable), not what the sender deposited: it
// excludes principal still escrowed in the DripStream contract that
// hasn't streamed yet. For fixed-duration streams the deposit is
// rate_per_second * duration; open-ended streams (endTime === 0) have no
// fixed deposit to derive this way, so fall back to the streamed-so-far
// total as the best available estimate.
const totalDeposited = info.endTime > 0
? info.ratePerSecond * BigInt(info.endTime - info.startTime)
: info.withdrawn + withdrawable;
// #318 — info.token is the SEP-41 contract address, not a display symbol;
// truncateAddress(info.token) rendered e.g. "Withdraw 42.50 CDLZ…CYSC"
// instead of "Withdraw 42.50 XLM". Resolve to a symbol where known,
Expand Down
31 changes: 27 additions & 4 deletions lib/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,12 @@ describe('streamsBySender / streamsByRecipient', () => {
});

describe('createStream', () => {
it('invokes create_stream on the factory contract with all args', async () => {
mockInvokeContract.mockResolvedValue('deadbeef');
it('invokes create_stream on the factory contract with all args and decodes the stream_id', async () => {
mockInvokeContract.mockResolvedValue({ hash: 'deadbeef', returnValue: u64(7n) });
const { createStream } = await import('./factory.js');
const signTx = vi.fn();

const hash = await createStream({
const result = await createStream({
sender: SENDER,
recipient: RECIPIENT,
token: TOKEN,
Expand All @@ -144,11 +144,34 @@ describe('createStream', () => {
clawback: false,
}, signTx);

expect(hash).toBe('deadbeef');
expect(result.hash).toBe('deadbeef');
expect(result.streamId).toBe(7n);
expect(mockInvokeContract).toHaveBeenCalledWith(
SENDER, FACTORY_ID, 'create_stream', expect.any(Array), signTx,
);
// sender, recipient, token, deposit, rate, start, end, clawback
expect(mockInvokeContract.mock.calls[0]?.[3]).toHaveLength(8);
});

// Regression test for #362: older nodes / RPC responses may confirm the
// transaction without reporting a return value at all.
it('resolves streamId to null when the confirmed transaction has no returnValue', async () => {
mockInvokeContract.mockResolvedValue({ hash: 'deadbeef', returnValue: undefined });
const { createStream } = await import('./factory.js');
const signTx = vi.fn();

const result = await createStream({
sender: SENDER,
recipient: RECIPIENT,
token: TOKEN,
deposit: 1_000_000n,
ratePerSec: 100n,
startTime: 1_700_000_000,
endTime: 1_700_003_600,
clawback: false,
}, signTx);

expect(result.hash).toBe('deadbeef');
expect(result.streamId).toBeNull();
});
});
32 changes: 24 additions & 8 deletions lib/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,35 @@ export interface CreateStreamArgs {
clawback: boolean;
}

export interface CreateStreamResult {
/** The create_stream transaction's hash. */
hash: string;
/**
* The newly assigned stream_id, decoded from the confirmed transaction's
* return value. `null` if the RPC/node didn't report a return value (older
* node, or the pipeline lost it) — callers should fall back to re-querying
* the factory (e.g. streamsBySender) in that case.
*/
streamId: bigint | null;
}

/**
* Create a new stream via the factory.
*
* Returns only the transaction hash — DripFactory::create_stream emits no
* event carrying the assigned stream_id (see streamFi-contracts issue #39),
* and invokeContract() doesn't currently surface the confirmed transaction's
* actual return value either. Callers needing the new stream's ID must
* re-query the factory (e.g. streamsBySender) after this resolves.
* Returns both the transaction hash and the assigned stream_id, decoded from
* DripFactory::create_stream's u64 return value on the confirmed transaction
* (see #362). `streamId` is `null` only if the confirmed transaction carried
* no return value — callers needing the new stream's ID in that edge case
* must re-query the factory (e.g. streamsBySender).
*/
export async function createStream(
args: CreateStreamArgs,
signTx: (xdr: string) => Promise<string>,
): Promise<string> {
if (isMock()) return 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6';
return invokeContract(
): Promise<CreateStreamResult> {
if (isMock()) {
return { hash: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6', streamId: null };
}
const { hash, returnValue } = await invokeContract(
args.sender,
FACTORY()!,
'create_stream',
Expand All @@ -154,4 +168,6 @@ export async function createStream(
],
signTx,
);
const streamId = returnValue ? scValToU64(returnValue) : null;
return { hash, streamId };
}
22 changes: 20 additions & 2 deletions lib/soroban-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,32 @@ describe('invokeContract', () => {
const signTx = vi.fn().mockResolvedValue('signed-envelope-b64');

const { invokeContract } = await import('./soroban.js');
const hash = await runThroughFirstPoll(() =>
const result = await runThroughFirstPoll(() =>
invokeContract(SOURCE, CONTRACT_ID, 'withdraw', [], signTx),
);

expect(hash).toBe('deadbeef');
expect(result.hash).toBe('deadbeef');
expect(signTx).toHaveBeenCalledWith('assembled-envelope-b64');
});

// Regression test for #362: the confirmed transaction's return value was
// being discarded, so callers like DripFactory::create_stream had no way
// to obtain contract-returned data (e.g. the assigned stream_id).
it('surfaces the confirmed transaction\'s returnValue alongside the hash', async () => {
mockSimulate.mockResolvedValue(simSuccess());
const returnValue = xdr.ScVal.scvU64(xdr.Uint64.fromString('42'));
mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', returnValue });
const signTx = vi.fn().mockResolvedValue('signed-envelope-b64');

const { invokeContract } = await import('./soroban.js');
const result = await runThroughFirstPoll(() =>
invokeContract(SOURCE, CONTRACT_ID, 'create_stream', [], signTx),
);

expect(result.hash).toBe('deadbeef');
expect(result.returnValue).toBe(returnValue);
});

it('throws on simulation failure without ever calling signTx', async () => {
mockSimulate.mockResolvedValue(simError('HostError: Error(Contract, #6)'));
const signTx = vi.fn();
Expand Down
36 changes: 34 additions & 2 deletions lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,18 @@ export interface InvokeContractOptions {
idempotencyKey?: string;
}

/** Result of a confirmed contract invocation. */
export interface InvokeContractResult {
/** The submitted transaction's hash. */
hash: string;
/**
* The contract function's return value, as reported by GetTransactionStatus
* on SUCCESS. `undefined` if the confirmed transaction carried no retval
* (e.g. the invoked function returns void).
*/
returnValue?: xdr.ScVal;
}

/**
* Build a contract-call transaction, simulate it to get the fee + footprint,
* assemble it, hand it to the wallet for signing, then submit and poll.
Expand All @@ -313,6 +325,7 @@ export interface InvokeContractOptions {
* @param args XDR ScVal arguments
* @param signTx Wallet sign callback from WalletContext (supports AbortSignal)
* @param options Optional abort signal, timeout, and idempotency key
* @returns Transaction hash and the confirmed transaction's return value
* @returns Transaction hash — confirmed, or (if polling could not
* reach a verdict in time) submitted-and-pending. Throws
* `TransactionRevertedError` if the contract reverted.
Expand All @@ -324,7 +337,7 @@ export async function invokeContract(
args: xdr.ScVal[],
signTx: (xdrBase64: string, signal?: AbortSignal) => Promise<string>,
options?: InvokeContractOptions,
): Promise<string> {
): Promise<InvokeContractResult> {
const signal = options?.signal;
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const idempotencyKey = options?.idempotencyKey;
Expand All @@ -333,7 +346,7 @@ export async function invokeContract(
validateTimeout(timeoutMs);

// If an idempotency key is provided, deduplicate
const operation = async (): Promise<string> => {
const operation = async (): Promise<InvokeContractResult> => {
if (signal?.aborted) throw new OperationAbortedError();

const passphrase = getNetworkPassphrase();
Expand Down Expand Up @@ -413,6 +426,25 @@ export async function invokeContract(
throw new Error('Submission returned no transaction hash');
}

if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
// #362 — surface the confirmed transaction's return value instead
// of discarding it. Contract functions like DripFactory::create_stream
// return data (the assigned stream_id) that callers otherwise have
// no way to obtain without a separate re-query.
return { hash, returnValue: status.returnValue };
}
if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
// Non-retryable — transaction executed and failed on-chain
recordFailure();
throw new Error(`Transaction failed: ${hash}`);
}
// status === 'NOT_FOUND' — keep polling
}
throw new Error(`Transaction timed out after ${MAX_POLL_ATTEMPTS}s: ${hash}`);
}, {
context: `invokeContract(${method})`,
signal,
});
return pollForConfirmation(hash, timeoutMs, signal);
};

Expand Down
10 changes: 5 additions & 5 deletions lib/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe('getStreamInfo', () => {

describe('mutating calls', () => {
it('withdraw() invokes withdraw with the i128 amount', async () => {
mockInvokeContract.mockResolvedValue('hash1');
mockInvokeContract.mockResolvedValue({ hash: 'hash1' });
const { withdraw } = await import('./stream.js');
const signTx = vi.fn();
const hash = await withdraw(SENDER, STREAM_ADDRESS, 5_000n, signTx);
Expand All @@ -206,15 +206,15 @@ describe('mutating calls', () => {
});

it('cancel() invokes cancel with no args', async () => {
mockInvokeContract.mockResolvedValue('hash2');
mockInvokeContract.mockResolvedValue({ hash: 'hash2' });
const { cancel } = await import('./stream.js');
const signTx = vi.fn();
expect(await cancel(SENDER, STREAM_ADDRESS, signTx)).toBe('hash2');
expect(mockInvokeContract).toHaveBeenCalledWith(SENDER, STREAM_ADDRESS, 'cancel', [], signTx);
});

it('pause()/resume() invoke their respective methods', async () => {
mockInvokeContract.mockResolvedValue('hash3');
mockInvokeContract.mockResolvedValue({ hash: 'hash3' });
const { pause, resume } = await import('./stream.js');
const signTx = vi.fn();
await pause(SENDER, STREAM_ADDRESS, signTx);
Expand All @@ -224,7 +224,7 @@ describe('mutating calls', () => {
});

it('topUp() invokes top_up with the i128 amount', async () => {
mockInvokeContract.mockResolvedValue('hash4');
mockInvokeContract.mockResolvedValue({ hash: 'hash4' });
const { topUp } = await import('./stream.js');
const signTx = vi.fn();
await topUp(SENDER, STREAM_ADDRESS, 25_000n, signTx);
Expand All @@ -234,7 +234,7 @@ describe('mutating calls', () => {
});

it('clawback() invokes clawback with no args', async () => {
mockInvokeContract.mockResolvedValue('hash5');
mockInvokeContract.mockResolvedValue({ hash: 'hash5' });
const { clawback } = await import('./stream.js');
const signTx = vi.fn();
expect(await clawback(SENDER, STREAM_ADDRESS, signTx)).toBe('hash5');
Expand Down
Loading
Loading