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
34 changes: 34 additions & 0 deletions cross-app-connect/src/app/cross-app/_lib/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export async function decodeClause(
thor: ThorClient | null,
network: NETWORK_TYPE,
self?: string,
/** Lowercased address of the generic delegator's deposit account. When
* set, clauses transferring VET / VTHO / B3TR / VOT3 to this address
* are re-labelled as "Pay transaction fee" instead of an opaque
* "Send X VET to 0x86…fa" — the user understands the clause exists
* to fund the gas payer, not as a separate transfer. */
feeDepositAccount?: string,
): Promise<DecodedClause> {
const data = (clause.data ?? '0x').toLowerCase();
const value = (() => {
Expand All @@ -123,9 +129,25 @@ export async function decodeClause(
}
})();

const isFeeDeposit = (recipient: string): boolean =>
!!feeDepositAccount &&
recipient.toLowerCase() === feeDepositAccount;

// 1. Native VET transfer
if ((data === '0x' || data === '') && value > ZERO) {
const amount = formatUnits(value, 18);
if (isFeeDeposit(clause.to)) {
return {
kind: 'known_action',
category: 'fee',
recipient: clause.to,
summary: t('action.fee.payTransactionFee'),
detail: t('action.fee.amount', {
amount: trimAmount(amount),
symbol: 'VET',
}),
};
}
return {
kind: 'native_transfer',
recipient: clause.to,
Expand Down Expand Up @@ -157,6 +179,18 @@ export async function decodeClause(
bigint,
];
const amount = formatUnits(raw, token.decimals);
if (isFeeDeposit(recipient)) {
return {
kind: 'known_action',
category: 'fee',
recipient,
summary: t('action.fee.payTransactionFee'),
detail: t('action.fee.amount', {
amount: trimAmount(amount),
symbol: token.symbol,
}),
};
}
Comment on lines +182 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict fee relabeling to supported gas tokens only.

Right now, any ERC‑20 transfer to the deposit account is shown as “Pay transaction fee”. That can hide a malicious transfer of an unrelated token. Gate this branch to known fee tokens (e.g., VTHO/B3TR/VOT3 by address) before returning category: 'fee'.

Suggested guard
-                    if (isFeeDeposit(recipient)) {
+                    const isSupportedFeeToken =
+                        ['VTHO', 'B3TR', 'VOT3'].includes(token.symbol.toUpperCase());
+                    if (isFeeDeposit(recipient) && isSupportedFeeToken) {
                         return {
                             kind: 'known_action',
                             category: 'fee',
📝 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
if (isFeeDeposit(recipient)) {
return {
kind: 'known_action',
category: 'fee',
recipient,
summary: t('action.fee.payTransactionFee'),
detail: t('action.fee.amount', {
amount: trimAmount(amount),
symbol: token.symbol,
}),
};
}
const isSupportedFeeToken =
['VTHO', 'B3TR', 'VOT3'].includes(token.symbol.toUpperCase());
if (isFeeDeposit(recipient) && isSupportedFeeToken) {
return {
kind: 'known_action',
category: 'fee',
recipient,
summary: t('action.fee.payTransactionFee'),
detail: t('action.fee.amount', {
amount: trimAmount(amount),
symbol: token.symbol,
}),
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cross-app-connect/src/app/cross-app/_lib/decoder.ts` around lines 182 - 193,
The branch that treats any ERC‑20 transfer to the deposit account as a fee must
be guarded by a whitelist of known gas token addresses; update the condition in
the decoder (the block using isFeeDeposit(recipient) that returns kind:
'known_action', category: 'fee') to also check that the token being transferred
is one of the supported fee tokens (e.g., via an isKnownGasToken(token.address)
helper or a constant set of addresses for VTHO/B3TR/VOT3), handling
casing/normalization and the possibility token or token.address is undefined;
only return the fee-labeled object when both isFeeDeposit(recipient) and the
gas-token whitelist check pass, otherwise fall through to the normal transfer
handling.

return {
kind: 'token_transfer',
recipient,
Expand Down
3 changes: 2 additions & 1 deletion cross-app-connect/src/app/cross-app/_lib/knownActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type KnownActionCategory =
| 'nft'
| 'token'
| 'staking'
| 'swap';
| 'swap'
| 'fee';

/**
* Structured side-channel that travels alongside the localized summary so
Expand Down
31 changes: 31 additions & 0 deletions cross-app-connect/src/app/cross-app/_lib/thor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,48 @@ const NETWORK = {
main: {
nodeUrl: 'https://mainnet.vechain.org',
accountFactoryAddress: '0xC06Ad8573022e2BE416CA89DA47E8c592971679A',
genericDelegatorUrl: 'https://mainnet.delegator.vechain.org/api/v1/',
},
test: {
nodeUrl: 'https://testnet.vechain.org',
accountFactoryAddress: '0x713b908Bcf77f3E00EFEf328E50b657a1A23AeaF',
genericDelegatorUrl: 'https://testnet.delegator.vechain.org/api/v1/',
},
} as const;

export const networkType = NETWORK_TYPE;
export const networkConfig = NETWORK[NETWORK_TYPE];
export const thor = ThorClient.at(networkConfig.nodeUrl);

// Fetch the generic delegator's current deposit account once per page load.
// The recogniser in decoder.ts uses the result to re-label clauses sending
// gas tokens to that address as "Pay transaction fee" instead of an opaque
// "Send X VET to 0x86…fa", so the user understands what they're paying.
let depositAccountPromise: Promise<string | null> | null = null;
export async function fetchGenericDelegatorDepositAccount(): Promise<
string | null
> {
if (!depositAccountPromise) {
depositAccountPromise = (async () => {
try {
const res = await fetch(
new URL(
'deposit/account',
networkConfig.genericDelegatorUrl,
),
{ method: 'GET', headers: { 'Content-Type': 'application/json' } },
);
if (!res.ok) return null;
const data = (await res.json()) as { depositAccount?: string };
return data.depositAccount?.toLowerCase() ?? null;
} catch {
return null;
}
})();
}
return depositAccountPromise;
}

// Minimal ABI for SocialLoginSmartAccountFactory.getAccountAddress. Inlined
// to avoid pulling the full `@vechain/vechain-contract-types` package; this
// single read is all the host needs.
Expand Down
15 changes: 14 additions & 1 deletion cross-app-connect/src/app/cross-app/transact/TransactClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type Risk,
} from '../_lib/labels';
import {
fetchGenericDelegatorDepositAccount,
getChainId,
getSmartAccountAddress,
networkType,
Expand Down Expand Up @@ -387,9 +388,21 @@ export function TransactClient() {
const selfAddress = smartAccount?.address;
let cancelled = false;
(async () => {
// Resolve the generic delegator's deposit account so transfers
// funding the gas payer get re-labelled as "Pay transaction fee".
// Cached after the first call; null if the delegator is
// unreachable (decoder gracefully falls back to the raw label).
const feeDepositAccount =
(await fetchGenericDelegatorDepositAccount()) ?? undefined;
const results = await Promise.all(
parsed.clauses.map((c) =>
decodeClause(c, thor, networkType, selfAddress),
decodeClause(
c,
thor,
networkType,
selfAddress,
feeDepositAccount,
),
),
);
if (!cancelled) setDecoded(results);
Expand Down
4 changes: 4 additions & 0 deletions cross-app-connect/src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"native": "Send {{amount}} VET",
"token": "Send {{amount}} {{symbol}}"
},
"fee": {
"payTransactionFee": "Pay transaction fee",
"amount": "{{amount}} {{symbol}} to the gas payer"
},
"approve": {
"unlimited": "Allow unlimited {{symbol}} spending",
"upTo": "Allow spending up to {{amount}} {{symbol}}"
Expand Down
4 changes: 4 additions & 0 deletions cross-app-connect/src/app/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"native": "Invia {{amount}} VET",
"token": "Invia {{amount}} {{symbol}}"
},
"fee": {
"payTransactionFee": "Paga fee per transazione",
"amount": "{{amount}} {{symbol}} al pagatore del gas"
},
"approve": {
"unlimited": "Consenti spesa illimitata di {{symbol}}",
"upTo": "Consenti spesa fino a {{amount}} {{symbol}}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,16 @@ export const ChooseNameSummaryContent = ({
const [userSelectedGasToken, setUserSelectedGasToken] =
React.useState<GasTokenType | null>(null);

// VeChain pays gas for domain CLAIMS via the kit-sponsored delegator
// (see useClaimVetDomain / useClaimVeWorldSubdomain). The unset path
// (useUnsetDomain) is NOT sponsored — the user pays — so we still
// need the gas-token UI and balance check for that case. Drives:
// skip estimation, hide GasFeeSummary, force hasEnoughGasBalance,
// and suppress gas-estimation errors.
const KIT_PAYS_GAS = !isUnsetting;

const shouldEstimateGas =
!KIT_PAYS_GAS &&
preferences.availableGasTokens.length > 0 &&
(connection.isConnectedWithPrivy ||
connection.isConnectedWithVeChain) &&
Expand All @@ -182,6 +191,7 @@ export const ChooseNameSummaryContent = ({
});
const usedGasToken = gasEstimation?.usedToken;
const disableConfirmButtonDuringEstimation =
!KIT_PAYS_GAS &&
(gasEstimationLoading || !gasEstimation) &&
connection.isConnectedWithPrivy &&
!feeDelegation?.delegatorUrl;
Expand All @@ -196,7 +206,8 @@ export const ChooseNameSummaryContent = ({
);

// hasEnoughBalance is now determined by the hook itself
const hasEnoughBalance = !!usedGasToken && !gasEstimationError;
const hasEnoughBalance =
KIT_PAYS_GAS || (!!usedGasToken && !gasEstimationError);

// Auto-fallback: if the selected token cannot cover fees (estimation error),
// clear selection to re-estimate across all available tokens
Expand Down Expand Up @@ -256,7 +267,7 @@ export const ChooseNameSummaryContent = ({
</Text>
</VStack>
)}
{connection.isConnectedWithPrivy && (
{!KIT_PAYS_GAS && connection.isConnectedWithPrivy && (
<GasFeeSummary
estimation={gasEstimation}
isLoading={gasEstimationLoading}
Expand Down Expand Up @@ -291,6 +302,7 @@ export const ChooseNameSummaryContent = ({
hasEnoughGasBalance={hasEnoughBalance}
isLoadingGasEstimation={gasEstimationLoading}
showGasEstimationError={
!KIT_PAYS_GAS &&
!feeDelegation?.delegatorUrl &&
connection.isConnectedWithPrivy
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,13 @@ export const CustomizationSummaryContent = ({
const [userSelectedGasToken, setUserSelectedGasToken] =
React.useState<GasTokenType | null>(null);

// VeChain pays gas for profile updates via the kit-sponsored delegator
// (see useUpdateTextRecord). Skip the gas-token UI and "do you have
// enough VTHO" check so users with no gas tokens can still proceed.
const KIT_PAYS_GAS = true;

const shouldEstimateGas =
!KIT_PAYS_GAS &&
preferences.availableGasTokens.length > 0 &&
(connection.isConnectedWithPrivy ||
connection.isConnectedWithVeChain) &&
Expand All @@ -226,6 +232,7 @@ export const CustomizationSummaryContent = ({
});
const usedGasToken = gasEstimation?.usedToken;
const disableConfirmButtonDuringEstimation =
!KIT_PAYS_GAS &&
(gasEstimationLoading || !gasEstimation) &&
connection.isConnectedWithPrivy &&
!feeDelegation?.delegatorUrl;
Expand All @@ -240,7 +247,8 @@ export const CustomizationSummaryContent = ({
);

// hasEnoughBalance is now determined by the hook itself
const hasEnoughBalance = !!usedGasToken && !gasEstimationError;
const hasEnoughBalance =
KIT_PAYS_GAS || (!!usedGasToken && !gasEstimationError);

// Auto-fallback: if the selected token cannot cover fees (estimation error),
// clear selection to re-estimate across all available tokens
Expand Down Expand Up @@ -377,7 +385,7 @@ export const CustomizationSummaryContent = ({
renderField(t('Website'), changes.website)}
{changes.email && renderField(t('Email'), changes.email)}
</VStack>
{connection.isConnectedWithPrivy && (
{!KIT_PAYS_GAS && connection.isConnectedWithPrivy && (
<GasFeeSummary
estimation={gasEstimation}
isLoading={gasEstimationLoading}
Expand Down Expand Up @@ -407,6 +415,7 @@ export const CustomizationSummaryContent = ({
hasEnoughGasBalance={hasEnoughBalance}
isLoadingGasEstimation={gasEstimationLoading}
showGasEstimationError={
!KIT_PAYS_GAS &&
!feeDelegation?.delegatorUrl &&
connection.isConnectedWithPrivy
}
Expand Down
Loading
Loading