Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
211 changes: 181 additions & 30 deletions packages/chain-adapters/src/sui/SuiChainAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,9 +521,73 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
return
}

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 || !('Input' in firstAmount)) continue

const amountInput = inputs[firstAmount.Input]
if (amountInput?.type === 'pure' && amountInput.valueType === 'u64') {
transferAmount = amountInput.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') {
coinObjectId = coinInput.objectId
}
}
}

if ('TransferObjects' in command) {
const [_objects, recipientArg] = command.TransferObjects
if (!recipientArg || !('Input' in recipientArg)) continue

const recipientInput = inputs[recipientArg.Input]
if (recipientInput?.type === 'pure' && recipientInput.valueType === 'address') {
recipient = recipientInput.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<(.+)>/)
return match?.[1]
})()

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,45 +596,64 @@ 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'
Expand All @@ -585,22 +668,90 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SuiMainnet> {
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,19 +161,35 @@ 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