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
268 changes: 218 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,102 @@ 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') {
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<Transaction> {
try {
// Fetch full transaction data if only txHash was provided
const tx =
typeof txHashOrTx === 'string'
? await this.client.getTransactionBlock({
Expand All @@ -532,75 +607,168 @@ 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()

// 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,
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