@ledgerhq/coin-tezos
Version:
142 lines (129 loc) • 4.62 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosContext } from '../config'
import type { APIAccount, APIManagerAccount, APIUnstakeRequest } from '../network/types'
import type { Cursor, Page, Stake } from '@ledgerhq/coin-module-framework/api/types'
import { log } from '@ledgerhq/logs'
import { hasManagerKey } from '../network/types'
import { createTzktApi } from '../network/tzkt'
import { partitionNativeBalance } from '../utils'
import { STAKING_UID_PREFIX } from './positionUid'
export {
isDelegationPosition,
isFinalizablePosition,
isStakePosition,
isUnstakingPosition,
} from './positionUid'
export function fetchUnstakeRequests(
config: TezosCoinConfig,
address: string,
account: APIAccount
): Promise<APIUnstakeRequest[]> {
if (!hasManagerKey(account)) return Promise.resolve([])
return (account.unstakedBalance ?? 0) > 0
? createTzktApi(config).getUnstakeRequests(address)
: Promise.resolve([])
}
function unstakeRequestToStake(address: string, req: APIUnstakeRequest): Stake | null {
// TzKT's status filter is unreliable; never build a position for a finalized request.
if (req.status !== 'pending' && req.status !== 'finalizable') {
log('coin:tezos', 'unstakeRequestToStake: dropping non-active unstake request', {
requestId: req.id,
status: req.status,
})
return null
}
if (req.actualAmount <= 0) {
log('coin:tezos', 'unstakeRequestToStake: dropping non-positive unstake request', {
requestId: req.id,
actualAmount: req.actualAmount,
})
return null
}
const createdAt = new Date(req.firstTime)
if (!Number.isFinite(createdAt.getTime())) {
log('coin:tezos', 'unstakeRequestToStake: dropping unstake request with invalid firstTime', {
requestId: req.id,
firstTime: req.firstTime,
})
return null
}
const isFinalizable = req.status === 'finalizable'
const prefix = isFinalizable ? STAKING_UID_PREFIX.finalizable : STAKING_UID_PREFIX.unstaking
return {
uid: `${prefix}${req.id}`,
address,
...(req.baker?.address && { delegate: req.baker.address }),
state: isFinalizable ? 'inactive' : 'deactivating',
createdAt,
asset: { type: 'native' },
amount: BigInt(req.actualAmount),
actions: [],
}
}
export function buildStakesForAccount(
address: string,
account: APIManagerAccount,
unstakeRequests: APIUnstakeRequest[]
): Stake[] {
const balance = BigInt(account.balance ?? 0)
const stakedBalance = BigInt(account.stakedBalance ?? 0)
// Account-level unstaked total (the source validateIntent's gate uses), not the summed per-request
// positions below — so spendable stays gate-consistent even if a request is dropped or its fetch fails.
const unstakedBalance = BigInt(account.unstakedBalance ?? 0)
const delegateAddress = account.delegate?.address
const unstakeStakes: Stake[] = []
for (const req of unstakeRequests) {
const stake = unstakeRequestToStake(address, req)
if (stake) unstakeStakes.push(stake)
}
const stakes: Stake[] = []
if (delegateAddress) {
const { spendable: delegated, locked } = partitionNativeBalance(
balance,
stakedBalance,
unstakedBalance
)
if (locked < stakedBalance + unstakedBalance) {
log('coin:tezos', 'buildStakesForAccount: balance < staked + unstaked, clamping to 0', {
address,
balance: balance.toString(),
stakedBalance: stakedBalance.toString(),
unstakedBalance: unstakedBalance.toString(),
})
}
stakes.push({
uid: `${STAKING_UID_PREFIX.delegation}${address}`,
address,
delegate: delegateAddress,
state: 'active',
asset: { type: 'native' },
amount: delegated,
actions: [],
})
}
if (stakedBalance > 0n) {
stakes.push({
uid: `${STAKING_UID_PREFIX.stake}${address}`,
address,
...(delegateAddress && { delegate: delegateAddress }),
state: 'active',
asset: { type: 'native' },
amount: stakedBalance,
actions: [],
})
}
stakes.push(...unstakeStakes)
return stakes
}
export async function getStakes(
context: TezosContext,
address: string,
_cursor?: Cursor
): Promise<Page<Stake>> {
const config = await context.config()
const accountInfo = await createTzktApi(config).getAccountByAddress(address)
if (!hasManagerKey(accountInfo)) return { items: [] }
const unstakeRequests = await fetchUnstakeRequests(config, address, accountInfo)
return { items: buildStakesForAccount(address, accountInfo, unstakeRequests) }
}