Skip to content

feat(payments): handle Token-2022 transfer fees and scaled UI amounts on single transfers - #2491

Open
multipletwigs wants to merge 17 commits into
feat/payment-transfers-ui-amount-multiplierfrom
feat/t22-transfer-fee-scaled-ui
Open

multipletwigs wants to merge 17 commits into
feat/payment-transfers-ui-amount-multiplierfrom
feat/t22-transfer-fee-scaled-ui

Conversation

@multipletwigs

@multipletwigs multipletwigs commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Single transfers now handle Token-2022 transfer-fee and scaled-UI mints, and transfer responses report the fee.

  • Fee mints sign TransferCheckedWithFee with the epoch's expected fee, so an issuer fee change fails the send (FeeMismatch) instead of settling short.
  • Scaled mints convert UI to base units at the Clock's multiplier via token-2022's f64 helper (truncates; loses precision above 2^53 base units); rounded up one base unit when truncation would show less; responses add unscaledAmount, shown as an Underlying amount row.
  • transferFee / receivedAmount are decoded from the saved signed_transaction: no new columns, present only when a fee is stated.
  • fee is now the network fee priced with sponsoredNetworkFee before the send, not the budget ledger (its settled lamports include ATA rent).
  • /v1/transactions rows gain optional tokenSymbol from Helius DAS; it is issuer-controlled, so well-known mints keep priority in the dashboard.
  • Known gap: off-ramp deposits on fee mints arrive short of the provider's amount; not handled here.

API after (local): POST /v1/payments/transfers 10 SDPFEE → "amount": "10", "transferFee": "0.1"; GET /v1/transactions → "token": "So111…", "tokenSymbol": "SOL".

Verification: typecheck passes in every package except sdp-web's stale local .next/types; check:value-movement passes; local app: a 10 SDPFEE devnet send confirmed with transferFee: "0.1". Tests: 18 new cases across token-accounts, mappers, DAS and transactions suites, plus updated transfer route suites; vitest not run locally (CI runs it); Pi browser verify and screenshots not run.

CleanShot 2026-10-22 at 12 23 15 AM@2x CleanShot 2026-10-22 at 12 33 55 AM@2x

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

React Doctor found 3 new issues in 3 files · 3 warnings · score 93 / 100 (Great) · 0 fixed · vs main

3 warnings

src/app/dashboard/[projectId]/payments/ramps/components/onchain-send-step-content.redesign.tsx

  • ⚠️ L366 React function has high control-flow complexity no-high-complexity-react-function

src/app/dashboard/[projectId]/payments/transactions/transaction-detail-workspace.tsx

  • ⚠️ L347 React function has high control-flow complexity no-high-complexity-react-function

src/app/dashboard/[projectId]/payments/transactions/unverified-mint-warning.tsx

  • ⚠️ L16 Non-component export in component file only-export-components

Reviewed by React Doctor for commit bbc322d. See inline comments for fixes.

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
sdp-docs Ready Ready Preview Oct 21, 2026 5:03pm UTC
sdp-web Ready Ready Preview Oct 21, 2026 5:03pm UTC

Request Review

@greptile-apps

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 3/5

[Medium impact] The changes affect token transfers and payment transaction views.

Fix the payment-request test setup and the misleading received-amount display before merging.

Findings

  1. P1 Payment-request test now fails ▶
  2. P1 Failed transfers look paid ▶

Summary

Adds Token-2022 fee-aware transfers and scaled amount conversion.

  • Single token transfers use the mint’s fee and scaled amount rules.
  • Payment requests and batches refuse mints with unsupported extensions.
  • Transfer records and dashboard details show fees and underlying amounts.
  • Transaction rows can show mint symbols from Helius DAS.

Reviews (3) · Last reviewed commit: "feat(web): align the unverified-mint war..." · Reviewed by Greptile

