Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"url": "https://github.com/AudiusProject/apps/issues"
},
"dependencies": {
"@audius/eth": "*",
"@audius/fetch-nft": "0.2.8",
"@audius/fixed-decimal": "*",
"@audius/sdk": "*",
Expand Down
29 changes: 13 additions & 16 deletions packages/common/src/api/tan-query/wallets/useAudioBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useQueryClient
} from '@tanstack/react-query'
import { call, getContext } from 'typed-redux-saga'
import { getAddress } from 'viem'
import { getAddress, type Hex } from 'viem'

import {
getQueryContext,
Expand All @@ -17,6 +17,11 @@ import {
} from '~/api/tan-query/utils/QueryContext'
import { Chain, ID } from '~/models'
import { Feature } from '~/models/ErrorReporting'
import {
createEthPublicClient,
getAudioBalance,
getFullAudioBalance
} from '~/services/ethereum/ethereum'
import { toErrorWithMessage } from '~/utils/error'

import { QUERY_KEYS } from '../queryKeys'
Expand All @@ -43,7 +48,7 @@ export const getWalletAudioBalanceQueryKey = ({

type FetchAudioBalanceContext = Pick<
QueryContextType,
'audiusSdk' | 'audiusBackend' | 'reportToSentry'
'audiusSdk' | 'audiusBackend' | 'reportToSentry' | 'env'
>

const getWalletAudioBalanceQueryFn =
Expand All @@ -54,26 +59,18 @@ const getWalletAudioBalanceQueryFn =
ReturnType<typeof getWalletAudioBalanceQueryKey>
>) => {
const [_ignored, chain, address, { includeStaked }] = queryKey
const { audiusSdk, audiusBackend, reportToSentry } = context
const { audiusSdk, audiusBackend, reportToSentry, env } = context
try {
const sdk = await audiusSdk()
if (chain === Chain.Eth) {
const checksumWallet = getAddress(address)
const balance = await sdk.services.audiusTokenClient.balanceOf({
account: checksumWallet
})
const checksumWallet = getAddress(address) as Hex
const ethClient = createEthPublicClient(env.ETH_PROVIDER_URL)
const balance = await getAudioBalance(ethClient, checksumWallet)
if (!includeStaked) {
return AUDIO(balance).value
}
const delegatedBalance =
await sdk.services.delegateManagerClient.getTotalDelegatorStake({
delegatorAddress: checksumWallet
})
const stakedBalance = await sdk.services.stakingClient.totalStakedFor({
account: checksumWallet
})

return AUDIO(balance + delegatedBalance + stakedBalance).value
const fullBalance = await getFullAudioBalance(ethClient, checksumWallet)
return AUDIO(fullBalance).value
} else {
const wAudioSolBalance = await audiusBackend.getAddressWAudioBalance({
address,
Expand Down
38 changes: 15 additions & 23 deletions packages/common/src/services/audius-backend/AudiusBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ import {
Transaction,
VersionedTransaction
} from '@solana/web3.js'
import { getAddress } from 'viem'
import { getAddress, type Hex } from 'viem'

import { userMetadataToSdk } from '~/adapters/user'
import { Env } from '~/services/env'
import {
createEthPublicClient,
getAudioBalance,
getFullAudioBalance
} from '~/services/ethereum/ethereum'
import dayjs from '~/utils/dayjs'

import { ID, ComputedUserProperties, WriteableUserMetadata } from '../../models'
Expand Down Expand Up @@ -668,19 +673,16 @@ export const audiusBackend = ({
* @returns {Promise<AudioWei | null>} balance or null if failed to fetch balance
*/
async function getBalance({
ethAddress,
sdk
ethAddress
}: {
ethAddress: string
sdk: AudiusSdk
}): Promise<AudioWei | null> {
if (!ethAddress) return null

try {
const checksumWallet = getAddress(ethAddress)
const balance = await sdk.services.audiusTokenClient.balanceOf({
account: checksumWallet
})
const checksumWallet = getAddress(ethAddress) as Hex
const ethClient = createEthPublicClient(env.ETH_PROVIDER_URL)
const balance = await getAudioBalance(ethClient, checksumWallet)
return AUDIO(balance).value
} catch (e) {
console.error(e)
Expand Down Expand Up @@ -754,24 +756,14 @@ export const audiusBackend = ({
* @param bustCache
* @returns balance or null if error
*/
async function getAddressTotalStakedBalance(address: string, sdk: AudiusSdk) {
async function getAddressTotalStakedBalance(address: string) {
if (!address) return null

try {
const checksumWallet = getAddress(address)
const [balance, delegatedBalance, stakedBalance] = await Promise.all([
sdk.services.audiusTokenClient.balanceOf({
account: checksumWallet
}),
sdk.services.delegateManagerClient.getTotalDelegatorStake({
delegatorAddress: checksumWallet
}),
sdk.services.stakingClient.totalStakedFor({
account: checksumWallet
})
])

return AUDIO(balance + delegatedBalance + stakedBalance).value
const checksumWallet = getAddress(address) as Hex
const ethClient = createEthPublicClient(env.ETH_PROVIDER_URL)
const fullBalance = await getFullAudioBalance(ethClient, checksumWallet)
return AUDIO(fullBalance).value
} catch (e) {
reportError({ error: e as Error })
console.error(e)
Expand Down
248 changes: 248 additions & 0 deletions packages/common/src/services/ethereum/ethereum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* Ethereum contract utilities for Audius.
*
* Plain functions that call Audius Ethereum contracts via viem,
* using ABIs + mainnet addresses from @audius/eth.
* No classes, no schemas — just contract calls.
*/

import {
AudiusToken,
type AudiusTokenTypes,
AudiusWormhole,
type AudiusWormholeTypes,
DelegateManager,
Staking
} from '@audius/eth'
import {
type Hex,
type PublicClient,
type TypedDataDefinition,
type WalletClient,
createPublicClient,
createWalletClient,
http,
parseSignature
} from 'viem'
import { mainnet } from 'viem/chains'

// ---------- Types ----------

/** Minimal signer interface for Ethereum transactions. */
export type EthSigner = {
getAddresses: () => Promise<Hex[]>
signTypedData: (data: any) => Promise<Hex>
}

// ---------- Client factories ----------

/** Create a viem PublicClient for Ethereum mainnet. */
export const createEthPublicClient = (rpcUrl: string) =>
createPublicClient({
chain: mainnet,
transport: http(rpcUrl)
})

/** Create a viem WalletClient for Ethereum mainnet. */
export const createEthWalletClient = (rpcUrl: string) =>
createWalletClient({
chain: mainnet,
transport: http(rpcUrl)
})
Comment on lines +46 to +51
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createEthWalletClient currently always uses http(rpcUrl) transport. A WalletClient created this way can only send transactions if the RPC node can sign for the from address (unlocked/local account), which is usually not true in browser/mobile contexts. To prevent accidental misuse, consider (a) removing this factory and requiring callers to provide a WalletClient/transport, or (b) renaming/documenting it as a server-side/unlocked-account helper and adding an alternative helper for the identity-relay/EIP-1193 transport pattern.

Copilot uses AI. Check for mistakes.

// ---------- Reads: AudiusToken ----------

/** Get AUDIO token balance for an Ethereum address. */
export const getAudioBalance = (client: PublicClient, account: Hex) =>
client.readContract({
address: AudiusToken.address,
abi: AudiusToken.abi,
functionName: 'balanceOf',
args: [account]
})

// ---------- Reads: Staking ----------

/** Get total staked AUDIO for an address. */
export const getTotalStakedFor = (client: PublicClient, account: Hex) =>
client.readContract({
address: Staking.address,
abi: Staking.abi,
functionName: 'totalStakedFor',
args: [account]
})

// ---------- Reads: DelegateManager ----------

/** Get total delegated stake for a delegator address. */
export const getTotalDelegatorStake = (client: PublicClient, delegator: Hex) =>
client.readContract({
address: DelegateManager.address,
abi: DelegateManager.abi,
functionName: 'getTotalDelegatorStake',
args: [delegator]
})

// ---------- Composite reads ----------

/** Get full AUDIO balance: token + staked + delegated. */
export const getFullAudioBalance = async (
client: PublicClient,
account: Hex
) => {
const [balance, stakedBalance, delegatedBalance] = await Promise.all([
getAudioBalance(client, account),
getTotalStakedFor(client, account),
getTotalDelegatorStake(client, account)
])
return balance + stakedBalance + delegatedBalance
}

// ---------- Writes ----------

const ONE_HOUR_IN_MS = 1000 * 60 * 60
const ONE_HOUR_IN_S = 60 * 60

/** Wormhole chain ID for Solana (always 1 in the Wormhole protocol). */
const WORMHOLE_SOLANA_CHAIN_ID = 1

/**
* EIP-2612 permit: approve a spender to transfer AUDIO tokens on behalf of
* the owner using a signed message instead of an on-chain approve() tx.
*/
export async function permitAudioToken({
ethPublicClient,
ethWalletClient,
signer,
spender,
value
}: {
ethPublicClient: PublicClient
ethWalletClient: WalletClient
signer: EthSigner
spender: Hex
value: bigint
}): Promise<Hex> {
const owner = (await signer.getAddresses())[0]
if (!owner) {
throw new Error('No wallet address available')
}

const deadline = BigInt(Date.now() + ONE_HOUR_IN_MS)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deadline is being set using Date.now() milliseconds, but the comment says “one hour” and EIP-2612 deadlines are typically Unix timestamps in seconds (checked against block.timestamp). Using ms makes the permit effectively valid for ~55k years, which is likely unintended and weakens the safety of expiring permits. Use a seconds-based timestamp (consistent with wormholeTransferTokens) or rename/comment to reflect the intended unit.

Suggested change
const deadline = BigInt(Date.now() + ONE_HOUR_IN_MS)
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 60)

Copilot uses AI. Check for mistakes.

const nonce = await ethPublicClient.readContract({
address: AudiusToken.address,
abi: AudiusToken.abi,
functionName: 'nonces',
args: [owner]
})

const name = await ethPublicClient.readContract({
abi: AudiusToken.abi,
address: AudiusToken.address,
functionName: 'name'
})

const chainId = BigInt(await ethPublicClient.getChainId())

const typedData: TypedDataDefinition<AudiusTokenTypes, 'Permit'> = {
primaryType: 'Permit',
domain: {
name,
version: '1',
chainId,
verifyingContract: AudiusToken.address
},
message: { owner, spender, value, nonce, deadline },
types: AudiusToken.types
}

const signature = await signer.signTypedData(typedData)
const { r, s, v } = parseSignature(signature)

const { request } = await ethPublicClient.simulateContract({
address: AudiusToken.address,
abi: AudiusToken.abi,
functionName: 'permit',
args: [owner, spender, value, deadline, Number(v), r, s] as const,
account: owner
})
return await ethWalletClient.writeContract(request)
}

/**
* Transfer AUDIO tokens through the Wormhole bridge to Solana.
*/
export async function wormholeTransferTokens({
ethPublicClient,
ethWalletClient,
signer,
amount,
recipient
}: {
ethPublicClient: PublicClient
ethWalletClient: WalletClient
signer: EthSigner
amount: bigint
recipient: Hex
}): Promise<Hex> {
const from = (await signer.getAddresses())[0]
if (!from) {
throw new Error('No wallet address available')
}

const deadline = BigInt(Math.round(Date.now() / 1000) + ONE_HOUR_IN_S)
const arbiterFee = BigInt(0)

const nonce = await ethPublicClient.readContract({
address: AudiusWormhole.address,
abi: AudiusWormhole.abi,
functionName: 'nonces',
args: [from]
})

const chainId = BigInt(await ethPublicClient.getChainId())

const typedData: TypedDataDefinition<AudiusWormholeTypes, 'TransferTokens'> =
{
primaryType: 'TransferTokens',
domain: {
name: 'AudiusWormholeClient',
version: '1',
chainId,
verifyingContract: AudiusWormhole.address
},
message: {
from,
amount,
recipientChain: WORMHOLE_SOLANA_CHAIN_ID,
recipient,
artbiterFee: arbiterFee,
deadline,
nonce
},
types: AudiusWormhole.types
}

const signature = await signer.signTypedData(typedData)
const { r, s, v } = parseSignature(signature)

const { request } = await ethPublicClient.simulateContract({
address: AudiusWormhole.address,
abi: AudiusWormhole.abi,
functionName: 'transferTokens',
args: [
from,
amount,
WORMHOLE_SOLANA_CHAIN_ID,
recipient,
arbiterFee,
deadline,
Number(v),
r,
s
],
account: from
})
return await ethWalletClient.writeContract(request)
}
Loading
Loading