Skip to content

feat: support exponential streams + added stream preview#854

Open
dan13ram wants to merge 3 commits intostagingfrom
feat/sablier-exponential
Open

feat: support exponential streams + added stream preview#854
dan13ram wants to merge 3 commits intostagingfrom
feat/sablier-exponential

Conversation

@dan13ram
Copy link
Collaborator

@dan13ram dan13ram commented Feb 11, 2026

Summary by CodeRabbit

  • New Features

    • Exponential streaming mode with an Exponential Curve toggle and exponent input
    • Interactive stream preview graphs embedded in creation and proposal UIs
  • Improvements

    • UI labels now show Exponential (exp: X) or Linear and conditionally hide cliff input when exponential
    • Previews and decoded transaction views display exponential streams and per-segment details
    • Date picker marks selections as touched immediately
  • Removals

    • "Number of Streams" display removed from stream details

@vercel
Copy link

vercel bot commented Feb 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
testnet-nouns-builder Ready Ready Preview Feb 14, 2026 1:55pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
nouns-builder Ignored Ignored Feb 14, 2026 1:55pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds exponential ("LD") Sablier streaming: UI toggle and exponent input, validation and schema updates, LD encoding paths, parsers and live-data plumbing to surface exponent/shape, and new graph components (BaseGraph/StreamGraph) for preview and proposal display.

Changes

Cohort / File(s) Summary
Stream form & schema
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamForm.tsx, packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.tsx, packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.schema.ts
Adds useExponential toggle and exponent field; conditional cliff input when exponential; validation requiring exponent (integer 2–100) in exponential mode; streamPreviewData calc and graph preview; selects LD encoding when exponential enabled.
Graph components & exports
packages/ui/src/Graph/BaseGraph.tsx, packages/ui/src/Graph/StreamGraph.tsx, packages/ui/src/Graph/utils.ts, packages/ui/src/Graph/Graph.css.ts, packages/ui/src/Graph/index.ts, packages/ui/src/index.ts
Introduces interactive BaseGraph, StreamGraph (50-point stream visualization supporting linear/exponential math), pointer/touch utilities, CSS animation styles, barrel export, and re-exports from ui index.
Sablier encoding
packages/utils/src/sablier/encoding.ts
Adds LD encoding interfaces and functions (CreateWithDurationsLDParams, CreateWithTimestampsLDParams, encodeCreateWithDurationsLD, encodeCreateWithTimestampsLD) emitting exponent (UD2x18) and dynamicExponential shape.
Sablier streams & parsing
packages/utils/src/sablier/streams.ts
Adds LD segment/config types and parsers, extends StreamConfig union, updates calculateStreamTimes/extractors for LD variants, and surfaces exponent/shape in live data.
Hooks & proposal UI
packages/hooks/src/useStreamData.ts, packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx
useStreamData recognizes LD events/streams, emits exponent and shape; StreamItem uses unified StreamConfig, imports/renders StreamGraph, and labels exponential vs linear.
Decoded transactions & display
packages/ui/src/DecodedTransactions/ArgumentDisplay/ArgumentDisplay.tsx, packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx, packages/ui/src/DecodedTransactions/TransactionDisplay/DecodedDisplay.tsx
Broadens recognized function names to LD variants, adds timestamp formatter, renders per-segment details (exponent, durations/timestamps), and selects parser per function name.
Utils & minor UI tweaks
packages/ui/src/Accordion/AccordionItem.tsx, packages/ui/src/Fields/DatePicker.tsx, packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokensDetailsDisplay.tsx
Small UI tweaks: accordion overflow on open, DatePicker marks field touched on change, removed displayed streamCount.
Constants removal
packages/utils/src/sablier/constants.ts
Removes previously exported createLockupLinearStreamEventAbi extraction.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Form as StreamForm
    participant Valid as Validation
    participant Graph as StreamGraph
    participant Encoder as Encoder
    participant Chain as Blockchain
    participant Decoder as DecodedDisplay

    User->>Form: set amount, duration/dates, toggle useExponential, set exponent
    Form->>Valid: validate fields (exponent required if exponential)
    Valid-->>Form: validation result
    Form->>Graph: compute streamPreviewData (deposit, start, end, cliff, exponent)
    Graph->>Graph: generate 50-point curve (LD if exponent present else LL)
    Graph-->>Form: render preview
    Form->>Encoder: choose function signature (createWith...LD or ...LL) and encode payload
    Encoder->>Chain: submit transaction with encoded LD/LL payload
    Chain->>Decoder: emit logs/tx
    Decoder->>Graph: parse and render final stream curve (show exponent if present)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐰 I hopped into code with exponential cheer,
Curves of fifty points now drawing near,
Toggle the exponent, watch the preview bloom,
Streams that arc from seed to harvest room,
A carrot-chart hop — the graph goes zoom! 🥕📈

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning No pull request description was provided by the author, leaving all required template sections (Description, Motivation & context, Code review, Type of change, Checklist) unfilled. Add a comprehensive description following the template: explain the changes made, motivation for exponential streams support, any code review notes, indicate this is a new feature, and complete the self-review checklist.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: adding exponential streams support and implementing a stream preview feature.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/sablier-exponential

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/hooks/src/useStreamData.ts (3)

175-227: ⚠️ Potential issue | 🔴 Critical

flattenedStaticStreams doesn't handle LD stream configs — times will be incorrect.

The flattening logic accesses s.durations?.cliff, s.durations?.total, s.timestamps?.start, etc. (lines 197–207). LD configs have segmentsWithDuration or segments instead, so these optional chains all resolve to undefinedNumber(undefined) = NaN or totalDur = 0, producing incorrect startTime/endTime/cliffTime for LD streams.

This block needs LD-aware branching similar to calculateStreamTimes in streams.ts.


334-346: ⚠️ Potential issue | 🟠 Major

calculateStreamedAmountLL is used for exponential (LD) streams.

For active/settled/pending LD streams, line 336 calls calculateStreamedAmountLL which assumes linear interpolation. Exponential streams follow a power curve (amount * (elapsed/total)^exponent), so the real-time streamed amount will be wrong for LD streams. This will show incorrect progress to users.

Consider adding a calculateStreamedAmountLD function or detecting the stream type and dispatching accordingly.


121-138: ⚠️ Potential issue | 🔴 Critical

Event ABI missing CreateLockupDynamicStream — LD stream IDs won't be captured.

The log decoding on line 124 uses createLockupLinearStreamEventAbi, which only includes the CreateLockupLinearStream event. When transaction receipts emit CreateLockupDynamicStream events, decodeEventLog will throw (event not in ABI), get caught, map to null, and be filtered out on lines 136–137. No LD stream IDs will be extracted.

Stream flattening incompatible with LD configs.

Lines 195–208 flatten streams by accessing s.durations and s.timestamps, which don't exist on LD stream configs (which have segmentsWithDuration/segments instead). LD streams will have undefined properties, resulting in incorrect startTime, endTime, and cliffTime values in FlattenedStaticStream.

