Skip to content

Commit

Permalink
Merge pull request #668 from subspace/633-add-flow-to-claim-operator-…
Browse files Browse the repository at this point in the history
…staking-token

Add flow to claim operator staking token
  • Loading branch information
marc-aurele-besner committed Jul 3, 2024
2 parents 8da1636 + 5c94e97 commit 7b1ea0b
Show file tree
Hide file tree
Showing 10 changed files with 484 additions and 36 deletions.
7 changes: 7 additions & 0 deletions explorer/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ DISCORD_GUILD_ROLE_ID_FARMER="<discord-farmer-role-id>"
NEXT_PUBLIC_RPC_URL="wss://"
NEXT_PUBLIC_NOVA_RPC_URL="wss://"

SLACK_TOKEN=""
SLACK_CONVERSATION_ID=""

NEXT_PUBLIC_SHOW_LOCALHOST="false"

WALLET_CLAIM_OPERATOR_DISBURSEMENT_URI="//Alice"
CLAIM_OPERATOR_DISBURSEMENT_AMOUNT="100000000000000000000"
CLAIM_WALLET_LOW_FUND_WARNING="500000000000000000000"

# Mock Wallet Configuration (to be used for development only)
# NEXT_PUBLIC_MOCK_WALLET="true"
# NEXT_PUBLIC_MOCK_WALLET_ADDRESS="" # jeremy
Expand Down
131 changes: 131 additions & 0 deletions explorer/src/app/api/claim/[...params]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { AuthProvider } from '@/constants'
import { ApiPromise, Keyring, WsProvider } from '@polkadot/api'
import { stringToU8a } from '@polkadot/util'
import { cryptoWaitReady, decodeAddress, signatureVerify } from '@polkadot/util-crypto'
import { chains } from 'constants/chains'
import { CLAIM_TYPES } from 'constants/routes'
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from 'utils/auth/verifyToken'
import {
findClaim,
findClaimStats,
findUserByID,
saveClaim,
saveClaimStats,
updateClaimStats,
updateUser,
} from 'utils/fauna'
import { formatUnitsToNumber } from 'utils/number'
import { sendSlackStatsMessage, walletBalanceLowSlackMessage } from 'utils/slack'

export const POST = async (req: NextRequest) => {
try {
if (!process.env.WALLET_CLAIM_OPERATOR_DISBURSEMENT_URI)
throw new Error('Missing WALLET_CLAIM_OPERATOR_DISBURSEMENT_URI')
if (
!process.env.CLAIM_OPERATOR_DISBURSEMENT_AMOUNT &&
process.env.CLAIM_OPERATOR_DISBURSEMENT_AMOUNT !== '0'
)
throw new Error('Missing CLAIM_OPERATOR_DISBURSEMENT_AMOUNT')

const session = verifyToken()
await cryptoWaitReady()

const dbSession = await findUserByID(session.id)

if (!dbSession) return NextResponse.json({ error: 'User not found' }, { status: 404 })

const pathname = req.nextUrl.pathname
const chain = pathname.split('/').slice(3)[0]
const claimType = pathname.split('/').slice(4)[0]
if (claimType !== CLAIM_TYPES.OperatorDisbursement)
return NextResponse.json({ error: 'Invalid claim type' }, { status: 400 })

const chainMatch = chains.find((c) => c.urls.page === chain)
if (!chainMatch) return NextResponse.json({ error: 'Invalid chain' }, { status: 400 })

const previousClaim = await findClaim(session.id, chainMatch.urls.page, claimType)
if (previousClaim) return NextResponse.json({ error: 'Already claimed' }, { status: 400 })

const claim = await req.json()
const { message, signature, address } = claim

// Verify the signature
const publicKey = decodeAddress(address)
const isValid = signatureVerify(stringToU8a(message), signature, publicKey).isValid
if (!isValid) return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })

const claimStats = await findClaimStats(chainMatch.urls.page, claimType)

// Connect to the Polkadot node
const wsProvider = new WsProvider(chainMatch.urls.rpc)
const api = await ApiPromise.create({ provider: wsProvider })

