Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
253 changes: 203 additions & 50 deletions packages/chain-adapters/src/sui/SuiChainAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,25 +135,7 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
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,
Expand Down Expand Up @@ -521,9 +503,93 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
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') {
transferAmount = amountInput.value as string
}

// 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') {
coinObjectId = coinInput.objectId as string
}
}
}

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') {
recipient = recipientInput.value as string
}
}
}

// 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 }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async parseTx(txHashOrTx: unknown, pubkey: string): Promise<Transaction> {
try {
// Fetch full transaction data if only txHash was provided
const tx =
typeof txHashOrTx === 'string'
? await this.client.getTransactionBlock({
Expand All @@ -532,75 +598,162 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
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

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) +
BigInt(gasUsed.storageCost) -
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'
? this.assetId
: 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()

const from = isSend ? [sender] : ownerAddress ? [ownerAddress] : [sender]
const to = isReceive ? [ownerAddress ?? sender] : [sender]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down