Linear calculation applied to exponential streams.

Line 336 uses calculateStreamedAmountLL for all stream types. calculateStreamedAmountLD exists in the codebase but is not imported or called. LD streams with exponential curves will get incorrect streamed amount calculations.

exponent and shape fields not populated in StreamLiveData.

Lines 352–370 construct StreamLiveData but never populate the optional exponent and shape fields, even though they're available in the static stream configuration.

🤖 Fix all issues with AI agents
In
`@packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx`:
- Around line 132-169: The segment loop currently renders the wrong heading and
duplicates the outer "Stream #{index + 1}" label; change the segment heading
inside both maps (stream.segmentsWithDuration.map and stream.segments.map) to
"Segment #{segmentIndex + 1}" (use the segmentIndex variable). Also factor the
duplicated segment rendering into a shared renderer (a small helper component or
function inside StreamArgumentDisplay.tsx, e.g., renderSegment or
SegmentDisplay) that accepts (segment, segmentIndex) and a prop/flag for whether
to show duration (use formatStreamDuration(segment.duration)) or timestamp
(segment.timestamp), and then call that shared renderer from both maps to remove
the near-identical code paths.

In `@packages/ui/src/DecodedTransactions/TransactionDisplay/DecodedDisplay.tsx`:
- Line 93: The inline comment on DecodedDisplay (near the conditional that
checks for createWithDurationsLL/createWithTimestampsLL) is stale because the
condition now also includes the LD variants; update the comment to either list
all four constructors (createWithDurationsLL, createWithTimestampsLL,
createWithDurationsLD, createWithTimestampsLD) or replace it with a generic
phrase like "Check if this is a createWithDurations/createWithTimestamps
variant" so the comment accurately reflects the condition in the DecodedDisplay
component.

In `@packages/ui/src/Graph/BaseGraph.tsx`:
- Around line 51-64: The useEffect that animates the SVG path (using lineRef,
animationKey) can call lineRef.current! inside the setTimeout after the
component may have unmounted; capture the timeout ID and add a cleanup to
clearTimeout to avoid the callback running after unmount, and inside the timeout
callback guard against a null lineRef.current before accessing its style (remove
the non-null assertion). Update the effect to store the timer ID (e.g., const t
= setTimeout(...)), return a cleanup function that calls clearTimeout(t) and
(optionally) resets any transition, and check lineRef.current exists before
setting transition/strokeDashoffset/opacity.
- Around line 125-129: The hardcoded filter id "smooth" in the BaseGraph
component will conflict across multiple instances; change BaseGraph to generate
a unique filter id (e.g., call React.useId() or combine props.animationKey) and
replace the static id="smooth" with that unique id, then update every reference
to the filter (any attributes using url(`#smooth`) or "#smooth") to use the
generated id (e.g., `${uniqueId}-smooth`); ensure the unique id generation and
substitution occur inside the BaseGraph render so each instance emits and
references its own filter.

In `@packages/ui/src/Graph/Graph.css.ts`:
- Around line 13-20: The exported symbol cursorText in Graph.css.ts is not used
anywhere; either delete the entire cursorText style definition to remove dead
code, or if it's intended for future use, add a clear JSDoc comment above
cursorText explaining its purpose and keep it exported (and optionally add a
TODO and a test or example import) so reviewers know it's intentional; search
for usages of cursorText to confirm removal won't break anything before
committing.

In `@packages/ui/src/Graph/utils.ts`:
- Around line 11-12: getTouchEventSource currently returns e.touches[0] which
can be undefined on touchend/touchcancel; update getTouchEventSource to safely
return the first available touch by checking e.touches[0] and falling back to
e.changedTouches[0] (or undefined if neither exists), and adjust the return type
of getTouchEventSource to React.Touch | undefined so callers of
getTouchEventSource (by name) handle the possible undefined result.
- Around line 14-31: The utilities use horizontal coordinates but are misnamed:
rename calculateY to calculateX (and update its parameter names: event.clientX,
paddingX remain but change the param name from e if desired), and in
calculateVisibleIndex rename parameters y -> x and paddingY -> paddingX (and
rename the function if you prefer calculateVisibleIndexByX) so the parameter
names reflect horizontal usage; update the internal math/comments to reference
width/left/x consistently, and update all callers to use the new
function/parameter names to avoid confusion.

In `@packages/utils/src/sablier/streams.ts`:
- Around line 179-205: The parseStreamDataConfigTimestampsLD function is adding
a stray cliffTime property (cliffTime: Number(streamData.cliffTime)) even though
StreamConfigTimestampsLD and encodeCreateWithTimestampsLD do not define or emit
it; remove the cliffTime assignment from the returned object in
parseStreamDataConfigTimestampsLD so the returned value strictly matches
StreamConfigTimestampsLD and avoids producing NaN/undeclared properties when the
object is inspected or spread.
🧹 Nitpick comments (9)
packages/ui/src/Fields/DatePicker.tsx (1)

78-81: Double validation on date change.

setFieldValue already triggers validation by default. Calling setFieldTouched(id, true, true) triggers it again. Consider passing false as the third argument to one of them to avoid redundant validation cycles.

♻️ Suggested fix
        formikRef.current.setFieldValue(id, dateStr)
-        formikRef.current.setFieldTouched(id, true, true)
+        formikRef.current.setFieldTouched(id, true, false)
packages/utils/src/sablier/streams.ts (1)

161-165: Exponent UD2x18 → number conversion truncates fractional values.

Number(exponentUD2x18 / BigInt(10 ** 18)) uses BigInt integer division, which truncates any fractional part. This is fine given the current schema validates integer exponents (2–100), but if the protocol or UI ever supports fractional exponents (e.g., 2.5), this will silently round down.

Consider using Number(exponentUD2x18) / 1e18 for lossless conversion (safe for values ≤ 100 × 10¹⁸ which is well within float64 precision).

Also applies to: 188-191

packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx (1)

27-32: formatTimestamp accepts any — consider narrowing to number.

A non-numeric value passed here would silently produce "Invalid Date". Since this is a display helper, a number type would catch misuse at compile time.

packages/ui/src/index.ts (1)

15-15: Generic utility names re-exported at the top level could collide.

The Graph/utils.ts exports generic names like isTouchEvent, calculateY, calculateVisibleIndex, getMouseEventSource, getTouchEventSource. Since Graph/index.ts does export * from './utils' and this file does export * from './Graph', all those names become part of the @buildeross/ui public surface. If any other module in the barrel exports a symbol with the same name, you'll get a TypeScript/bundler conflict.

Consider using named re-exports in Graph/index.ts to limit the public API surface, or keep the utils internal to the Graph module.

packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx (1)

314-342: Graph renders with hardcoded dimensions inside a responsive container — confirm scaling.

