Skip to content

Commit be6c5f8

Browse files
feat: sui Tx parsing fixes (#11529)
1 parent 04058d0 commit be6c5f8

File tree

2 files changed

+243
-58
lines changed

2 files changed

+243
-58
lines changed

packages/chain-adapters/src/sui/SuiChainAdapter.ts

Lines changed: 218 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -135,25 +135,7 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
135135
const tokens = await Promise.all(
136136
nonZeroBalances.map(async balance => {
137137
const symbol = balance.coinType.split('::').pop() ?? 'UNKNOWN'
138-
139-
// Normalize coinType to ensure proper format with leading zeros
140-
// SUI addresses should be 66 chars (0x + 64 hex chars)
141-
const normalizeCoinType = (coinType: string): string => {
142-
const parts = coinType.split('::')
143-
if (parts.length < 2) return coinType
144-
145-
const address = parts[0]
146-
if (!address.startsWith('0x')) return coinType
147-
148-
// Pad address to 66 characters (0x + 64 hex digits)
149-
const hexPart = address.slice(2)
150-
const paddedHex = hexPart.padStart(64, '0')
151-
parts[0] = `0x${paddedHex}`
152-
153-
return parts.join('::')
154-
}
155-
156-
const normalizedCoinType = normalizeCoinType(balance.coinType)
138+
const normalizedCoinType = this.normalizeCoinType(balance.coinType)
157139

158140
const assetId = toAssetId({
159141
chainId: this.chainId,
@@ -521,9 +503,102 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
521503
return
522504
}
523505

506+
// Normalize SUI coin type to ensure consistent AssetId generation
507+
// SUI addresses should be 66 characters (0x + 64 hex chars) with leading zeros
508+
// Example: 0x2::sui::SUI stays the same, but 0xdba3::usdc::USDC becomes 0x0000...0dba3::usdc::USDC
509+
private normalizeCoinType(coinType: string): string {
510+
const parts = coinType.split('::')
511+
if (parts.length < 2) return coinType
512+
513+
const address = parts[0]
514+
if (!address.startsWith('0x')) return coinType
515+
516+
const hexPart = address.slice(2)
517+
const paddedHex = hexPart.padStart(64, '0')
518+
parts[0] = `0x${paddedHex}`
519+
520+
return parts.join('::')
521+
}
522+
523+
private parseProgrammableTransactionBlock(tx: SuiTransactionBlockResponse): {
524+
transferAmount: string | undefined
525+
recipient: string | undefined
526+
coinType: string | undefined
527+
} {
528+
const ptb = tx.transaction?.data.transaction
529+
if (ptb?.kind !== 'ProgrammableTransaction') {
530+
return { transferAmount: undefined, recipient: undefined, coinType: undefined }
531+
}
532+
533+
const inputs = ptb.inputs ?? []
534+
const commands = ptb.transactions ?? []
535+
536+
let transferAmount: string | undefined
537+
let recipient: string | undefined
538+
let coinObjectId: string | undefined
539+
540+
for (const command of commands) {
541+
if ('SplitCoins' in command) {
542+
const [coinSource, amounts] = command.SplitCoins
543+
const firstAmount = amounts?.[0]
544+
if (!firstAmount || typeof firstAmount !== 'object' || !('Input' in firstAmount)) continue
545+
546+
const amountInput = inputs[firstAmount.Input]
547+
if (amountInput?.type === 'pure' && amountInput.valueType === 'u64') {
548+
const value = amountInput.value
549+
if (typeof value === 'string') {
550+
transferAmount = value
551+
}
552+
}
553+
554+
// For token transfers, coin source is an object input
555+
if (typeof coinSource === 'object' && 'Input' in coinSource) {
556+
const coinInput = inputs[coinSource.Input]
557+
if (coinInput?.type === 'object') {
558+
const objectId = coinInput.objectId
559+
if (typeof objectId === 'string') {
560+
coinObjectId = objectId
561+
}
562+
}
563+
}
564+
}
565+
566+
if ('TransferObjects' in command) {
567+
const [_objects, recipientArg] = command.TransferObjects
568+
if (!recipientArg || typeof recipientArg !== 'object' || !('Input' in recipientArg))
569+
continue
570+
571+
const recipientInput = inputs[recipientArg.Input]
572+
if (recipientInput?.type === 'pure' && recipientInput.valueType === 'address') {
573+
const value = recipientInput.value
574+
if (typeof value === 'string') {
575+
recipient = value
576+
}
577+
}
578+
}
579+
}
580+
581+
// Extract coin type from objectChanges if we have a coin object ID
582+
const coinType = (() => {
583+
if (!coinObjectId) return undefined
584+
585+
const objectChange = tx.objectChanges?.find(
586+
change => 'objectId' in change && change.objectId === coinObjectId,
587+
)
588+
589+
if (!objectChange || !('objectType' in objectChange)) return undefined
590+
591+
const match = objectChange.objectType.match(/0x2::coin::Coin<(.+)>/)
592+
const extractedCoinType = match?.[1]
593+
594+
return extractedCoinType ? this.normalizeCoinType(extractedCoinType) : undefined
595+
})()
596+
597+
return { transferAmount, recipient, coinType }
598+
}
599+
524600
async parseTx(txHashOrTx: unknown, pubkey: string): Promise<Transaction> {
525601
try {
526-
// Fetch full transaction data if only txHash was provided
527602
const tx =
528603
typeof txHashOrTx === 'string'
529604
? await this.client.getTransactionBlock({
@@ -532,75 +607,168 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
532607
showInput: true,
533608
showEffects: true,
534609
showBalanceChanges: true,
610+
showObjectChanges: true,
535611
},
536612
})
537613
: (txHashOrTx as SuiTransactionBlockResponse)
538614

539615
const sender = tx.transaction?.data.sender ?? ''
540-
541616
const txid = tx.digest
542617
const blockHeight = Number(tx.checkpoint ?? 0)
543618
const blockTime = tx.timestampMs ? Math.floor(Number(tx.timestampMs) / 1000) : 0
544619

545620
const latestCheckpoint = await this.client.getLatestCheckpointSequenceNumber()
546621
const confirmations = tx.checkpoint ? Number(latestCheckpoint) - Number(tx.checkpoint) + 1 : 0
547622

548-
const status =
549-
tx.effects?.status.status === 'success'
550-
? TxStatus.Confirmed
551-
: tx.effects?.status.status === 'failure'
552-
? TxStatus.Failed
553-
: TxStatus.Unknown
623+
const status = (() => {
624+
const txStatus = tx.effects?.status.status
625+
if (txStatus === 'success') return TxStatus.Confirmed
626+
if (txStatus === 'failure') return TxStatus.Failed
627+
return TxStatus.Unknown
628+
})()
554629

555630
const gasUsed = tx.effects?.gasUsed
556-
const fee = gasUsed
557-
? {
631+
const fee = !gasUsed
632+
? undefined
633+
: {
558634
assetId: this.assetId,
559635
value: (
560636
BigInt(gasUsed.computationCost) +
561637
BigInt(gasUsed.storageCost) -
562638
BigInt(gasUsed.storageRebate)
563639
).toString(),
564640
}
565-
: undefined
641+
642+
const {
643+
transferAmount: ptbTransferAmount,
644+
recipient: ptbRecipient,
645+
coinType: ptbCoinType,
646+
} = this.parseProgrammableTransactionBlock(tx)
566647

567648
const balanceChanges = tx.balanceChanges ?? []
568649

569-
const transfers = balanceChanges.map(change => {
570-
let ownerAddress: string | null = null
571-
if (typeof change.owner === 'object' && 'AddressOwner' in change.owner) {
572-
ownerAddress = change.owner.AddressOwner
573-
}
650+
// Filter out balance changes that only represent gas fees
651+
const actualTransferChanges = balanceChanges.filter(change => {
652+
if (!fee || change.coinType !== '0x2::sui::SUI') return true
653+
654+
const changeAmount = BigInt(change.amount)
655+
const absoluteChange = changeAmount < 0n ? -changeAmount : changeAmount
656+
const feeAmount = BigInt(fee.value)
657+
658+
return absoluteChange !== feeAmount
659+
})
660+
661+
const transfersFromBalanceChanges = actualTransferChanges.map(change => {
662+
const ownerAddress = (() => {
663+
if (typeof change.owner === 'object' && 'AddressOwner' in change.owner) {
664+
return change.owner.AddressOwner
665+
}
666+
return null
667+
})()
574668

575669
const assetId =
576670
change.coinType === '0x2::sui::SUI'
577671
? this.assetId
578672
: toAssetId({
579673
chainId: this.chainId,
580674
assetNamespace: ASSET_NAMESPACE.suiCoin,
581-
assetReference: change.coinType,
675+
assetReference: this.normalizeCoinType(change.coinType),
582676
})
583677

584678
const amount = BigInt(change.amount)
585679
const isReceive = amount > 0n
586680
const isSend = amount < 0n
587681

588-
const transferType =
589-
ownerAddress === pubkey
590-
? isReceive
591-
? TransferType.Receive
592-
: TransferType.Send
593-
: TransferType.Contract
594-
595-
return {
596-
assetId,
597-
from: isSend ? [sender] : ownerAddress ? [ownerAddress] : [sender],
598-
to: isReceive ? [ownerAddress ?? sender] : [sender],
599-
type: transferType,
600-
value: amount < 0n ? (-amount).toString() : amount.toString(),
601-
}
682+
const transferType = (() => {
683+
if (ownerAddress !== pubkey) return TransferType.Contract
684+
return isReceive ? TransferType.Receive : TransferType.Send
685+
})()
686+
687+
// For Send transfers of native SUI, use PTB amount to exclude gas
688+
const shouldUsePtbAmount =
689+
isSend && ptbTransferAmount && change.coinType === '0x2::sui::SUI'
690+
const transferValue = shouldUsePtbAmount
691+
? ptbTransferAmount
692+
: (amount < 0n ? -amount : amount).toString()
693+
694+
// ownerAddress is who owns the balance after the transaction
695+
// For Send: from = sender, to = recipient (from PTB if available, else ownerAddress)
696+
// For Receive: from = sender, to = ownerAddress (recipient)
697+
const from = [sender]
698+
const to = (() => {
699+
if (isReceive) return [ownerAddress ?? sender]
700+
return ptbRecipient ? [ptbRecipient] : [sender]
701+
})()
702+
703+
return { assetId, from, to, type: transferType, value: transferValue }
602704
})
603705

706+
// For self-sends where balance changes were filtered out, use PTB data
707+
const transfersFromPtb = (() => {
708+
if (actualTransferChanges.length > 0) return []
709+
if (!ptbTransferAmount || !ptbRecipient) return []
710+
711+
const isSelfSend = sender === ptbRecipient
712+
const isSender = sender === pubkey
713+
const isRecipient = ptbRecipient === pubkey
714+
715+
// Determine the correct assetId (native SUI or token)
716+
const assetId = !ptbCoinType
717+
? this.assetId
718+
: toAssetId({
719+
chainId: this.chainId,
720+
assetNamespace: ASSET_NAMESPACE.suiCoin,
721+
assetReference: ptbCoinType,
722+
})
723+
724+
if (isSelfSend && isSender) {
725+
return [
726+
{
727+
assetId,
728+
from: [sender],
729+
to: [ptbRecipient],
730+
type: TransferType.Send,
731+
value: ptbTransferAmount,
732+
},
733+
{
734+
assetId,
735+
from: [sender],
736+
to: [ptbRecipient],
737+
type: TransferType.Receive,
738+
value: ptbTransferAmount,
739+
},
740+
]
741+
}
742+
743+
if (isSender) {
744+
return [
745+
{
746+
assetId,
747+
from: [sender],
748+
to: [ptbRecipient],
749+
type: TransferType.Send,
750+
value: ptbTransferAmount,
751+
},
752+
]
753+
}
754+
755+
if (isRecipient) {
756+
return [
757+
{
758+
assetId,
759+
from: [sender],
760+
to: [ptbRecipient],
761+
type: TransferType.Receive,
762+
value: ptbTransferAmount,
763+
},
764+
]
765+
}
766+
767+
return []
768+
})()
769+
770+
const transfers = [...transfersFromBalanceChanges, ...transfersFromPtb]
771+
604772
return {
605773
txid,
606774
blockHeight,

src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,34 @@ export const useSendActionSubscriber = () => {
161161

162162
if (isConfirmed) {
163163
// Parse and upsert Tx for second-class chains
164+
const { accountIdsToRefetch } = action.transactionMetadata
165+
const accountIdsToUpsert = accountIdsToRefetch ?? [accountId]
166+
164167
try {
165168
const adapter = getChainAdapterManager().get(chainId)
166-
if (adapter?.parseTx) {
167-
const parsedTx = await adapter.parseTx(txHash, accountAddress)
168-
dispatch(
169-
txHistory.actions.onMessage({
170-
message: parsedTx,
171-
accountId,
172-
}),
173-
)
169+
if (!adapter?.parseTx) {
170+
completeAction(action)
171+
const intervalId = pollingIntervalsRef.current.get(pollingKey)
172+
if (intervalId) {
173+
clearInterval(intervalId)
174+
pollingIntervalsRef.current.delete(pollingKey)
175+
}
176+
return
174177
}
178+
179+
// Parse and upsert for all involved accounts (sender + recipient if held)
180+
await Promise.all(
181+
accountIdsToUpsert.map(async accountIdToUpsert => {
182+
const address = fromAccountId(accountIdToUpsert).account
183+
const parsedTx = await adapter.parseTx(txHash, address)
184+
dispatch(
185+
txHistory.actions.onMessage({
186+
message: parsedTx,
187+
accountId: accountIdToUpsert,
188+
}),
189+
)
190+
}),
191+
)
175192
} catch (error) {
176193
// Silent fail - Tx just won't show in history
177194
console.error('Failed to parse and upsert Tx:', error)

0 commit comments

Comments
 (0)