Comment on lines +91 to +98
const transferFee =
signed.fee === null ? undefined : formatDecimalAmount(signed.fee, signed.decimals);
const movedAmount = formatDecimalAmount(signed.amount, signed.decimals);
const scaled = compareDecimalAmounts(movedAmount, row.amount) !== 0;
return {
transferFee,
receivedAmount:
transferFee === undefined ? undefined : subtractDecimalAmounts(row.amount, transferFee),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Recipient amounts use different scales

receivedAmount subtracts an unscaled fee from a scaled amount. Custom mints can enable both extensions. With a multiplier of 2 and a 1% fee, sending 10 moves 5 underlying tokens and withholds 0.05. The recipient gets 9.9 displayed tokens, but this returns 9.95.

Convert the fee and received amount to the same UI scale before returning them, and add a test with both extensions.

Comment on lines +91 to +98
const transferFee =
signed.fee === null ? undefined : formatDecimalAmount(signed.fee, signed.decimals);
const movedAmount = formatDecimalAmount(signed.amount, signed.decimals);
const scaled = compareDecimalAmounts(movedAmount, row.amount) !== 0;
return {
transferFee,
receivedAmount:
transferFee === undefined ? undefined : subtractDecimalAmounts(row.amount, transferFee),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Scaled fees show wrong amounts

receivedAmount subtracts an underlying fee from a scaled UI amount. Sending 10 with multiplier 2 and a 1% fee moves 5 underlying tokens and withholds 0.05. The recipient sees 9.9, but this returns 9.95 and labels the fee as 0.05.

Convert the fee and received balance into the same units as amount, and add a test combining both extensions.

Comment on lines +375 to +376
<RecordRow label={t("DashboardPayments.transactionDetail.recipientReceived")}>
{receivedAfterFee.received}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Failed transfers look paid

The new “Recipient received” row also appears for failed and still-processing transfers. signedTokenTransferFields reads the signed instructions regardless of status, and signed bytes remain stored when a send fails. A rejected transfer can therefore show a positive amount as received even though no tokens moved.

Show this row only after successful confirmation, or clearly label it as an expected amount.

Comment on lines +76 to +78
const symbolsByMint = await lookupTokenSymbols(
result.rows.flatMap((row) => (row.token === null ? [] : [row.token]))
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Token labels delay transaction lists

/v1/transactions now waits for optional token symbols before returning its database rows. The DAS request has no timeout, and the cache shares unfinished requests. If Helius stalls, this list and later reads for the same tokens remain waiting.

Add a bounded timeout so the list can return without symbols.

@Windscar Windscar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good work. The transfer construction looks sound, but the API reports the wrong recipient amount when a mint has both scaled UI amounts and transfer fees.

Reviewed: Token-2022 amount and fee calculation, signed transaction decoding, off-ramp funding, sponsorship fee reporting, and dashboard presentation.

Required for approval:

  • Express transferFee and receivedAmount in the same scaled UI units for mints with both extensions; see inline comment.

return {
transferFee,
receivedAmount:
transferFee === undefined ? undefined : subtractDecimalAmounts(row.amount, transferFee),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

row.amount is the requested scaled UI amount, while transferFee comes from the signed instruction in underlying units. With a 10× multiplier, a request for 100 UI units moves 10 underlying units; if 1 is withheld, the recipient gets 90 UI units, but this returns receivedAmount: "99" and transferFee: "1". Please calculate both displayed values in the same units, using the transferred amount after the fee. A test with both extensions would catch this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Addressed on the current head: transferFee and receivedAmount now both use underlying units, with matching API documentation and dashboard labels. I'm superseding this finding with a review of the new head.

@Windscar Windscar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good work. The scaled fee calculation from my earlier review is addressed: the fee and received amount now use underlying units, and the contract and UI labels say so.

Reviewed: Token-2022 conversion and signed amounts, failed submission state, payment and batch scoping, transaction symbols, and dashboard presentation.

Required for approval:

  • Show receivedAmount as received only after successful settlement; see the mapper comment.
  • Guard scaled transfers whose f64 conversion still displays less than the requested amount; see the conversion comment.
  • Warn when a transaction view displays a self-declared mint symbol, including the classic list and the detail's primary amount; see the UI comments.

receivedAmount:
signed.fee === null
? undefined
: formatDecimalAmount(signed.amount - signed.fee, signed.decimals),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is derived from saved signed bytes regardless of row.status. Those bytes are persisted before broadcast and retained on a definite submission or chain failure, so a failed or still-processing transfer can return a positive receivedAmount; the dashboard then says ‘Recipient received’ even though nothing settled. Gate the received field on the canonical successful status predicate, or label it as an expected amount until confirmation.

multiplier: number
): bigint {
const shownUiUnits = BigInt(Math.trunc(Number(truncatedBaseUnits) * multiplier));
return shownUiUnits < requestedUiUnits ? truncatedBaseUnits + 1n : truncatedBaseUnits;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding one base unit does not always make the displayed amount reach the request because Number(raw + 1n) can equal Number(raw). With 6 decimals, multiplier 0.001, and request 90000000.000002, the pinned conversion helper gives 90000000000001984n; both that value and the incremented value display as 90000000.000001. This signs a transfer that delivers less than requested. Please keep incrementing until the display check passes, or reject amounts where the f64 conversion cannot guarantee it.

: transaction.token === null
? transaction.amount
: `${transaction.amount} ${resolveTokenByMint(transaction.token, issuedTokensByMint).tokenName}`}
: `${transaction.amount} ${resolveTokenByMint(transaction.token, issuedTokensByMint, transaction.tokenSymbol).tokenName}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This now shows the mint's self-declared DAS symbol for an unknown mint without the unverified-mint warning added to the redesign list. A mint claiming a familiar symbol can therefore look verified in this transactions view. Please apply the same warning here.

<RecordRow
label={t("DashboardPayments.transactionDetail.scaledAmount")}
hint={t("DashboardPayments.transactionDetail.scaledAmountHint", scaled)}
valueAccessory={unverifiedToken ? <UnverifiedMintWarning /> : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The warning is attached only to the underlying-amount row, which is absent for ordinary unscaled mints. The primary amount and page title still show the self-declared symbol with no warning. Please put the warning beside the primary amount whenever unverifiedToken is true.

… on single transfers

Transfers convert scaled UI amounts with the multiplier in effect at the
Clock, state the expected fee with TransferCheckedWithFee on fee mints,
and report transferFee, receivedAmount and the priced network fee.
Unified transaction rows carry the mint's DAS symbol.
Transfer responses carry unscaledAmount and uiAmountMultiplier, decoded
from the signed TransferChecked instruction, and the send screen and
transaction detail show a Scaled amount row with an info hint.
…ansfer rows

A scaled mint's truncated conversion could show the recipient one UI unit
short; one more base unit covers it. A transaction with several Token-2022
transfers (a batch chunk) no longer reports one recipient's terms as the
row's.
…a derived multiplier

amount divided by whole base units only approximates the mint's
multiplier (500 / 499.975002 = 1.000049998 on a 1.00005 mint), so the
response drops uiAmountMultiplier and the row reads the underlying
amount and what it is shown as.
…nd flag unverified mints

Payment requests and batches refuse scaled-UI and fee mints until they build
and settle against the signed terms. Fee fields come from the signed base
units, so they are underlying amounts on scaled mints. DAS symbol lookups get
a 3s deadline and skip native and well-known mints; out-of-range scaled
amounts are a 400. The transaction detail flags names that only the mint's
own metadata vouches for.
@multipletwigs
multipletwigs force-pushed the feat/t22-transfer-fee-scaled-ui branch from a1b1270 to bbc322d Compare October 21, 2026 17:02
@multipletwigs
multipletwigs changed the base branch from main to feat/payment-transfers-ui-amount-multiplier October 21, 2026 17:02
@@ -361,14 +365,26 @@ function DetailsStep({ wizard, contact, privateSend, onPayByBank }: StepProps) {

function ReviewSummary({ wizard, counterpartyName }: StepProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-high-complexity-react-function (warning)

ReviewSummary has cyclomatic complexity 17, cognitive complexity 17, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.

Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.

Docs

* @param tokenSymbol - The row's metadata symbol, when the API resolved one.
* @returns True when the name shown is only the mint's self-declared symbol.
*/
export function isUnverifiedMintName(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs

const wallet = resolveWallet(scope.wallets, body.walletId);
const token = normalizePaymentToken(body.token);
if (token !== SOL_MINT) {
await assertMintMovesUnscaled(assertValidAddress(token, "token"), "Payment requests");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Payment-request test now fails

The USDC create test in payments.requests.idempotency.test.ts expects 201, but its setup only mocks the old RPC wrappers. The new guard calls fetchEncodedAccount(createClusterRpc(), mint) directly, while global test setup clears every managed RPC endpoint. The request therefore fails before creating the row, breaking the suite in CI.

Install the cluster RPC stub and answer the mint read in this suite, as the updated transfer suites do.

@keeganthomp keeganthomp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: PYUSD/USDG payment requests and batches now 400 (token-accounts.ts:311), and three paths re-read RPC state they already hold (handlers.ts:713, resolve.ts:75, token-accounts.ts:514); nothing in this stack writes #2493's ui_amount_multiplier, so is it still needed?

// biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret.
isExtension("ScaledUiAmountConfig", extension)
),
transferFeeConfig: extensions.find((extension) => isExtension("TransferFeeConfig", extension)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PYUSD and USDG both carry a TransferFeeConfig at 0 bps (mainnet and devnet), so assertMintMovesUnscaled now 400s their payment requests and batches, and every single PYUSD/USDG transfer reports transferFee: "0". Count only a config that can charge, which puts them back on main's TransferChecked:

Suggested change
transferFeeConfig: extensions.find((extension) => isExtension("TransferFeeConfig", extension)),
// PYUSD and USDG carry a 0 bps config; only one that can charge changes the amount.
transferFeeConfig: extensions.find(
(extension): extension is TransferFeeConfig =>
isExtension("TransferFeeConfig", extension) &&
[extension.olderTransferFee, extension.newerTransferFee].some(
(fee) => fee.transferFeeBasisPoints > 0 && fee.maximumFee > 0n
)
),

const partiallySigned = await partiallySignTransactionMessageWithSigners(message);
const txEncoder = getTransactionEncoder();
const txBytes = new Uint8Array(txEncoder.encode(partiallySigned));
const networkFee = await sponsoredNetworkFee(txBytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On managed deployments BudgetedFeePayment.prepareAdmission already prices these exact bytes with sponsoredNetworkFee, so every single transfer now pays a second, sequential getFeeForMessage. Have prepareOwnedSubmission return the fee it already priced (self-hosted would price there) instead of calling it again here.

Comment on lines +75 to 76
await assertMintMovesUnscaled(mintAddress, "Batch transfers");
const tokenProgram = await resolveMintTokenProgram(rpc, mintAddress);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

assertMintMovesUnscaled fetches the mint, then resolveMintTokenProgram fetches it again on the next line: two getAccountInfo calls for one account per batch. Once the assert returns the TransferMint it read, this becomes (and the resolveMintTokenProgram import goes):

Suggested change
await assertMintMovesUnscaled(mintAddress, "Batch transfers");
const tokenProgram = await resolveMintTokenProgram(rpc, mintAddress);
const { tokenProgram } = await assertMintMovesUnscaled(mintAddress, "Batch transfers");

source?: SplTransferSource;
}) {
const rpc = createClusterRpc();
const transferMint = await readTransferMint(input.mint);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A ramp deposit passes source because assertRampDepositFunded already read the mint (and Clock), but this re-reads both, where main reused source.tokenProgram. Carry the TransferMint on SplTransferSource and read only when it's absent.

unscaledAmount: z.string().optional().openapi({
description:
"The base units the transfer moved, at the mint's decimals, before its Token-2022 scaled UI amount multiplier. Present only when the mint scaled `amount`.",
example: "5",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the combined example can't happen: with unscaledAmount 5, underlying receivedAmount can't be 99.75.

This branch was successfully deployed

2 active deployments
Preview – sdp-web — bbc322d5 Deployed Oct 21, 2026 by vercel[bot]
Preview – sdp-docs — bbc322d5 Deployed Oct 21, 2026 by vercel[bot]
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.

3 participants