The StreamGraph receives width={500} and height={220} while the parent Box uses w="100%" with aspectRatio: '2.27/1'. Since BaseGraph uses a viewBox, the SVG should scale. However, tooltip positioning in renderTooltip uses absolute pixel calculations based on the width/height props. On very narrow screens, the tooltip text may overlap or clip if the rendered pixel size is much smaller than the viewBox.

packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamForm.tsx (1)

10-10: SECONDS_PER_DAY is duplicated across files.

This constant is also defined in StreamTokens.tsx (Line 46). Extract it to a shared location (e.g., a constants file in the same module or @buildeross/utils) to avoid drift.

packages/ui/src/Graph/BaseGraph.tsx (1)

85-96: calculateY is misnamed — it computes horizontal (X) position, not Y.

calculateY in utils.ts subtracts the element's left from clientX, producing a horizontal offset. The local variable y on Line 92 is then passed to calculateVisibleIndex as a horizontal offset. This naming inversion is confusing for future readers.

packages/ui/src/Graph/StreamGraph.tsx (2)

117-168: Tooltip position logic is fragile — hardcoded offsets must match BaseGraph internals.

The tooltip computes positions using chartWidth = width - 20, chartHeight = height - 60, and offsets +10, +30, -12 (Lines 125-131). These must stay perfectly in sync with BaseGraph's paddingX = 10 and paddingY = 30. If BaseGraph's padding constants change, the tooltip will misalign without any compile-time warning.

Consider exporting BaseGraph's padding constants or passing them to the renderTooltip callback so the tooltip can compute positions consistently.


48-115: isExponential in the dependency array is redundant.

Line 112: isExponential is derived from exponent (Line 45), and exponent is already in the dependency list at Line 111. Including both is harmless but unnecessary — React will already re-run the memo when exponent changes.

Comment on lines 13 to 20
export const cursorText = style({
fontSize: '20px',
'@media': {
'screen and (max-width: 768px)': {
fontSize: '12px',
},
},
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg 'cursorText' --type ts --type tsx --type js --type jsx

Repository: BuilderOSS/nouns-builder

Length of output: 95


🏁 Script executed:

rg 'cursorText'

Repository: BuilderOSS/nouns-builder

Length of output: 238


🏁 Script executed:

rg 'from.*Graph\.css|cursorText' -A 2 -B 2

Repository: BuilderOSS/nouns-builder

Length of output: 1437


🏁 Script executed:

rg 'import.*cursorText|cursorText\s*=' --type-list

Repository: BuilderOSS/nouns-builder

Length of output: 457


🏁 Script executed:

rg '(import.*cursorText|cursorText\s*[=:]|\bcursorText\b)' -B 2 -A 2

Repository: BuilderOSS/nouns-builder

Length of output: 751


🏁 Script executed:

rg 'className.*cursorText|cursorText\s*\}' -B 3 -A 1

Repository: BuilderOSS/nouns-builder

Length of output: 50


🏁 Script executed:

# Also search for files that might import from Graph.css or use the exported cursorText
rg 'import.*from.*Graph' --type ts

Repository: BuilderOSS/nouns-builder

Length of output: 638


🏁 Script executed:

# Search for any reference to cursorText that's not in the export definition
rg 'cursorText' -C 5 | grep -v '^export const cursorText'

Repository: BuilderOSS/nouns-builder

Length of output: 1672


🏁 Script executed:

# Also check the index.ts to see what's being exported
cat packages/ui/src/Graph/index.ts

Repository: BuilderOSS/nouns-builder

Length of output: 253


cursorText is unused and should be removed or documented.

The export is defined but not imported or used anywhere in the codebase. Either remove it if it's not needed, or clarify its purpose if it's intended for future use.

🤖 Prompt for AI Agents
In `@packages/ui/src/Graph/Graph.css.ts` around lines 13 - 20, The exported symbol
cursorText in Graph.css.ts is not used anywhere; either delete the entire
cursorText style definition to remove dead code, or if it's intended for future
use, add a clear JSDoc comment above cursorText explaining its purpose and keep
it exported (and optionally add a TODO and a test or example import) so
reviewers know it's intentional; search for usages of cursorText to confirm
removal won't break anything before committing.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@packages/ui/src/Graph/BaseGraph.tsx`:
- Around line 145-154: The vertical guide line in BaseGraph.tsx currently sets
opacity both as an SVG attribute (opacity="0.2") and via
style.opacity={cursorOpacity}, causing the style value to override and make the
line fully opaque when cursorOpacity is 1; update the line rendering to compute
a single opacity value (e.g., const baseOpacity = 0.2; const finalOpacity =
baseOpacity * cursorOpacity) and use that single finalOpacity for the SVG
opacity prop (or for style but not both), removing the conflicting hardcoded
"0.2" attribute so the line respects the intended faded appearance; locate this
change around the <line ... /> element where strokeColor, STROKE, currentX and
cursorOpacity are used.
- Around line 110-112: The code accesses data[visibleIndex] which can be
undefined if the data array shrinks; update BaseGraph to guard and keep
visibleIndex in-bounds by clamping or resetting it when data changes (e.g., in a
useEffect watching data.length, call
setVisibleIndex(Math.min(currentVisibleIndex, Math.max(0, data.length - 1))))
and also defensively check currentPoint before reading currentPoint.y so
currentX/currentY calculations use a safe fallback when currentPoint is
undefined; reference visibleIndex, data, currentPoint, currentX, and currentY
when applying the fixes.
- Around line 85-96: The helper calculateY is misnamed and actually computes an
X coordinate—rename calculateY → calculateX in utils and update all call sites
(including BaseGraph.handleMouseMove) to use calculateX; in BaseGraph change
local variable y to x (const x = calculateX(event, e, paddingX)) and rename the
visible index variable to avoid shadowing the component state (e.g., const
newIndex = calculateVisibleIndex(x, e, paddingY, data.length) and then call
setVisibleIndex(newIndex)); also update calculateVisibleIndex signature to
rename its first parameter from y to x to reflect the horizontal position.
🧹 Nitpick comments (2)
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamForm.tsx (1)

137-164: Cliff input duplication across days/dates branches.

The two cliff <SmartInput> blocks (lines 138–163 and 199–224) are nearly identical — only the helperText differs. Consider extracting a shared CliffInput component to reduce duplication.

Example extraction
const CliffInput: React.FC<{ index: number; helperText: string }> = ({ index, helperText }) => (
  <SmartInput
    {...formik.getFieldProps(`streams.${index}.cliffDays`)}
    inputLabel="Cliff Period (optional, in days)"
    id={`streams.${index}.cliffDays`}
    type={FIELD_TYPES.NUMBER}
    placeholder={'0'}
    min={0}
    step={1}
    errorMessage={
      formik.touched.streams?.[index]?.cliffDays
        ? getFieldError('cliffDays')
        : undefined
    }
    helperText={helperText}
    onChange={(e) => {
      formik.handleChange(e)
      formik.setFieldTouched(`streams.${index}.cliffDays`, true, false)
    }}
    onBlur={(e) => {
      formik.handleBlur(e)
      formik.setFieldTouched(`streams.${index}.cliffDays`, true, true)
    }}
    onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
      e.target.select()
    }}
  />
)