// Create a keyring instance from wallet in environments variables
const keyring = new Keyring({ type: 'sr25519' })
const wallet = keyring.addFromUri(process.env.WALLET_CLAIM_OPERATOR_DISBURSEMENT_URI)

// Get wallet free balance
const {
data: { free },
} = (await api.query.system.account(wallet.address)).toJSON() as { data: { free: string } }
if (BigInt(free) < BigInt(process.env.CLAIM_WALLET_LOW_FUND_WARNING || 1000 * 10 ** 18))
await walletBalanceLowSlackMessage(formatUnitsToNumber(free).toString(), wallet.address)

if (BigInt(free) <= BigInt(process.env.CLAIM_OPERATOR_DISBURSEMENT_AMOUNT))
return NextResponse.json({ error: 'Insufficient funds' }, { status: 400 })

// Create and sign the transfer transaction
const block = await api.rpc.chain.getBlock()
const transfer = api.tx.balances.transferKeepAlive(
address,
process.env.CLAIM_OPERATOR_DISBURSEMENT_AMOUNT,
)
const hash = await transfer.signAndSend(wallet)
const tx = {
ownerAccount: wallet.address,
status: 'pending',
submittedAtBlockHash: block.block.header.hash.toHex(),
submittedAtBlockNumber: block.block.header.number.toNumber(),
call: 'balances.transferKeepAlive',
txHash: hash.hash.toHex(),
blockHash: '',
}

await saveClaim(session, chainMatch.urls.page, claimType, claim, tx)

if (!claimStats) {
const slackMessage = await sendSlackStatsMessage(1)
if (slackMessage) await saveClaimStats(session, chainMatch.urls.page, claimType, slackMessage)
} else {
await sendSlackStatsMessage(
claimStats[0].data.totalClaims + 1,
claimStats[0].data.slackMessageId,
)
await updateClaimStats(claimStats[0].ref, claimStats[0].data, session)
}

await api.disconnect()

await updateUser(dbSession[0].ref, dbSession[0].data, AuthProvider.subspace, {
...dbSession[0].data.subspace,
disbursements:
dbSession[0].data.subspace && dbSession[0].data.subspace.disbursements
? {
...dbSession[0].data.subspace.disbursements,
stakeWars2: true,
}
: {
stakeWars2: true,
},
})

return NextResponse.json({
message: 'Disbursement claimed successfully',
hash: hash.hash.toHex(),
})
} catch (error) {
console.error('Error processing disbursement:', error)
return NextResponse.json({ error: 'Failed to claim disbursement' }, { status: 500 })
}
}
110 changes: 101 additions & 9 deletions explorer/src/components/WalletSideKick/GetDiscordRoles.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { CheckMarkIcon } from '@/components/icons/CheckMarkIcon'
import { useQuery } from '@apollo/client'
import { CheckCircleIcon, ClockIcon } from '@heroicons/react/24/outline'
import { Accordion } from 'components/common/Accordion'
import { List, StyledListItem } from 'components/common/List'
import { Modal } from 'components/common/Modal'
import { EXTERNAL_ROUTES } from 'constants/routes'
import { CheckMarkIcon } from 'components/icons/CheckMarkIcon'
import { EXTERNAL_ROUTES, ROUTE_API } from 'constants/routes'
import { ExtrinsicsByHashQuery } from 'gql/graphql'
import useDomains from 'hooks/useDomains'
import useWallet from 'hooks/useWallet'
import { signIn, useSession } from 'next-auth/react'
import Link from 'next/link'
import { FC, useCallback, useState } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { QUERY_EXTRINSIC_BY_HASH } from '../Extrinsic/query'

