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

Add flow to claim operator staking token #668

Merged
merged 15 commits into from
Jul 3, 2024
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
6 changes: 6 additions & 0 deletions explorer/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ 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="100"

# Mock Wallet Configuration (to be used for development only)
# NEXT_PUBLIC_MOCK_WALLET="true"
# NEXT_PUBLIC_MOCK_WALLET_ADDRESS="" # jeremy
Expand Down
105 changes: 105 additions & 0 deletions explorer/src/app/api/claim/[...params]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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, saveClaim, saveClaimStats, updateClaimStats } 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 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 and add Alice's account
marc-aurele-besner marked this conversation as resolved.
Show resolved Hide resolved
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(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()

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 @@ -133,6 +133,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
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
20 changes: 20 additions & 0 deletions explorer/src/utils/auth/verifyToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as jsonwebtoken from 'jsonwebtoken'
import { JWT } from 'next-auth/jwt'
import { cookies } from 'next/headers'

export const verifyToken = () => {
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

if (session.id && session.DIDs) return session
else throw new Error('Invalid token')
}
Loading
Loading