Also applies to: 198-225

packages/ui/src/Graph/BaseGraph.tsx (1)

67-67: Math.max(...data.map(...)) can overflow the call stack for large datasets.

Spreading a large array into Math.max arguments hits the engine's argument-count limit. A reduce is safer and equally readable:

♻️ Suggested refactor
-  const maximumY = useMemo(() => Math.max(...data.map((point) => point.y), 1), [data])
+  const maximumY = useMemo(
+    () => data.reduce((max, point) => (point.y > max ? point.y : max), 1),
+    [data]
+  )

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
packages/hooks/src/useStreamData.ts (3)

175-227: ⚠️ Potential issue | 🔴 Critical

flattenedStaticStreams does not handle LD stream configs — LD streams will get invalid time values.

This block accesses s.durations, s.timestamps, s.cliffTime, and s.unlockAmounts, which only exist on StreamConfigDurations and StreamConfigTimestamps (LL variants). For LD streams (StreamConfigDurationsLD/StreamConfigTimestampsLD), these properties are undefined, resulting in:

  • totalDur = 0endTime = executedAt (stream appears to have zero duration)
  • startTime = 0 / endTime = 0 for timestamps LD
  • unlockStart = 0n, unlockCliff = 0n (happens to be correct for LD, but by accident)

The LD variants store timing in segmentsWithDuration or segments + startTime, not in the LL-shaped properties. This block needs LD-aware branching similar to calculateStreamTimes.

Suggested approach
     for (const s of batch.streams as any[]) {
+      // Import and use calculateStreamTimes for proper LD/LL handling
+      const times = calculateStreamTimes(s, batch.isDurationsMode, executedAt)
       const sender = s.sender as Address
       const recipient = s.recipient as Address
       const depositAmount = s.depositAmount as bigint
       const cancelable = s.cancelable as boolean
       const transferable = s.transferable as boolean
       const unlockStart = (s.unlockAmounts?.start ?? 0n) as bigint
       const unlockCliff = (s.unlockAmounts?.cliff ?? 0n) as bigint

-      let startTime: number
-      let endTime: number
-      let cliffTime: number
-
-      if (batch.isDurationsMode) {
-        const cliffDur = Number(s.durations?.cliff ?? 0)
-        const totalDur = Number(s.durations?.total ?? 0)
-        startTime = executedAt
-        endTime = executedAt + totalDur
-        cliffTime = cliffDur === 0 ? 0 : executedAt + cliffDur
-      } else {
-        startTime = Number(s.timestamps?.start ?? 0)
-        endTime = Number(s.timestamps?.end ?? 0)
-        cliffTime = Number(s.cliffTime ?? 0)
-      }
-
       out.push({
         sender,
         recipient,
         depositAmount,
         cancelable,
         transferable,
-        startTime,
-        cliffTime,
-        endTime,
+        startTime: times.startTime,
+        cliffTime: times.cliffTime,
+        endTime: times.endTime,
         unlockStart,
         unlockCliff,
         tokenAddress: batch.tokenAddress,
       })
     }

329-347: ⚠️ Potential issue | 🔴 Critical

calculateStreamedAmountLL is used for all streams including exponential LD — LD streams will show incorrect progress.

For active/settled/pending LD streams, calculateStreamedAmountLL computes a linear interpolation, while exponential streams follow a power-curve formula. This will produce visually and numerically incorrect streamedAmount and withdrawableAmount for LD streams.

The code should detect whether the stream is LD (e.g., by checking for segments/segmentsWithDuration on the static config, or storing an isExponential flag in FlattenedStaticStream) and dispatch to calculateStreamedAmountLD accordingly.


121-138: ⚠️ Potential issue | 🔴 Critical

Use a separate ABI for decoding CreateLockupDynamicStream events, or combine both event types into a single ABI constant.

The code filters for both CreateLockupLinearStream and CreateLockupDynamicStream events (line 137), but decodeEventLog uses only createLockupLinearStreamEventAbi (line 125), which is defined in constants.ts as:

.filter((item) => item.type === 'event' && item.name === 'CreateLockupLinearStream')

When a CreateLockupDynamicStream event is processed, decodeEventLog will throw because that event isn't in the ABI. The catch block silently returns null, and the filter drops it, so all LD stream IDs are lost after proposal execution, making the LD feature non-functional.

Additionally, useStreamData.ts uses only calculateStreamedAmountLL for all non-canceled/non-depleted streams without type discrimination, whereas StreamGraph.tsx properly branches between calculateStreamedAmountLD and calculateStreamedAmountLL. When fixed to extract LD stream IDs, the streamed amounts will be calculated incorrectly for LD streams.

packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.tsx (1)

287-305: ⚠️ Potential issue | 🟡 Minor

Residual cliffDays can block submission when exponential mode is enabled.

When a user sets cliffDays > durationDays, then toggles on exponential mode, the cliff input disappears (Line 137 of StreamForm.tsx) but cliffDays persists in form state. The validation at Line 299 (cliffDuration > totalDuration) will reject the submission even though cliff is irrelevant for LD streams.

Either skip cliff validation when useExponential is true (matching the schema), or clear cliffDays to 0 when toggling exponential on.

