Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attestation improvements #2559

Merged
merged 6 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion centrifuge-app/src/components/Charts/PriceYieldChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function PriceYieldChart({

if (!poolId) throw new Error('Pool not found')

const { trancheStates: tranches } = useDailyPoolStates(poolId, undefined, undefined, false) || {}
const { trancheStates: tranches } = useDailyPoolStates(poolId, undefined, undefined) || {}
const trancheStates = tranches?.[trancheId]
const pool = usePool(poolId)

Expand Down
14 changes: 5 additions & 9 deletions centrifuge-app/src/components/LoanList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useBasePath } from '@centrifuge/centrifuge-app/src/utils/useBasePath'
import { AssetSnapshot, CurrencyBalance, Loan, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js'
import { CurrencyBalance, Loan, Pool, TinlakeLoan } from '@centrifuge/centrifuge-js'
import {
AnchorButton,
Box,
Expand Down Expand Up @@ -27,10 +27,9 @@ import { formatBalance, formatPercentage } from '../utils/formatting'
import { useFilters } from '../utils/useFilters'
import { useMetadata } from '../utils/useMetadata'
import { useCentNFT } from '../utils/useNFTs'
import { usePool, usePoolMetadata } from '../utils/usePools'
import { useAllPoolAssetSnapshots, usePool, usePoolMetadata } from '../utils/usePools'
import { Column, DataTable, SortableTableHeader } from './DataTable'
import { prefetchRoute } from './Root'
import { Spinner } from './Spinner'

type Row = (Loan | TinlakeLoan) & {
idSortKey: number
Expand All @@ -46,15 +45,14 @@ type Row = (Loan | TinlakeLoan) & {

type Props = {
loans: Loan[] | TinlakeLoan[]
snapshots: AssetSnapshot[]
isLoading: boolean
}

export function LoanList({ loans, snapshots, isLoading }: Props) {
export function LoanList({ loans }: Props) {
const { pid: poolId } = useParams<{ pid: string }>()
if (!poolId) throw new Error('Pool not found')

const navigate = useNavigate()
const { data: snapshots } = useAllPoolAssetSnapshots(poolId, new Date().toISOString().slice(0, 10))
const pool = usePool(poolId)
const isTinlakePool = poolId?.startsWith('0x')
const basePath = useBasePath()
Expand Down Expand Up @@ -267,8 +265,6 @@ export function LoanList({ loans, snapshots, isLoading }: Props) {
const filteredData = isLoading ? [] : showRepaid ? rows : rows.filter((row) => !row.marketValue?.isZero())
const pagination = usePagination({ data: filteredData, pageSize: 20 })

if (isLoading) return <Spinner />

return (
<>
<Box pt={1} pb={2} paddingX={1} display="flex" justifyContent="space-between" alignItems="center">
Expand All @@ -293,7 +289,7 @@ export function LoanList({ loans, snapshots, isLoading }: Props) {
View asset transactions
</Button>
<AnchorButton
href={csvUrl ?? undefined}
href={csvUrl ?? ''}
download={`pool-assets-${poolId}.csv`}
variant="inverted"
icon={IconDownload}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,13 @@ export function AddAddressInput({

const exists = !!truncated && existingAddresses.some((addr) => isSameAddress(addr, address))

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newAddress = e.target.value
setAddress(newAddress)
}

function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
const address = e.target.value
if (truncated) {
onAdd(addressToHex(address))
setAddress('')
}
}

return (
<Grid columns={2} equalColumns gap={4} alignItems="center">
<AddressInput
clearIcon
placeholder="Search to add address..."
value={address}
onChange={handleChange}
onBlur={handleBlur}
onChange={(e) => setAddress(e.target.value)}
/>
{address &&
(truncated ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ function PoolDomain({ poolId, domain, refetch }: { poolId: string; domain: Domai
</React.Fragment>
))}
{domain.trancheTokens[t.id] && (
<a href={explorer.address(domain.trancheTokens[t.id])} target="_blank" rel="noopener noreferrer">
<a href={explorer.address(domain.trancheTokens[t.id]!)} target="_blank" rel="noopener noreferrer">
<Button variant="secondary" small style={{ width: '100%' }}>
<Shelf gap={1}>
<span>View {t.currency.symbol} token</span>
Expand Down
6 changes: 3 additions & 3 deletions centrifuge-app/src/pages/Loan/FinanceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { Field, FieldProps, Form, FormikProvider, useField, useFormik, useFormik
import * as React from 'react'
import { combineLatest, map, of, switchMap } from 'rxjs'
import { useTheme } from 'styled-components'
import { AnchorTextLink } from '../../components/TextLink'
import { AnchorTextLink, RouterTextLink } from '../../components/TextLink'
import { parachainIcons, parachainNames } from '../../config'
import { Dec, min } from '../../utils/Decimal'
import { formatBalance } from '../../utils/formatting'
Expand Down Expand Up @@ -409,7 +409,7 @@ function WithdrawSelect({ withdrawAddresses, poolId }: { withdrawAddresses: With
<ErrorMessage type="warning" condition={!withdrawAddresses.length}>
<Stack gap={1}>
To purchase/finance this asset, the pool must set trusted withdrawal addresses to which funds will be sent.
<AnchorTextLink href={`#/issuer/${poolId}/access`}>Add trusted addresses</AnchorTextLink>
<RouterTextLink to={`/issuer/${poolId}/access`}>Add trusted addresses</RouterTextLink>
</Stack>
</ErrorMessage>
)
Expand Down Expand Up @@ -452,7 +452,7 @@ function Mux({
<ErrorMessage type="warning" condition={!withdrawAmounts.length}>
<Stack gap={1}>
To purchase/finance this asset, the pool must set trusted withdrawal addresses to which funds will be sent.
<AnchorTextLink href={`#/issuer/${poolId}/access`}>Add trusted addresses</AnchorTextLink>
<RouterTextLink to={`/issuer/${poolId}/access`}>Add trusted addresses</RouterTextLink>
</Stack>
</ErrorMessage>
) : (
Expand Down
176 changes: 111 additions & 65 deletions centrifuge-app/src/pages/NavManagement/NavManagementAssetTable.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ActiveLoan,
addressToHex,
CreatedLoan,
CurrencyBalance,
CurrencyKey,
Expand Down Expand Up @@ -29,9 +30,11 @@ import {
} from '@centrifuge/fabric'
import { stringToHex } from '@polkadot/util'
import { BN } from 'bn.js'
import { keccak256, SigningKey, toUtf8Bytes } from 'ethers'
import { Field, FieldProps, FormikProvider, useFormik } from 'formik'
import * as React from 'react'
import { Observable, catchError, combineLatest, from, map, of, switchMap } from 'rxjs'
import { useQueryClient } from 'react-query'
import { combineLatest, defer, firstValueFrom, switchMap } from 'rxjs'
import daiLogo from '../../assets/images/dai-logo.svg'
import usdcLogo from '../../assets/images/usdc-logo.svg'
import { ButtonGroup } from '../../components/ButtonGroup'
Expand All @@ -43,6 +46,7 @@ import { Dec } from '../../utils/Decimal'
import { formatBalance } from '../../utils/formatting'
import { useLiquidity } from '../../utils/useLiquidity'
import { useActiveDomains } from '../../utils/useLiquidityPools'
import { metadataQueryFn } from '../../utils/useMetadata'
import { useSuitableAccounts } from '../../utils/usePermissions'
import { usePool, usePoolAccountOrders, usePoolFees } from '../../utils/usePools'
import { usePoolsForWhichAccountIsFeeder } from '../../utils/usePoolsForWhichAccountIsFeeder'
Expand All @@ -51,17 +55,22 @@ import { isCashLoan, isExternalLoan } from '../Loan/utils'

type Attestation = {
portfolio: {
timestamp: number
decimals: number
assets: {
asset: string
assetId?: string
name: string
quantity: string
price: string
}[]
netAssetValue: string
netFeeValue: string
tokenSupply: string[]
tokenPrice: string[]
signature?: string
tokenAddresses: Record<string, string[]>
}
signature?: {
hash: string
publicKey: string
}
}

Expand All @@ -82,13 +91,14 @@ type Row = FormValues['feed'][0] | ActiveLoan | CreatedLoan
const MAX_COLLECT = 100 // maximum number of transactions to collect in one batch

export function NavManagementAssetTable({ poolId }: { poolId: string }) {
const queryClient = useQueryClient()
const { data: domains } = useActiveDomains(poolId)
const allowedPools = usePoolsForWhichAccountIsFeeder()
const isFeeder = !!allowedPools?.find((p) => p.id === poolId)
const [isEditing, setIsEditing] = React.useState(false)
const [isConfirming, setIsConfirming] = React.useState(false)
const orders = usePoolAccountOrders(poolId)
const [liquidityAdminAccount] = useSuitableAccounts({ poolId, poolRole: ['LiquidityAdmin'] })
let [liquidityAdminAccount] = useSuitableAccounts({ poolId, poolRole: ['LiquidityAdmin'] })
const pool = usePool(poolId)
const [allLoans] = useCentrifugeQuery(['loans', poolId], (cent) => cent.pools.getLoans([poolId]), {
enabled: !!poolId && !!pool,
Expand All @@ -97,15 +107,17 @@ export function NavManagementAssetTable({ poolId }: { poolId: string }) {
const { substrate } = useWallet()
const poolFees = usePoolFees(poolId)

const externalLoans = React.useMemo(
const activeLoans = React.useMemo(
() =>
(allLoans?.filter(
// Keep external loans, except ones that are fully repaid
(l) => isExternalLoan(l) && l.status !== 'Closed' && (!('presentValue' in l) || !l.presentValue.isZero())
) as ExternalLoan[]) ?? [],
allLoans?.filter(
// Filter out loans that are closed or fully repaid
(l) => l.status !== 'Closed' && (!('presentValue' in l) || !l.presentValue.isZero())
) ?? [],
[allLoans]
)

const externalLoans = React.useMemo(() => activeLoans.filter(isExternalLoan), [activeLoans])

const cashLoans =
(allLoans?.filter((l) => isCashLoan(l) && l.status !== 'Closed') as (CreatedLoan | ActiveLoan)[]) ?? []
const api = useCentrifugeApi()
Expand All @@ -127,58 +139,96 @@ export function NavManagementAssetTable({ poolId }: { poolId: string }) {
const { execute, isLoading } = useCentrifugeTransaction(
'Update NAV',
(cent) => (args: [values: FormValues], options) => {
const attestation: Attestation = {
portfolio: {
decimals: pool.currency.decimals,
assets: externalLoans.map((l) => ({
asset: l.id,
quantity: l.pricing.outstandingQuantity.toString(),
price: (l as ActiveLoan).currentPrice?.toString() ?? '0',
})),
netAssetValue: pool.nav.total.toString(),
netFeeValue: pool.nav.fees.toString(),
tokenSupply: pool.tranches.map((t) => t.totalIssuance.toString()),
tokenPrice: pool.tranches.map((t) => t.tokenPrice?.toString() ?? '0'),
},
}

let $signMessage: Observable<string | null> = of(null)
if (provider) {
$signMessage = from(provider.getSigner()).pipe(
switchMap((signer) => from(signer.signMessage(JSON.stringify(attestation)))),
catchError((error) => {
console.error('EVM signing failed:', error)
return of(null)
})
const $attestationHash = defer(async () => {
const nftsByNftId = new Map(
(
await firstValueFrom(
cent.nfts.getNfts([allLoans![0].asset.collectionId, activeLoans.map((l) => l.asset.nftId)])
)
).map((nft) => [nft.id, nft])
)
} else if (substrate?.selectedAccount?.address && substrate?.selectedWallet?.signer?.signRaw) {
$signMessage = from(
substrate.selectedWallet.signer.signRaw({
address: substrate.selectedAccount.address,
data: stringToHex(JSON.stringify(attestation)),
type: 'bytes',
})
).pipe(
map(({ signature }) => signature),
catchError((error) => {
console.error('Substrate signing failed:', error)
return of(null)
const nftMetas = await Promise.all(
activeLoans.map((l) => {
const nft = nftsByNftId.get(l.asset.nftId)
if (!nft?.metadataUri) return null
return queryClient.fetchQuery(['metadata', nft.metadataUri], () => metadataQueryFn(nft.metadataUri!, cent))
})
)
}

const $attestationHash = $signMessage.pipe(
switchMap((signature) => {
if (signature) {
attestation.portfolio.signature = signature
return cent.metadata.pinJson(attestation)
} else {
console.warn('No signature available')
return of(null)
const attestation: Attestation = {
portfolio: {
timestamp: Math.floor(Date.now() / 1000),
decimals: pool.currency.decimals,
assets: [
{
assetId: '0',
name: 'Onchain reserve',
quantity: pool.reserve.total.toString(),
hieronx marked this conversation as resolved.
Show resolved Hide resolved
price: CurrencyBalance.fromFloat(1, pool.currency.decimals).toString(),
},
{
name: 'Accrued fees',
quantity: pool.nav.fees.toString(),
price: CurrencyBalance.fromFloat(-1, pool.currency.decimals).toString(),
},
...activeLoans.map((l, i) =>
isExternalLoan(l)
? {
assetId: l.id,
name: nftMetas[i]?.name ?? '',
quantity: CurrencyBalance.fromFloat(
l.pricing.outstandingQuantity.toDecimal(),
pool.currency.decimals
).toString(),
Comment on lines +178 to +181
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a way to check how that rounds? ^^

price: (l as ActiveLoan).currentPrice?.toString() ?? '0',
}
: {
assetId: l.id,
name: nftMetas[i]?.name ?? '',
quantity: (l as ActiveLoan).presentValue.toString(),
price: CurrencyBalance.fromFloat(1, pool.currency.decimals).toString(),
}
),
],
netAssetValue: pool.nav.total.toString(),
tokenSupply: pool.tranches.map((t) => t.totalIssuance.toString()),
tokenPrice: pool.tranches.map((t) => t.tokenPrice?.toString() ?? '0'),
tokenAddresses: Object.fromEntries(
domains
?.map((d) => [d.chainId, Object.values(d.trancheTokens) as string[]] as const)
.filter(([, tokens]) => !tokens.every((t) => t === null)) || []
),
},
}

let signature: { hash: string; publicKey: string } | null = null
try {
const message = JSON.stringify(attestation.portfolio)
if (provider) {
const signer = await provider.getSigner()
const sig = await signer.signMessage(message)
const hash = keccak256(toUtf8Bytes(`\x19Ethereum Signed Message:\n${message.length}${message}`))
const recoveredPubKey = SigningKey.recoverPublicKey(hash, sig)
signature = { hash: sig, publicKey: recoveredPubKey }
} else if (substrate.selectedAccount?.address && substrate?.selectedWallet?.signer?.signRaw) {
const { address } = substrate.selectedAccount
const { signature: sig } = await substrate.selectedWallet.signer.signRaw({
address: address,
data: stringToHex(message),
type: 'bytes',
})
signature = { hash: sig, publicKey: addressToHex(address) }
}
}),
map((result) => (result ? result.ipfsHash : null))
)
} catch {}
if (!signature) return null

attestation.signature = signature
try {
const result = await firstValueFrom(cent.metadata.pinJson(attestation))
return result.ipfsHash
} catch {
return null
}
})

const deployedDomains = domains?.filter((domain) => domain.hasDeployedLp)
const updateTokenPrices = deployedDomains
Expand All @@ -194,12 +244,8 @@ export function NavManagementAssetTable({ poolId }: { poolId: string }) {
)
: []

return combineLatest([
$attestationHash,
cent.pools.closeEpoch([poolId, false], { batch: true }),
...updateTokenPrices,
]).pipe(
switchMap(([attestationHash, closeTx, ...updateTokenPricesTxs]) => {
return combineLatest([$attestationHash, ...updateTokenPrices]).pipe(
switchMap(([attestationHash, ...updateTokenPricesTxs]) => {
if (!attestationHash) {
throw new Error('Attestation signing failed')
}
Expand All @@ -218,7 +264,7 @@ export function NavManagementAssetTable({ poolId }: { poolId: string }) {

if (liquidityAdminAccount && orders?.length) {
batch.push(
...closeTx.method.args[0],
api.tx.poolSystem.closeEpoch(poolId),
...orders
.slice(0, ordersFullyExecutable ? MAX_COLLECT : 0)
.map((order) =>
Expand Down
Loading
Loading