interface StakingSummaryProps {
subspaceAccount: string
Expand All @@ -16,6 +21,7 @@ interface StakingSummaryProps {
interface StyledButtonProps {
children: React.ReactNode
className?: string
isDisabled?: boolean
onClick?: () => void
}

Expand All @@ -24,9 +30,10 @@ type ExplainerProps = {
onClose: () => void
}

const StyledButton: FC<StyledButtonProps> = ({ children, className, onClick }) => (
const StyledButton: FC<StyledButtonProps> = ({ children, className, isDisabled, onClick }) => (
<button
className={`border-purpleAccent w-[100px] rounded-xl border bg-transparent px-4 shadow-lg ${className}`}
className={`w-[100px] rounded-xl border border-purpleAccent bg-transparent px-4 shadow-lg ${className}`}
disabled={isDisabled}
onClick={onClick}
>
{children}
Expand All @@ -51,7 +58,7 @@ const Explainer: FC<ExplainerProps> = ({ isOpen, onClose }) => {
</div>
</div>
<button
className='bg-grayDarker dark:bg-blueAccent flex w-full max-w-fit items-center gap-2 rounded-full px-2 text-sm font-medium text-white md:space-x-4 md:text-base'
className='flex w-full max-w-fit items-center gap-2 rounded-full bg-grayDarker px-2 text-sm font-medium text-white dark:bg-blueAccent md:space-x-4 md:text-base'
onClick={onClose}
>
Close
Expand Down Expand Up @@ -79,7 +86,18 @@ const ExplainerLinkAndModal: FC = () => {

export const GetDiscordRoles: FC<StakingSummaryProps> = ({ subspaceAccount }) => {
const { data: session } = useSession()
const { selectedChain } = useDomains()
const { actingAccount, injector } = useWallet()
const [claimIsPending, setClaimIsPending] = useState(false)
const [claimIsFinalized, setClaimIsFinalized] = useState(false)
const [claimError, setClaimError] = useState<string | null>(null)
const [claimHash, setClaimHash] = useState<string | null>(null)

const { data } = useQuery<ExtrinsicsByHashQuery>(QUERY_EXTRINSIC_BY_HASH, {
variables: { hash: claimHash },
skip: claimHash === null || claimIsFinalized,
pollInterval: 6000,
})

const handleWalletOwnership = useCallback(async () => {
try {
Expand Down Expand Up @@ -116,19 +134,93 @@ export const GetDiscordRoles: FC<StakingSummaryProps> = ({ subspaceAccount }) =>
[],
)

const handleClaimOperatorDisbursement = useCallback(async () => {
setClaimError(null)
if (!actingAccount || !injector) throw new Error('No wallet connected')
if (!injector.signer.signRaw) throw new Error('No signer')
if (!subspaceAccount) throw new Error('No subspace account')

// Prepare and sign the message
const message = `I am the owner of ${subspaceAccount} and I claim the operator disbursement`
const signature = await injector.signer.signRaw({
address: actingAccount.address,
type: 'bytes',
data: message,
})
if (!signature) throw new Error('No signature')
const claim = await fetch(ROUTE_API.claim.operatorDisbursement(selectedChain.urls.page), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: actingAccount.address,
message,
signature: signature.signature,
}),
}).then((res) => res.json())
if (claim.hash) {
setClaimIsPending(true)
setClaimHash(claim.hash)
} else if (claim.error) setClaimError(claim.error)
}, [actingAccount, injector, selectedChain.urls.page, subspaceAccount])

useEffect(() => {
if (data && data.extrinsics && data.extrinsics.length > 0) setClaimIsFinalized(true)
}, [data])

if (session?.user?.discord?.vcs.roles.farmer)
return (
<div className='bg-grayLight dark:bg-blueAccent m-2 mt-0 rounded-[20px] p-5 dark:text-white'>
<div className='m-2 mt-0 rounded-[20px] bg-grayLight p-5 dark:bg-blueAccent dark:text-white'>
<Accordion title='Your verified roles on Discord'>
<List>
<StyledListItem title='You are a Farmer on Discord'>🌾</StyledListItem>
<StyledListItem title='You are a Verified Farmer on Discord'>🌾</StyledListItem>
</List>
<List>
<StyledListItem
title={
<>
<p>
<b>Run an operator node</b> in Stake Wars 2,
</p>
<p>
{' '}
claim <b>100 {selectedChain.token.symbol}</b> to cover the operator stake.
</p>
</>
}
>
{claimIsFinalized ? (
<>
<p className='text-sm text-gray-500'>
Claimed <CheckCircleIcon className='size-5' stroke='green' />
</p>
</>
) : (
<>
{claimIsPending ? (
<p className='text-sm text-gray-500'>
Pending <ClockIcon className='size-5' stroke='orange' />
</p>
) : (
<StyledButton
className={`ml-2 ${claimError !== null && 'cursor-not-allowed'}`}
isDisabled={claimError !== null}
onClick={handleClaimOperatorDisbursement}
>
Claim
</StyledButton>
)}
</>
)}
</StyledListItem>
{claimError && <p className='text-sm text-red-500'>{claimError}</p>}
</List>
</Accordion>
<ExplainerLinkAndModal />
</div>
)

return (
<div className='bg-grayLight dark:bg-blueAccent m-2 mt-0 rounded-[20px] p-5 dark:text-white'>
<div className='m-2 mt-0 rounded-[20px] bg-grayLight p-5 dark:bg-blueAccent dark:text-white'>
<Accordion title='Get verified roles on Discord'>
<List>
<StyledListItem title='Verify the ownership of your wallet'>
Expand Down
16 changes: 16 additions & 0 deletions explorer/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ export const INTERNAL_ROUTES = {
catchAll: '*',
}

export enum API_ROUTES {
Auth = 'auth',
Claim = 'claim',
}

export enum CLAIM_TYPES {
OperatorDisbursement = 'operator-disbursement',
}

export const ROUTE_API = {
claim: {
operatorDisbursement: (chain: string): string =>
`/api/${API_ROUTES.Claim}/${chain}/${CLAIM_TYPES.OperatorDisbursement}`,
},
}

export enum ROUTE_EXTRA_FLAG_TYPE {
WALLET = 'wallet',
WALLET_SIDEKICK = 'walletSidekick',
Expand Down
3 changes: 3 additions & 0 deletions explorer/src/constants/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const DEFAULT_SUBSPACE_TOKEN: SubspaceToken = {
operator: false,
nominator: false,
},
disbursements: {
stakeWars2: false,
},
}

export const DEFAULT_DISCORD_TOKEN: DiscordToken = {
Expand Down
3 changes: 3 additions & 0 deletions explorer/src/types/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export type SubspaceToken = {
operator: boolean
nominator: boolean
}
disbursements?: {
stakeWars2: boolean
}
}

export type DiscordToken = {
Expand Down
16 changes: 2 additions & 14 deletions explorer/src/utils/auth/providers/discord.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { AuthProvider } from 'constants/session'
import * as jsonwebtoken from 'jsonwebtoken'
import type { TokenSet } from 'next-auth'
import { User } from 'next-auth'
import { JWT } from 'next-auth/jwt'
import type { DiscordProfile } from 'next-auth/providers/discord'
import DiscordProvider from 'next-auth/providers/discord'
import { cookies } from 'next/headers'
import { findUserByID, saveUser, updateUser } from 'utils/fauna'
import {
giveDiscordFarmerRole,
verifyDiscordFarmerRole,
verifyDiscordGuildMember,
} from '../vcs/discord'
import { verifyToken } from '../verifyToken'

const { DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET } = process.env

Expand All @@ -29,17 +27,7 @@ export const Discord = () => {
try {
if (!token.access_token) throw new Error('No access token')

if (!process.env.NEXTAUTH_SECRET) throw new Error('No secret')
const { NEXTAUTH_SECRET } = process.env

const { get } = cookies()
const sessionToken =
get('__Secure-next-auth.session-token')?.value || get('next-auth.session-token')?.value
if (!sessionToken) throw new Error('No session token')

const session = jsonwebtoken.verify(sessionToken, NEXTAUTH_SECRET, {
algorithms: ['HS256'],
}) as JWT
const session = verifyToken()
const did = 'did:openid:discord:' + profile.id

const member = await verifyDiscordGuildMember(token.access_token)
Expand Down
Loading

0 comments on commit 7b1ea0b

Please sign in to comment.