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
6 changes: 6 additions & 0 deletions src/lib/flows/edit-stream-flow/edit-stream-flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface EditStreamFlowState {
newAmountValue: string | undefined;
newName: string | undefined;
newSelectedMultiplier: string;
addEndDate: boolean;
endDateValue: string | undefined;
endTimeValue: string | undefined;
}

export default (stream: EditStreamFlowStreamFragment) => {
Expand All @@ -23,5 +26,8 @@ export default (stream: EditStreamFlowStreamFragment) => {
),
newName: stream.name ?? undefined,
newSelectedMultiplier: '1',
addEndDate: false,
endDateValue: undefined,
endTimeValue: undefined,
});
};
78 changes: 72 additions & 6 deletions src/lib/flows/edit-stream-flow/enter-new-details.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
id
name
isPaused
createdAt
config {
dripId
startDate
Expand Down Expand Up @@ -49,6 +50,10 @@
import SafeAppDisclaimer from '$lib/components/safe-app-disclaimer/safe-app-disclaimer.svelte';
import type { EditStreamFlowState } from './edit-stream-flow-state';
import Wallet from '$lib/components/icons/Wallet.svelte';
import Toggleable from '$lib/components/toggleable/toggleable.svelte';
import parseDate from '$lib/flows/create-stream-flow/methods/parse-date';
import parseTime from '$lib/flows/create-stream-flow/methods/parse-time';
import combineDateAndTime from '$lib/flows/create-stream-flow/methods/combine-date-and-time';
import type { EditStreamFlowStreamFragment } from './__generated__/gql.generated';
import { buildEditStreamBatch } from '$lib/utils/streams/streams';
import assert from '$lib/utils/assert';
Expand All @@ -72,6 +77,8 @@

let amountLocked = $derived(stream.isPaused === true);

let actualStartDate = $derived(new Date(stream.config.startDate ?? stream.createdAt));

let newAmountValueParsed = $derived(
$context.newAmountValue
? parseTokenAmount(
Expand All @@ -89,15 +96,49 @@

let amountValidationState = $derived(validateAmtPerSecInput(newAmountPerSecond));

let nameUpdated = $derived($context.newName !== stream.name);
let nameUpdated = $derived(($context.newName ?? null) !== (stream.name ?? null));
let amountUpdated = $derived(
newAmountPerSecond?.toString() !== stream.config.amountPerSecond.amount.toString(),
);

// End date handling
let canAddEndDate = $derived(
!stream.isPaused && (!stream.config.durationSeconds || stream.config.durationSeconds === 0),
);

let endDateParsed = $derived(parseDate($context.endDateValue));
let endTimeParsed = $derived(parseTime($context.endTimeValue));

let combinedEndDate = $derived.by(() => {
if (!endDateParsed.date || !endTimeParsed.time) return undefined;
return combineDateAndTime(endDateParsed.date, endTimeParsed.time);
});

let endDateInFuture = $derived.by(() => {
if (!combinedEndDate) return undefined;
return combinedEndDate.getTime() > Date.now();
});

let endDateValidationState = $derived.by(() => {
if (!$context.addEndDate) return { type: 'unvalidated' as const };
if (endDateParsed.validationState.type !== 'valid') return endDateParsed.validationState;
if (endTimeParsed.validationState.type !== 'valid') return endTimeParsed.validationState;
if (endDateInFuture === false) {
return { type: 'invalid' as const, message: 'End date must be in the future' };
}
if (combinedEndDate && combinedEndDate.getTime() <= actualStartDate.getTime()) {
return { type: 'invalid' as const, message: 'End date must be after stream start' };
}
return { type: 'valid' as const };
});

let endDateUpdated = $derived($context.addEndDate && combinedEndDate !== undefined);

let canUpdate = $derived(
newAmountValueParsed &&
$context.newName &&
(nameUpdated || amountUpdated) &&
amountValidationState?.type === 'valid',
(nameUpdated || amountUpdated || endDateUpdated) &&
amountValidationState?.type === 'valid' &&
(!$context.addEndDate || endDateValidationState.type === 'valid'),
);

function updateStream() {
Expand All @@ -109,12 +150,14 @@
const { newHash, batch } = await buildEditStreamBatch(stream.id, {
name: nameUpdated ? $context.newName : undefined,
amountPerSecond: amountUpdated ? newAmountPerSecond : undefined,
newEndDate: endDateUpdated ? combinedEndDate : undefined,
actualStartDate: endDateUpdated ? actualStartDate : undefined,
Comment on lines 150 to +154
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

name is passed through as-is when updated. If the input is cleared, $context.newName will be an empty string; combined with buildEditStreamBatch's truthy checks, this can produce an empty batch (and still enable the confirm button via nameUpdated). Normalize the value before sending (e.g., trim and convert '' to undefined) and align nameUpdated with the normalized value so the flow doesn't allow submitting a no-op.

Copilot uses AI. Check for mistakes.
});

return {
batch,
newHash,
needGasBuffer: amountUpdated,
needGasBuffer: amountUpdated || endDateUpdated,
};
},

Expand Down Expand Up @@ -149,7 +192,7 @@

<StepLayout>
<StepHeader headline="Edit stream" description="Set a new name or edit the stream rate." />
<FormField title="New name*">
<FormField title="New name">
<TextInput bind:value={$context.newName} />
</FormField>
<div class="form-row">
Expand Down Expand Up @@ -203,6 +246,29 @@
{#if amountLocked}
<p class="typo-text">Currently, the stream rate can not be edited for paused streams.</p>
{/if}
{#if canAddEndDate}
<Toggleable bind:toggled={$context.addEndDate} label="Add end date">
<p class="typo-text">
Set an end date for this stream. The stream will stop at the specified time.
</p>
<div class="form-row" style="margin-top: 1rem">
<FormField title="End date*">
<TextInput
placeholder="YYYY-MM-DD"
bind:value={$context.endDateValue}
validationState={$context.addEndDate ? endDateParsed.validationState : undefined}
/>
</FormField>
<FormField title="End time (UTC, 24-hour)*">
<TextInput
placeholder="HH:MM:SS"
bind:value={$context.endTimeValue}
validationState={$context.addEndDate ? endTimeParsed.validationState : undefined}
/>
</FormField>
</div>
</Toggleable>
{/if}
<SafeAppDisclaimer disclaimerType="drips" />
{#snippet actions()}
<Button onclick={modal.hide} variant="ghost">Cancel</Button>
Expand Down
34 changes: 27 additions & 7 deletions src/lib/utils/streams/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type {
} from './__generated__/gql.generated';
import { pin } from '../ipfs';
import { toBigInt, type ContractTransaction, type Signer } from 'ethers';
import unreachable from '../unreachable';
import assert from '$lib/utils/assert';
import makeStreamId, { decodeStreamId } from './make-stream-id';
import extractAddressFromAccountId from '../sdk/utils/extract-address-from-accountId';
Expand Down Expand Up @@ -412,6 +411,8 @@ export async function buildEditStreamBatch(
newData: {
name?: string;
amountPerSecond?: bigint;
newEndDate?: Date;
actualStartDate?: Date;
},
) {
const ownAccountId = await getOwnAccountId();
Expand All @@ -430,7 +431,17 @@ export async function buildEditStreamBatch(

let hash: string | undefined;

if (newData.name) {
// Calculate duration from the stream's actual start date
let durationSeconds: number | undefined;
let startSeconds: bigint | undefined;
if (newData.newEndDate && newData.actualStartDate) {
startSeconds = BigInt(Math.floor(newData.actualStartDate.getTime() / 1000));
const endDateSeconds = Math.floor(newData.newEndDate.getTime() / 1000);
durationSeconds = endDateSeconds - Number(startSeconds);
assert(durationSeconds > 0, 'Duration must be positive');
Comment on lines +438 to +441
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

durationSeconds/startSeconds are encoded into 32-bit fields in streamConfigToUint256 (see stream-config-utils.ts), but this code doesn't validate the computed values fit uint32. A user can enter a far-future end date (e.g. year 9999) and cause streamConfigToUint256 to throw (via its internal round-trip/unreachable checks). Add explicit range validation for both startSeconds and the computed duration (0 < duration <= 0xFFFF_FFFF, start <= 0xFFFF_FFFF), and consider computing the duration using BigInt arithmetic to avoid Number(...) conversions.

Suggested change
startSeconds = BigInt(Math.floor(newData.actualStartDate.getTime() / 1000));
const endDateSeconds = Math.floor(newData.newEndDate.getTime() / 1000);
durationSeconds = endDateSeconds - Number(startSeconds);
assert(durationSeconds > 0, 'Duration must be positive');
const UINT32_MAX = 0xFFFF_FFFFn;
const startSecondsBigInt = BigInt(Math.floor(newData.actualStartDate.getTime() / 1000));
const endSecondsBigInt = BigInt(Math.floor(newData.newEndDate.getTime() / 1000));
const durationBigInt = endSecondsBigInt - startSecondsBigInt;
assert(durationBigInt > 0n, 'Duration must be positive');
assert(startSecondsBigInt <= UINT32_MAX, 'Start time is too far in the future');
assert(durationBigInt <= UINT32_MAX, 'Duration exceeds maximum allowed value');
startSeconds = startSecondsBigInt;
durationSeconds = Number(durationBigInt);

Copilot uses AI. Check for mistakes.
}

if (newData.name || durationSeconds !== undefined) {
const metadata = _buildMetadata(currentStreams, ownAccountId);
Comment on lines +444 to 445
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

These conditionals treat name as truthy/falsy (if (newData.name ...)). If the UI passes an empty string (common when clearing an optional text input), this block is skipped and buildEditStreamBatch can return an empty batch, which will later result in attempting to submit a no-op/invalid transaction. Use explicit undefined checks (newData.name !== undefined) and decide how to handle empty-string names (normalize to undefined or allow clearing by deleting the name field in metadata).

Copilot uses AI. Check for mistakes.

const assetConfigIndex = metadata.assetConfigs.findIndex(
Expand All @@ -446,7 +457,15 @@ export async function buildEditStreamBatch(
`Stream ${streamId} not found in metadata`,
);

metadata.assetConfigs[assetConfigIndex].streams[streamIndex].name = newData.name;
if (newData.name) {
metadata.assetConfigs[assetConfigIndex].streams[streamIndex].name = newData.name;
}

if (durationSeconds !== undefined) {
metadata.assetConfigs[assetConfigIndex].streams[
streamIndex
].initialDripsConfig.durationSeconds = durationSeconds;
}

hash = await pin(metadata);

Expand All @@ -458,7 +477,7 @@ export async function buildEditStreamBatch(
);
}

if (newData.amountPerSecond) {
if (newData.amountPerSecond || durationSeconds !== undefined) {
const newReceivers = currentReceivers.map((r) => {
const streamConfig = streamConfigFromUint256(r.config);

Expand All @@ -467,9 +486,10 @@ export async function buildEditStreamBatch(
accountId: r.accountId,
config: streamConfigToUint256({
dripId: streamConfig.dripId,
start: streamConfig.start,
duration: streamConfig.duration,
amountPerSec: newData.amountPerSecond ?? unreachable(),
start: startSeconds ?? streamConfig.start,
duration:
durationSeconds !== undefined ? BigInt(durationSeconds) : streamConfig.duration,
amountPerSec: newData.amountPerSecond ?? streamConfig.amountPerSec,
}),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@
</div>
<div class="rounded-drip-lg shadow-low pt-3 px-4 pb-4 relative overflow-hidden">
<div
class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-8 relative z-10"
class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-8 relative z-[1]"
>
<div>
<div class="typo-header-5">
Expand Down
Loading