Option A: Skip cliff validation for exponential
+        const skipCliffValidation = values.useExponential && values.exponent
+
         if (useDurations) {
           // Days from now
-          const cliffDuration = (stream.cliffDays || 0) * SECONDS_PER_DAY
+          const cliffDuration = skipCliffValidation ? 0 : (stream.cliffDays || 0) * SECONDS_PER_DAY
           const totalDuration = (stream.durationDays || 0) * SECONDS_PER_DAY
Option B: Clear cliffDays on toggle (in StreamTokens.tsx line 787-792)
                          onChange={(e) => {
                            formik.setFieldValue('useExponential', e.target.checked)
                            // Set default exponent when enabling
                            if (e.target.checked && !formik.values.exponent) {
                              formik.setFieldValue('exponent', 2)
                            }
+                           // Clear cliff days for all streams when enabling exponential
+                           if (e.target.checked) {
+                             formik.values.streams.forEach((_, idx) => {
+                               formik.setFieldValue(`streams.${idx}.cliffDays`, 0)
+                             })
+                           }
                          }}
🤖 Fix all issues with AI agents
In
`@packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx`:
- Around line 154-167: The UI may render two "start:" rows when both
stream.timestamps.start and stream.startTime are present; update
StreamArgumentDisplay (around the JSX rendering that uses stream.timestamps,
stream.startTime, formatTimestamp) to render only one start row by making the
stream.startTime check conditional (else) when stream.timestamps is truthy or
explicitly prefer one source (e.g., use stream.timestamps.start if available,
otherwise stream.startTime) so only a single "start:" Flex is output.
🧹 Nitpick comments (6)
packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx (2)

27-32: formatTimestamp lacks input validation for edge cases.

If a timestamp value of 0 is passed (e.g., stream.timestamps.start at line 156), this will render "Dec 31, 1969" instead of something meaningful. Some call sites guard with > 0 (lines 161, 165), but lines 156–157 do not.

Consider adding a guard or typing the parameter as number:

Suggested improvement
-const formatTimestamp = (timestamp: any) =>
-  new Date(timestamp * 1000).toLocaleDateString('en-US', {
+const formatTimestamp = (timestamp: number): string => {
+  if (!timestamp || timestamp <= 0) return 'N/A'
+  return new Date(timestamp * 1000).toLocaleDateString('en-US', {
     month: 'short',
     day: 'numeric',
     year: 'numeric',
   })
+}

44-63: Token logo + amount rendering pattern is duplicated.

The <img> element with identical styling for the token logo appears in both SegmentDisplay (lines 47–61) and the batch stream rendering (lines 126–139). Consider extracting a small TokenAmount or TokenLogo helper to reduce duplication.

Also applies to: 123-143

packages/utils/src/sablier/streams.ts (1)

276-304: calculateStreamTimes LD handling looks correct, but the type discrimination could be fragile.

The property-based checks ('segments' in stream, 'segmentsWithDuration' in stream) work today because LL variants don't have these properties. However, if the union evolves, this could silently mis-classify. Consider using a discriminant field (e.g., a kind or type property) on each variant for safer discrimination.

This is minor since the current union is well-defined and the property names are sufficiently unique.

packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.schema.ts (1)

89-102: Conditional exponent validation is correct; Biome lint warning is a false positive.

The Biome error "Do not add then to an object" on Line 95 is a false positive. This is yup's standard .when() API, where then is a configuration key for conditional schema — not a .then() method making the object thenable. Consider adding an inline suppression comment if this triggers in CI.

Suppression example
     exponent: yup
       .number()
       .optional()
       .when('useExponential', {
         is: true,
+        // biome-ignore lint/suspicious/noThenProperty: yup .when() API config key
         then: (schema) =>
           schema
             .required('Exponent is required when using exponential curve')
             .integer('Exponent must be a whole number')
             .min(2, 'Exponent must be at least 2')
             .max(100, 'Exponent cannot exceed 100'),
         otherwise: (schema) => schema,
       }),
packages/ui/src/Graph/StreamGraph.tsx (1)

117-168: Tooltip text elements use backgroundColor which has no effect on SVG <text>.

Lines 151 and 162 pass backgroundColor={'accent'} to <Text as="text">. Since as="text" renders an SVG <text> element, the CSS background-color property has no visual effect. If you intended a background behind the tooltip text, you'd need an SVG <rect> behind it. If the prop isn't needed, removing it reduces confusion.

packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamForm.tsx (1)

137-164: Cliff input duplication across days/dates branches.

The cliff SmartInput blocks at Lines 137-164 and 198-225 are nearly identical. Consider extracting a shared CliffInput component to reduce duplication. This is pre-existing pattern though, so deferrable.

Also applies to: 198-225

Comment on lines 154 to 167
{stream.timestamps && (
<>
<Flex>
start: {new Date(stream.timestamps.start * 1000).toLocaleString()}
</Flex>
<Flex>
end: {new Date(stream.timestamps.end * 1000).toLocaleString()}
</Flex>
<Flex>start: {formatTimestamp(stream.timestamps.start)}</Flex>
<Flex>end: {formatTimestamp(stream.timestamps.end)}</Flex>
</>
)}

{stream.startTime !== undefined && stream.startTime > 0 && (
<Flex>start: {formatTimestamp(stream.startTime)}</Flex>
)}

{stream.cliffTime !== undefined && stream.cliffTime > 0 && (
<Flex>cliff: {new Date(stream.cliffTime * 1000).toLocaleString()}</Flex>
<Flex>cliff: {formatTimestamp(stream.cliffTime)}</Flex>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential duplicate "start:" line if both stream.timestamps and stream.startTime coexist.

Lines 156 and 162 both render a start: label. If a stream object ever has both stream.timestamps.start and stream.startTime defined, the UI will show two "start:" rows. If these are guaranteed to be mutually exclusive by the data model, consider adding an else-style guard (or a comment) to make the intent explicit.

🤖 Prompt for AI Agents
In
`@packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx`
around lines 154 - 167, The UI may render two "start:" rows when both
stream.timestamps.start and stream.startTime are present; update
StreamArgumentDisplay (around the JSX rendering that uses stream.timestamps,
stream.startTime, formatTimestamp) to render only one start row by making the
stream.startTime check conditional (else) when stream.timestamps is truthy or
explicitly prefer one source (e.g., use stream.timestamps.start if available,
otherwise stream.startTime) so only a single "start:" Flex is output.

@dan13ram dan13ram force-pushed the feat/sablier-exponential branch 3 times, most recently from 47739cc to 4ab9ed4 Compare February 12, 2026 04:42
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.tsx (1)

360-373: ⚠️ Potential issue | 🟠 Major

Shape should reflect stream type (linear, cliff, or exponential), not just cliff presence.

Line 372 sets shape based only on cliff presence, but the form supports exponential streams via the useExponential flag. For exponential streams, the shape should be 'dynamicExponential' to match the encoding functions. Currently, exponential streams will incorrectly have shape: 'linear', which doesn't match their actual type. While validateBatchStreams doesn't validate specific shape values, other parts of the system may rely on the shape field for proper stream type identification (e.g., UI display, stream parsing).

Update the shape assignment to check values.useExponential and set shape to 'dynamicExponential' for exponential streams.

🤖 Fix all issues with AI agents
In `@packages/ui/src/Graph/StreamGraph.tsx`:
- Around line 134-146: The tooltip position calculation (constants TEXT_OFFSET,
computed x and y using index, parts, chartWidth, chartHeight, GRAPH_PADDING_X,
GRAPH_PADDING_Y, and point.y/maximumY) can produce negative coordinates near
graph edges; clamp x and y after computing them so they never go outside the SVG
viewBox (e.g., ensure x is between GRAPH_PADDING_X and chartWidth -
GRAPH_PADDING_X - some tooltip width buffer, and y is between GRAPH_PADDING_Y
and chartHeight - GRAPH_PADDING_Y - some tooltip height buffer) to keep the
<text> tooltip fully visible — update the code that computes x and y to apply
these min/max bounds before rendering the text.

In `@packages/utils/src/sablier/encoding.ts`:
- Around line 130-132: The code currently does BigInt(stream.exponent) *
BigInt(10 ** 18) inside the streams.map batch, which will throw if
stream.exponent is fractional; instead validate and scale the float before
converting to BigInt: inside the mapping for batch (referencing streams.map and
exponentUD2x18), check that stream.exponent is a finite number (Number.isFinite)
and not NaN, compute a scaled integer like const scaled =
Math.round(stream.exponent * 1e18) (or Math.floor/Math.trunc if a specific
rounding is required), then set exponentUD2x18 = BigInt(scaled); optionally
throw a clear error if validation fails so callers know the input is invalid.
- Around line 13-19: The exponent comment and type annotations claiming a range
of 2-100 are wrong because UD2x18 is backed by uint64 with 18 decimals (max
~18.446744073709551615), so update all exponent comments in
CreateWithDurationsLDParams, CreateWithTimestampsLDParams and related type
comments in streams.ts to reflect the real UD2x18 maximum (~18.44) and valid
minimum (>=2); add runtime validation in the encoding flow (the functions that
convert exponent to UD2x18 before encoding — see the encoding utilities where
exponent is multiplied by 1e18) to check the exponent is within [2,
18.446744073709551615] (or a safe integer upper bound like 18) and throw a
descriptive error if out of range, so invalid exponents are rejected before any
on-chain encoding/transaction is attempted.
🧹 Nitpick comments (7)
packages/ui/src/Accordion/AccordionItem.tsx (1)

91-104: Overflow may flash during the opening animation.

Since overflow switches to 'visible' immediately when isOpen becomes true, but the height animates from 0'auto', content can momentarily overflow the still-expanding container. If this causes a visible flicker, consider deferring overflow: 'visible' until the animation completes (e.g., via framer-motion's onAnimationComplete callback).

If the current behavior is visually acceptable for the stream preview use case, this is fine as-is.

packages/utils/src/sablier/streams.ts (1)

161-165: Exponent decoding truncates fractional values via integer division.

Number(exponentUD2x18 / BigInt(10 ** 18)) is BigInt integer division, so an exponent like 2.5 (encoded as 2500000000000000000n) would decode as 2. This is consistent with the encoding side (which only accepts integers), but worth noting if fractional exponents are ever needed. The exponent field on StreamConfigDurationsLD/StreamConfigTimestampsLD could carry a brief comment.

Also applies to: 188-191

packages/hooks/src/useStreamData.ts (1)

196-233: Duplicated exponent extraction logic across LD branches.

Lines 214-217 and 229-232 contain identical exponent extraction from UD2x18. Consider extracting a small helper:

Proposed helper
+const extractExponentFromSegments = (segments: any[]): number | undefined => {
+  if (segments.length > 0 && segments[0].exponent) {
+    const exponentUD2x18 = BigInt(segments[0].exponent)
+    return Number(exponentUD2x18 / 10n ** 18n)
+  }
+  return undefined
+}
packages/ui/src/Graph/StreamGraph.tsx (2)

105-115: isExponential in the dependency array is derived from exponent and is redundant.

Since isExponential is computed as exponent !== undefined && exponent >= 2, listing both exponent and isExponential in the dependency array is redundant. Removing isExponential would be cleaner, though functionally harmless.

♻️ Suggested change
   }, [
     depositAmount,
     decimals,
     startTime,
     endTime,
     cliffTime,
     exponent,
-    isExponential,
     unlockStart,
     unlockCliff,
   ])

117-176: renderTooltip is recreated on every render.

This function is passed as a prop to BaseGraph. Wrapping it in useCallback would stabilize the reference. Not critical since BaseGraph isn't memoized, but good practice.

packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamForm.tsx (1)

10-10: SECONDS_PER_DAY is duplicated across StreamForm.tsx and StreamTokens.tsx.

Both files define const SECONDS_PER_DAY = 86400. Consider extracting this to a shared constant to keep things DRY.

packages/ui/src/Graph/BaseGraph.tsx (1)

125-131: Confusing function name: handleMouseLeaveOrTouchEnter.

This function handles mouse enter and touch start (setting opacity to 1), but the name contains "Leave" which implies the opposite. Consider renaming for clarity.

♻️ Suggested rename
-  const handleMouseLeaveOrTouchEnd = () => {
+  const handleCursorExit = () => {
     setCursorOpacity(0)
   }

-  const handleMouseLeaveOrTouchEnter = () => {
+  const handleCursorEnter = () => {
     setCursorOpacity(1)
   }

Comment on lines 134 to 146
// Calculate position with text offset for better visual alignment
const TEXT_OFFSET = 12
const x =
index * (chartWidth / Math.max(1, parts - 1)) + GRAPH_PADDING_X - TEXT_OFFSET
const y =
chartHeight - (point.y / maximumY) * chartHeight + GRAPH_PADDING_Y - TEXT_OFFSET

// Format timestamp as date
const timestamp = new Date(point.x * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Tooltip text can render outside the SVG viewBox near graph boundaries.

When index is 0 or point.y is near maximumY, the computed x and y values (after subtracting TEXT_OFFSET and additional offsets of 10/28) can go negative, pushing the <text> elements above or left of the viewBox. Consider clamping x and y to keep the tooltip within visible bounds.

🤖 Prompt for AI Agents
In `@packages/ui/src/Graph/StreamGraph.tsx` around lines 134 - 146, The tooltip
position calculation (constants TEXT_OFFSET, computed x and y using index,
parts, chartWidth, chartHeight, GRAPH_PADDING_X, GRAPH_PADDING_Y, and
point.y/maximumY) can produce negative coordinates near graph edges; clamp x and
y after computing them so they never go outside the SVG viewBox (e.g., ensure x
is between GRAPH_PADDING_X and chartWidth - GRAPH_PADDING_X - some tooltip width
buffer, and y is between GRAPH_PADDING_Y and chartHeight - GRAPH_PADDING_Y -
some tooltip height buffer) to keep the <text> tooltip fully visible — update
the code that computes x and y to apply these min/max bounds before rendering
the text.

Comment on lines 13 to 19
export interface CreateWithDurationsLDParams {
sender: Address
recipient: Address
depositAmount: bigint
totalDuration: number // in seconds
exponent: number // Exponent for exponential curve (2-100)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the encoding.ts file to understand the structure
fd -e ts -p "encoding.ts" | head -5

Repository: BuilderOSS/nouns-builder

Length of output: 106


🏁 Script executed:

# Get an overview of the file structure and size
wc -l packages/utils/src/sablier/encoding.ts

Repository: BuilderOSS/nouns-builder

Length of output: 110


🏁 Script executed:

# Look at the specific lines mentioned in the review
sed -n '1,40p' packages/utils/src/sablier/encoding.ts

Repository: BuilderOSS/nouns-builder

Length of output: 1133


🏁 Script executed:

# Find the encoding functions around lines 132 and 174
sed -n '120,180p' packages/utils/src/sablier/encoding.ts

Repository: BuilderOSS/nouns-builder

Length of output: 1854


🌐 Web query:

Sablier V2 UD2x18 fixed point format uint64 maximum value

💡 Result:

In Sablier V2 / PRBMath, UD2x18 is a user-defined value type backed by uint64 (scaled by 1e18, i.e., 18 decimal places). (etherscan.io)

So its maximum raw (wrapped) value is:

  • uMAX_UD2x18 = 18_446_744_073_709_551_615 (which is 2^64 - 1) (etherscan.io)

And the corresponding maximum “real” fixed-point value is:

  • MAX = (2^64 - 1) / 1e18 = 18.446744073709551615 (etherscan.io)

Citations:


🌐 Web query:

Sablier LockupDynamic exponent range constraints UD2x18

💡 Result:

In Sablier LockupDynamic, each segment’s exponent is typed as UD2x18 (PRBMath’s unsigned 2.18 fixed-point, backed by uint64). That implies these range constraints:

  • Minimum: 0 (cannot be negative because it’s unsigned).
  • Maximum: MAX_UD2x18 = 18_446744073709551615 (raw, 18-decimal fixed point), i.e. 18.446744073709551615 as a real number. [1]
  • Scaling: 1e18 represents 1.0 in UD2x18. [1]
  • Type definition: UD2x18 is type UD2x18 is uint64, so values are fundamentally capped by uint64. [1][2]

So valid exponents are: 0 ≤ exponent ≤ 18.446744073709551615 (encoded as a uint64 with 18 decimals). [1][2]


🏁 Script executed:

# Search for validation or checks on exponent values
rg -i "exponent" packages/utils/src/sablier/ -A 2 -B 2

Repository: BuilderOSS/nouns-builder

Length of output: 9722


🏁 Script executed:

# Search for validation patterns in the entire utils/sablier directory
rg "validate|check|assert|throw.*exponent" packages/utils/src/sablier/ -A 3

Repository: BuilderOSS/nouns-builder

Length of output: 2184


🏁 Script executed:

# Check if there's a validation layer in related files
fd -e ts -p "sablier" packages/utils/src/ | xargs ls -la

Repository: BuilderOSS/nouns-builder

Length of output: 686


🌐 Web query:

Sablier V2 LockupDynamic contract exponent validation constraints

💡 Result:

In Sablier V2 LockupDynamic, the per-segment exponent is not range-validated by the LockupDynamic parameter-checking logic; it’s only constrained by its Solidity type:

  • Each segment stores exponent as UD2x18 (uint64-backed fixed-point). (basescan.org)
  • During stream creation, Helpers.checkCreateWithMilestones(... ) calls _checkSegments(...), and _checkSegments validates milestones ordering/future-ness and that depositAmount == sum(segment.amount)—but it does not check segment.exponent. (basescan.org)
  • Therefore, the effective exponent constraints are just PRBMath’s UD2x18 domain:
    • 0 <= exponent <= MAX_UD2x18
    • where uMAX_UD2x18 = 18_446_744_073_709_551_615 (i.e., 2^64 - 1 in 18-decimal fixed point, ≈ 18.446744073709551615) (basescan.org)

Citations:


UD2x18 overflow: exponent range 2-100 exceeds the maximum value constraint.

Sablier's UD2x18 format is backed by uint64 with 18 decimals, with a maximum representable value of approximately 18.446744073709551615. The comments claiming a valid range of 2-100 are incorrect—any exponent above ~18 will overflow when multiplied by 10^18 during encoding (lines 132, 174), producing invalid on-chain values that will likely cause transaction reverts.

The incorrect constraint is documented in multiple locations:

  • CreateWithDurationsLDParams (line 19)
  • CreateWithTimestampsLDParams (line 36)
  • Type comments in streams.ts

Add runtime validation before encoding and correct all exponent range comments to reflect the actual UD2x18 constraint (maximum ~18.44, not 100).

🤖 Prompt for AI Agents
In `@packages/utils/src/sablier/encoding.ts` around lines 13 - 19, The exponent
comment and type annotations claiming a range of 2-100 are wrong because UD2x18
is backed by uint64 with 18 decimals (max ~18.446744073709551615), so update all
exponent comments in CreateWithDurationsLDParams, CreateWithTimestampsLDParams
and related type comments in streams.ts to reflect the real UD2x18 maximum
(~18.44) and valid minimum (>=2); add runtime validation in the encoding flow
(the functions that convert exponent to UD2x18 before encoding — see the
encoding utilities where exponent is multiplied by 1e18) to check the exponent
is within [2, 18.446744073709551615] (or a safe integer upper bound like 18) and
throw a descriptive error if out of range, so invalid exponents are rejected
before any on-chain encoding/transaction is attempted.

Comment on lines 130 to 132
const batch = streams.map((stream) => {
// Convert exponent to UD2x18 format (multiply by 10^18)
const exponentUD2x18 = BigInt(stream.exponent) * BigInt(10 ** 18)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BigInt(stream.exponent) will throw at runtime for non-integer values.

BigInt() throws a RangeError when given a fractional number (e.g., BigInt(2.5)). Since exponent is typed as number, there's no compile-time guard. If the upstream schema or UI ever permits a decimal exponent, this will crash.

Consider either constraining the type or adding a guard:

Proposed fix
-    const exponentUD2x18 = BigInt(stream.exponent) * BigInt(10 ** 18)
+    const exponentUD2x18 = BigInt(Math.round(stream.exponent)) * 10n ** 18n
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const batch = streams.map((stream) => {
// Convert exponent to UD2x18 format (multiply by 10^18)
const exponentUD2x18 = BigInt(stream.exponent) * BigInt(10 ** 18)
const batch = streams.map((stream) => {
// Convert exponent to UD2x18 format (multiply by 10^18)
const exponentUD2x18 = BigInt(Math.round(stream.exponent)) * 10n ** 18n
🤖 Prompt for AI Agents
In `@packages/utils/src/sablier/encoding.ts` around lines 130 - 132, The code
currently does BigInt(stream.exponent) * BigInt(10 ** 18) inside the streams.map
batch, which will throw if stream.exponent is fractional; instead validate and
scale the float before converting to BigInt: inside the mapping for batch
(referencing streams.map and exponentUD2x18), check that stream.exponent is a
finite number (Number.isFinite) and not NaN, compute a scaled integer like const
scaled = Math.round(stream.exponent * 1e18) (or Math.floor/Math.trunc if a
specific rounding is required), then set exponentUD2x18 = BigInt(scaled);
optionally throw a clear error if validation fails so callers know the input is
invalid.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.tsx (2)

287-305: ⚠️ Potential issue | 🟡 Minor

handleSubmit cliff validation doesn't account for exponential mode.

When useExponential is true, cliff is irrelevant (the schema already skips cliff validation at Lines 126/177 of the schema file, and the LD encoding ignores it). However, Lines 299-305 here still validate cliffDuration > totalDuration unconditionally. If a user sets a cliff, then enables exponential mode, this handleSubmit check would reject the submission with a confusing error even though the cliff is unused.

Proposed fix
           if (!Number.isFinite(totalDuration) || totalDuration <= 0) {
             actions.setFieldError(
               'streams',
               `Stream #${i + 1}: Duration must be greater than 0 days.`
             )
             return
           }
-          if (cliffDuration < 0 || cliffDuration > totalDuration) {
+          if (!values.useExponential && (cliffDuration < 0 || cliffDuration > totalDuration)) {
             actions.setFieldError(
               'streams',
               `Stream #${i + 1}: Cliff must be within the total duration.`
             )
             return
           }

360-373: ⚠️ Potential issue | 🟡 Minor

shape doesn't reflect exponential mode in validation params.

At line 372, shape is derived only from cliff presence and never set to 'dynamicExponential' when useExponential is true. While validateBatchStreams only validates shape as a string (not its semantic value), the inconsistency means the shape won't accurately represent the actual vesting curve type, which could be problematic if shape is used for UI display or downstream processing. The actual transaction creation correctly uses the exponent parameter with LockupDynamic, but shape should be updated to maintain semantic accuracy.

🧹 Nitpick comments (7)
packages/ui/src/Accordion/AccordionItem.tsx (1)

91-104: Content may overflow during the opening animation.

When isOpen becomes true, overflow switches to visible immediately, but the height is still animating from 0 to auto. This can cause a brief flash where content is visible outside the expanding container.

Consider deferring overflow: 'visible' until after the open animation completes (e.g., via framer-motion's onAnimationComplete), or moving overflow into the animation variants so it transitions with the height.

♻️ Option: move overflow into variants
   const variants = {
     initial: {
       height: 0,
       paddingBottom: 0,
+      overflow: 'hidden',
     },
     animate: {
       height: 'auto',
       paddingBottom: 24,
+      overflow: 'visible',
+      transitionEnd: { overflow: 'visible' },
     },
   }

Then remove the inline overflow from the atoms(…) call:

         className={atoms({
           height: 'x0',
-          overflow: isOpen ? 'visible' : 'hidden',
           paddingLeft: 'x6',
           paddingRight: 'x6',
         })}

Using transitionEnd in framer-motion ensures overflow: 'visible' is applied only after the height animation completes, preventing content from spilling out mid-transition.

packages/ui/src/DecodedTransactions/ArgumentDisplay/StreamArgumentDisplay.tsx (2)

27-32: formatTimestamp lacks input validation and uses any type.

If a non-numeric or undefined value reaches this helper, new Date(NaN) renders as "Invalid Date". While most call sites guard with truthiness checks, consider adding a type annotation (number) and a safeguard.

Suggested improvement
-const formatTimestamp = (timestamp: any) =>
-  new Date(timestamp * 1000).toLocaleDateString('en-US', {
+const formatTimestamp = (timestamp: number) => {
+  const date = new Date(timestamp * 1000)
+  if (isNaN(date.getTime())) return 'N/A'
+  return date.toLocaleDateString('en-US', {
     month: 'short',
     day: 'numeric',
     year: 'numeric',
   })
+}

132-169: Extract a shared segment renderer to eliminate near-identical blocks.

The segmentsWithDuration (lines 132–169) and segments (lines 170–203) blocks differ only in rendering duration vs timestamp. A small helper would cut ~35 lines of duplication.

Example extraction
const renderSegment = (
  segment: any,
  segmentIndex: number,
  streamIndex: number,
  mode: 'duration' | 'timestamp'
) => (
  <Stack key={`segment-${streamIndex}-${segmentIndex}`} gap="x1">
    <Text fontWeight="heading">Segment #{segmentIndex + 1}</Text>
    <Stack pl="x2" gap="x1">
      {segment.amount && (
        <Flex align="center" gap="x1">
          <Text>amount:</Text>
          {tokenMetadata?.logo && (
            <img
              src={tokenMetadata.logo}
              alt={tokenMetadata.symbol}
              loading="lazy"
              decoding="async"
              width="16px"
              height="16px"
              style={{ maxWidth: '16px', maxHeight: '16px', objectFit: 'contain' }}
            />
          )}
          <Text>{formatAmount(BigInt(segment.amount))}</Text>
        </Flex>
      )}
      {segment.exponent && <Flex>exponent: {segment.exponent}</Flex>}
      {mode === 'duration' && segment.duration && (
        <Flex>duration: {formatStreamDuration(segment.duration)}</Flex>
      )}
      {mode === 'timestamp' && segment.timestamp && (
        <Flex>timestamp: {formatTimestamp(segment.timestamp)}</Flex>
      )}
    </Stack>
  </Stack>
)
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokensDetailsDisplay.tsx (1)

5-9: Unused streamCount prop in type definition.

streamCount is still declared in the props interface (Line 8) but is no longer destructured or rendered. Meanwhile, the caller (StreamTokens.tsx Line 649) still passes it. Consider removing it from both the type and the call site to avoid confusion.

Proposed fix
 export const StreamTokensDetailsDisplay: React.FC<{
   balanceError?: string
   totalStreamAmountWithSymbol?: string
-  streamCount?: number
-}> = ({ totalStreamAmountWithSymbol, balanceError }) => {
+}> = ({ totalStreamAmountWithSymbol, balanceError }) => {

And in StreamTokens.tsx:

 <StreamTokensDetailsDisplay
   balanceError={balanceError}
   totalStreamAmountWithSymbol={totalAmountString}
-  streamCount={formik.values.streams.length}
 />
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.schema.ts (1)

89-102: Biome noThenProperty lint warning is a false positive here.

The then key is part of Yup's standard .when() configuration API ({ is, then, otherwise }), not a thenable/Promise property. This is idiomatic Yup usage. If Biome is enforced in CI, you may need to suppress it with an inline comment.

Suppression if needed
     exponent: yup
       .number()
       .optional()
       .when('useExponential', {
         is: true,
+        // biome-ignore lint/suspicious/noThenProperty: Yup .when() API uses `then`
         then: (schema) =>
           schema
             .required('Exponent is required when using exponential curve')
packages/ui/src/Graph/StreamGraph.tsx (1)

140-199: renderTooltip is recreated on every render.

This closure captures width, height, and symbol, but isn't wrapped in useCallback. If BaseGraph performs any reference-equality checks on renderTooltip (e.g., via React.memo), this could cause unnecessary re-renders of the graph.

Wrap in useCallback
- const renderTooltip = (
+ const renderTooltip = React.useCallback((
     index: number,
     data: GraphDataPoint[],
     cursorOpacity: number
   ) => {
     ...
-  }
+  }, [width, height, symbol])
packages/create-proposal-ui/src/components/TransactionForm/StreamTokens/StreamTokens.tsx (1)

776-822: Exponential toggle UI looks good.

Auto-initializing exponent to 2 when enabling the toggle (Line 791) is a nice UX touch. The conditional exponent input with range [2, 100] aligns with the schema validation.

One consideration: when the user unchecks "Exponential Curve", the exponent value remains in form state. This is fine since the encoding path checks values.useExponential && values.exponent (Line 441), so the stale exponent is ignored. However, you may want to clear it for a cleaner form state:

Optional: clear exponent when disabling
 onChange={(e) => {
   formik.setFieldValue('useExponential', e.target.checked)
   // Set default exponent when enabling
   if (e.target.checked && !formik.values.exponent) {
     formik.setFieldValue('exponent', 2)
   }
+  if (!e.target.checked) {
+    formik.setFieldValue('exponent', undefined)
+  }
 }}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant