diff --git a/packages/chain-adapters/src/sui/SuiChainAdapter.ts b/packages/chain-adapters/src/sui/SuiChainAdapter.ts index a4f1c1c4fee..270f7934c6f 100644 --- a/packages/chain-adapters/src/sui/SuiChainAdapter.ts +++ b/packages/chain-adapters/src/sui/SuiChainAdapter.ts @@ -135,25 +135,7 @@ export class ChainAdapter implements IChainAdapter { const tokens = await Promise.all( nonZeroBalances.map(async balance => { const symbol = balance.coinType.split('::').pop() ?? 'UNKNOWN' - - // Normalize coinType to ensure proper format with leading zeros - // SUI addresses should be 66 chars (0x + 64 hex chars) - const normalizeCoinType = (coinType: string): string => { - const parts = coinType.split('::') - if (parts.length < 2) return coinType - - const address = parts[0] - if (!address.startsWith('0x')) return coinType - - // Pad address to 66 characters (0x + 64 hex digits) - const hexPart = address.slice(2) - const paddedHex = hexPart.padStart(64, '0') - parts[0] = `0x${paddedHex}` - - return parts.join('::') - } - - const normalizedCoinType = normalizeCoinType(balance.coinType) + const normalizedCoinType = this.normalizeCoinType(balance.coinType) const assetId = toAssetId({ chainId: this.chainId, @@ -521,9 +503,102 @@ export class ChainAdapter implements IChainAdapter { return } + // Normalize SUI coin type to ensure consistent AssetId generation + // SUI addresses should be 66 characters (0x + 64 hex chars) with leading zeros + // Example: 0x2::sui::SUI stays the same, but 0xdba3::usdc::USDC becomes 0x0000...0dba3::usdc::USDC + private normalizeCoinType(coinType: string): string { + const parts = coinType.split('::') + if (parts.length < 2) return coinType + + const address = parts[0] + if (!address.startsWith('0x')) return coinType + + const hexPart = address.slice(2) + const paddedHex = hexPart.padStart(64, '0') + parts[0] = `0x${paddedHex}` + + return parts.join('::') + } + + private parseProgrammableTransactionBlock(tx: SuiTransactionBlockResponse): { + transferAmount: string | undefined + recipient: string | undefined + coinType: string | undefined + } { + const ptb = tx.transaction?.data.transaction + if (ptb?.kind !== 'ProgrammableTransaction') { + return { transferAmount: undefined, recipient: undefined, coinType: undefined } + } + + const inputs = ptb.inputs ?? [] + const commands = ptb.transactions ?? [] + + let transferAmount: string | undefined + let recipient: string | undefined + let coinObjectId: string | undefined + + for (const command of commands) { + if ('SplitCoins' in command) { + const [coinSource, amounts] = command.SplitCoins + const firstAmount = amounts?.[0] + if (!firstAmount || typeof firstAmount !== 'object' || !('Input' in firstAmount)) continue + + const amountInput = inputs[firstAmount.Input] + if (amountInput?.type === 'pure' && amountInput.valueType === 'u64') { + const value = amountInput.value + if (typeof value === 'string') { + transferAmount = value + } + } + + // For token transfers, coin source is an object input + if (typeof coinSource === 'object' && 'Input' in coinSource) { + const coinInput = inputs[coinSource.Input] + if (coinInput?.type === 'object') { + const objectId = coinInput.objectId + if (typeof objectId === 'string') { + coinObjectId = objectId + } + } + } + } + + if ('TransferObjects' in command) { + const [_objects, recipientArg] = command.TransferObjects + if (!recipientArg || typeof recipientArg !== 'object' || !('Input' in recipientArg)) + continue + + const recipientInput = inputs[recipientArg.Input] + if (recipientInput?.type === 'pure' && recipientInput.valueType === 'address') { + const value = recipientInput.value + if (typeof value === 'string') { + recipient = value + } + } + } + } + + // Extract coin type from objectChanges if we have a coin object ID + const coinType = (() => { + if (!coinObjectId) return undefined + + const objectChange = tx.objectChanges?.find( + change => 'objectId' in change && change.objectId === coinObjectId, + ) + + if (!objectChange || !('objectType' in objectChange)) return undefined + + const match = objectChange.objectType.match(/0x2::coin::Coin<(.+)>/) + const extractedCoinType = match?.[1] + + return extractedCoinType ? this.normalizeCoinType(extractedCoinType) : undefined + })() + + return { transferAmount, recipient, coinType } + } + async parseTx(txHashOrTx: unknown, pubkey: string): Promise { try { - // Fetch full transaction data if only txHash was provided const tx = typeof txHashOrTx === 'string' ? await this.client.getTransactionBlock({ @@ -532,12 +607,12 @@ export class ChainAdapter implements IChainAdapter { showInput: true, showEffects: true, showBalanceChanges: true, + showObjectChanges: true, }, }) : (txHashOrTx as SuiTransactionBlockResponse) const sender = tx.transaction?.data.sender ?? '' - const txid = tx.digest const blockHeight = Number(tx.checkpoint ?? 0) const blockTime = tx.timestampMs ? Math.floor(Number(tx.timestampMs) / 1000) : 0 @@ -545,16 +620,17 @@ export class ChainAdapter implements IChainAdapter { const latestCheckpoint = await this.client.getLatestCheckpointSequenceNumber() const confirmations = tx.checkpoint ? Number(latestCheckpoint) - Number(tx.checkpoint) + 1 : 0 - const status = - tx.effects?.status.status === 'success' - ? TxStatus.Confirmed - : tx.effects?.status.status === 'failure' - ? TxStatus.Failed - : TxStatus.Unknown + const status = (() => { + const txStatus = tx.effects?.status.status + if (txStatus === 'success') return TxStatus.Confirmed + if (txStatus === 'failure') return TxStatus.Failed + return TxStatus.Unknown + })() const gasUsed = tx.effects?.gasUsed - const fee = gasUsed - ? { + const fee = !gasUsed + ? undefined + : { assetId: this.assetId, value: ( BigInt(gasUsed.computationCost) + @@ -562,15 +638,33 @@ export class ChainAdapter implements IChainAdapter { BigInt(gasUsed.storageRebate) ).toString(), } - : undefined + + const { + transferAmount: ptbTransferAmount, + recipient: ptbRecipient, + coinType: ptbCoinType, + } = this.parseProgrammableTransactionBlock(tx) const balanceChanges = tx.balanceChanges ?? [] - const transfers = balanceChanges.map(change => { - let ownerAddress: string | null = null - if (typeof change.owner === 'object' && 'AddressOwner' in change.owner) { - ownerAddress = change.owner.AddressOwner - } + // Filter out balance changes that only represent gas fees + const actualTransferChanges = balanceChanges.filter(change => { + if (!fee || change.coinType !== '0x2::sui::SUI') return true + + const changeAmount = BigInt(change.amount) + const absoluteChange = changeAmount < 0n ? -changeAmount : changeAmount + const feeAmount = BigInt(fee.value) + + return absoluteChange !== feeAmount + }) + + const transfersFromBalanceChanges = actualTransferChanges.map(change => { + const ownerAddress = (() => { + if (typeof change.owner === 'object' && 'AddressOwner' in change.owner) { + return change.owner.AddressOwner + } + return null + })() const assetId = change.coinType === '0x2::sui::SUI' @@ -578,29 +672,103 @@ export class ChainAdapter implements IChainAdapter { : toAssetId({ chainId: this.chainId, assetNamespace: ASSET_NAMESPACE.suiCoin, - assetReference: change.coinType, + assetReference: this.normalizeCoinType(change.coinType), }) const amount = BigInt(change.amount) const isReceive = amount > 0n const isSend = amount < 0n - const transferType = - ownerAddress === pubkey - ? isReceive - ? TransferType.Receive - : TransferType.Send - : TransferType.Contract - - return { - assetId, - from: isSend ? [sender] : ownerAddress ? [ownerAddress] : [sender], - to: isReceive ? [ownerAddress ?? sender] : [sender], - type: transferType, - value: amount < 0n ? (-amount).toString() : amount.toString(), - } + const transferType = (() => { + if (ownerAddress !== pubkey) return TransferType.Contract + return isReceive ? TransferType.Receive : TransferType.Send + })() + + // For Send transfers of native SUI, use PTB amount to exclude gas + const shouldUsePtbAmount = + isSend && ptbTransferAmount && change.coinType === '0x2::sui::SUI' + const transferValue = shouldUsePtbAmount + ? ptbTransferAmount + : (amount < 0n ? -amount : amount).toString() + + // ownerAddress is who owns the balance after the transaction + // For Send: from = sender, to = recipient (from PTB if available, else ownerAddress) + // For Receive: from = sender, to = ownerAddress (recipient) + const from = [sender] + const to = (() => { + if (isReceive) return [ownerAddress ?? sender] + return ptbRecipient ? [ptbRecipient] : [sender] + })() + + return { assetId, from, to, type: transferType, value: transferValue } }) + // For self-sends where balance changes were filtered out, use PTB data + const transfersFromPtb = (() => { + if (actualTransferChanges.length > 0) return [] + if (!ptbTransferAmount || !ptbRecipient) return [] + + const isSelfSend = sender === ptbRecipient + const isSender = sender === pubkey + const isRecipient = ptbRecipient === pubkey + + // Determine the correct assetId (native SUI or token) + const assetId = !ptbCoinType + ? this.assetId + : toAssetId({ + chainId: this.chainId, + assetNamespace: ASSET_NAMESPACE.suiCoin, + assetReference: ptbCoinType, + }) + + if (isSelfSend && isSender) { + return [ + { + assetId, + from: [sender], + to: [ptbRecipient], + type: TransferType.Send, + value: ptbTransferAmount, + }, + { + assetId, + from: [sender], + to: [ptbRecipient], + type: TransferType.Receive, + value: ptbTransferAmount, + }, + ] + } + + if (isSender) { + return [ + { + assetId, + from: [sender], + to: [ptbRecipient], + type: TransferType.Send, + value: ptbTransferAmount, + }, + ] + } + + if (isRecipient) { + return [ + { + assetId, + from: [sender], + to: [ptbRecipient], + type: TransferType.Receive, + value: ptbTransferAmount, + }, + ] + } + + return [] + })() + + const transfers = [...transfersFromBalanceChanges, ...transfersFromPtb] + return { txid, blockHeight, diff --git a/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx index fe439d99001..a69ca814192 100644 --- a/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx @@ -161,17 +161,34 @@ export const useSendActionSubscriber = () => { if (isConfirmed) { // Parse and upsert Tx for second-class chains + const { accountIdsToRefetch } = action.transactionMetadata + const accountIdsToUpsert = accountIdsToRefetch ?? [accountId] + try { const adapter = getChainAdapterManager().get(chainId) - if (adapter?.parseTx) { - const parsedTx = await adapter.parseTx(txHash, accountAddress) - dispatch( - txHistory.actions.onMessage({ - message: parsedTx, - accountId, - }), - ) + if (!adapter?.parseTx) { + completeAction(action) + const intervalId = pollingIntervalsRef.current.get(pollingKey) + if (intervalId) { + clearInterval(intervalId) + pollingIntervalsRef.current.delete(pollingKey) + } + return } + + // Parse and upsert for all involved accounts (sender + recipient if held) + await Promise.all( + accountIdsToUpsert.map(async accountIdToUpsert => { + const address = fromAccountId(accountIdToUpsert).account + const parsedTx = await adapter.parseTx(txHash, address) + dispatch( + txHistory.actions.onMessage({ + message: parsedTx, + accountId: accountIdToUpsert, + }), + ) + }), + ) } catch (error) { // Silent fail - Tx just won't show in history console.error('Failed to parse and upsert Tx:', error)