Skip to content

Commit 9a530cf

Browse files
feat: integrate cloud credits (#3021)
Co-authored-by: Pavan Soratur <[email protected]>
1 parent 80aa415 commit 9a530cf

File tree

15 files changed

+609
-9
lines changed

15 files changed

+609
-9
lines changed

apps/tangle-dapp/src/components/account/AccountSummaryCard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import CardWithTangleLogo from '../CardWithTangleLogo';
33
import AccountAddress from './AccountAddress';
44
import Actions from './Actions';
55
import Balance from './Balance';
6+
import { ClaimCreditsButton } from '../../features/claimCredits';
67

78
const AccountSummaryCard: FC<{ className?: string }> = ({ className }) => {
89
return (
910
<CardWithTangleLogo className={className}>
10-
<div className="w-full space-y-5">
11-
<header>
11+
<div className="w-full flex flex-col gap-4">
12+
<header className="flex lg:flex-col xl:flex-row justify-between items-start lg:min-h-[80px] xl:min-h-0">
1213
<AccountAddress />
14+
<ClaimCreditsButton />
1315
</header>
1416

1517
<Balance />

apps/tangle-dapp/src/components/account/Actions.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import { PagePath, StaticSearchQueryPath } from '../../types';
2222
import formatTangleBalance from '../../utils/formatTangleBalance';
2323
import ActionItem from './ActionItem';
2424
import WithdrawEvmBalanceAction from './WithdrawEvmBalanceAction';
25+
import { ClaimCreditsModal } from '../../features/claimCredits';
2526

2627
const Actions: FC = () => {
2728
const { nativeTokenSymbol } = useNetworkStore();
2829
const { execute: executeVestTx, status: vestTxStatus } = useVestTx();
2930
const activeAccountAddress = useActiveAccountAddress();
3031
const { transferable: transferableBalance } = useBalances();
3132
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false);
33+
const [isCreditsModalOpen, setIsCreditsModalOpen] = useState(false);
3234

3335
const { isEligible: isAirdropEligible } = useAirdropEligibility();
3436

@@ -141,6 +143,11 @@ const Actions: FC = () => {
141143
isModalOpen={isTransferModalOpen}
142144
setIsModalOpen={setIsTransferModalOpen}
143145
/>
146+
147+
<ClaimCreditsModal
148+
isOpen={isCreditsModalOpen}
149+
setIsOpen={setIsCreditsModalOpen}
150+
/>
144151
</div>
145152
);
146153
};

apps/tangle-dapp/src/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export enum TxName {
7676
RESTAKE_EXECUTE_WITHDRAW = 'restake execute withdraw',
7777
RESTAKE_CANCEL_WITHDRAW = 'restake cancel withdraw',
7878
CLAIM_REWARDS = 'claim rewards',
79+
CLAIM_CREDITS = 'claim credits',
7980
DEMOCRACY_UNLOCK = 'unlock democracy',
8081
RESTAKE_NATIVE_DELEGATE = 'restake delegate nomination',
8182
RESTAKE_NATIVE_UNSTAKE = 'restake undelegate native',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export enum ReactQueryKey {
22
GetAccountRewards = 'GetAccountRewards',
33
GetAccountPoints = 'GetAccountPoints',
4+
GetCredits = 'GetCredits',
45
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback } from 'react';
2+
import { toHex } from 'viem';
3+
import { TxName } from '../../constants';
4+
import useAgnosticTx from '@tangle-network/tangle-shared-ui/hooks/useAgnosticTx';
5+
import { EvmTxFactory } from '@tangle-network/tangle-shared-ui/hooks/useEvmPrecompileCall';
6+
import { SubstrateTxFactory } from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx';
7+
import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification';
8+
import { BN } from '@polkadot/util';
9+
import { PrecompileAddress } from '@tangle-network/tangle-shared-ui/constants/evmPrecompiles';
10+
import CREDITS_PRECOMPILE_ABI from '@tangle-network/tangle-shared-ui/abi/credits';
11+
12+
type Context = {
13+
amountToClaim: BN;
14+
offchainAccountId: string;
15+
};
16+
17+
const useClaimCreditsTx = () => {
18+
const evmTxFactory: EvmTxFactory<
19+
typeof CREDITS_PRECOMPILE_ABI,
20+
'claim_credits',
21+
Context
22+
> = useCallback((context) => {
23+
return {
24+
functionName: 'claim_credits',
25+
arguments: [
26+
BigInt(context.amountToClaim.toString()),
27+
toHex(new TextEncoder().encode(context.offchainAccountId)),
28+
],
29+
};
30+
}, []);
31+
32+
const substrateTxFactory: SubstrateTxFactory<Context> = useCallback(
33+
(api, _activeSubstrateAddress, context) => {
34+
// Based on the pallet specification: api.tx.credits.claim_credits(origin, amount_to_claim, offchain_account_id)
35+
return api.tx.credits.claimCredits(
36+
context.amountToClaim,
37+
context.offchainAccountId,
38+
);
39+
},
40+
[],
41+
);
42+
43+
return useAgnosticTx({
44+
name: TxName.CLAIM_CREDITS,
45+
abi: CREDITS_PRECOMPILE_ABI,
46+
precompileAddress: PrecompileAddress.CREDITS,
47+
evmTxFactory,
48+
substrateTxFactory,
49+
successMessageByTxName: SUCCESS_MESSAGES,
50+
});
51+
};
52+
53+
export default useClaimCreditsTx;
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { useActiveChain } from '@tangle-network/api-provider-environment/hooks/useActiveChain';
2+
import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore';
3+
import useSubstrateAddress from '@tangle-network/tangle-shared-ui/hooks/useSubstrateAddress';
4+
import { queryOptions, useQuery } from '@tanstack/react-query';
5+
import { useMemo } from 'react';
6+
import { ReactQueryKey } from '../../constants/reactQuery';
7+
import { LoggerService } from '@tangle-network/browser-utils';
8+
import { BN } from '@polkadot/util';
9+
import JSONParseBigInt from '@tangle-network/ui-components/utils/JSONParseBigInt';
10+
import JSONStringifyBigInt from '@tangle-network/ui-components/utils/JSONStringifyBigInt';
11+
import { z } from 'zod';
12+
13+
const logger = LoggerService.new('useCredits');
14+
15+
// This represents the structure of our credits data
16+
export type CreditsData = {
17+
amount: BN;
18+
};
19+
20+
export default function useCredits() {
21+
const activeSubstrateAddress = useSubstrateAddress(false);
22+
const { network } = useNetworkStore();
23+
const [activeChain] = useActiveChain();
24+
25+
// Use archive RPC endpoint if available
26+
const overrideRpcEndpoint = useMemo(() => {
27+
const wsEndpoints = activeChain?.rpcUrls.default?.webSocket;
28+
29+
if (wsEndpoints && wsEndpoints.length > 0) {
30+
return wsEndpoints[0];
31+
}
32+
33+
return network?.archiveRpcEndpoint ?? network.wsRpcEndpoints[0];
34+
}, [
35+
activeChain?.rpcUrls.default?.webSocket,
36+
network?.archiveRpcEndpoint,
37+
network.wsRpcEndpoints,
38+
]);
39+
40+
const { data: creditsResponse, ...rest } = useQuery(
41+
getQueryOptions(overrideRpcEndpoint, activeSubstrateAddress),
42+
);
43+
44+
const data = useMemo(() => {
45+
if (!creditsResponse) {
46+
return null;
47+
}
48+
49+
if ('error' in creditsResponse) {
50+
logger.error('Failed to fetch credits', creditsResponse);
51+
return null;
52+
}
53+
54+
return {
55+
amount: new BN(creditsResponse.result.toString()),
56+
};
57+
}, [creditsResponse]);
58+
59+
return {
60+
data,
61+
...rest,
62+
};
63+
}
64+
65+
const responseSchema = z.union([
66+
z.object({
67+
id: z.number(),
68+
jsonrpc: z.string(),
69+
error: z.object({
70+
code: z.number(),
71+
message: z.string(),
72+
data: z.string().optional(),
73+
}),
74+
}),
75+
z.object({
76+
id: z.number(),
77+
jsonrpc: z.string(),
78+
result: z.bigint(),
79+
}),
80+
]);
81+
82+
async function fetcher(rpcEndpoint: string, activeAddress: string | null) {
83+
if (!activeAddress) {
84+
return null;
85+
}
86+
87+
try {
88+
// Change the protocol to http/https if it's not already
89+
const url = new URL(rpcEndpoint);
90+
url.protocol = url.protocol === 'ws:' ? 'http' : 'https';
91+
92+
const body = JSONStringifyBigInt({
93+
id: 1,
94+
jsonrpc: '2.0',
95+
method: 'credits_queryUserCredits',
96+
params: [activeAddress],
97+
});
98+
99+
const response = await fetch(url.toString(), {
100+
method: 'POST',
101+
body,
102+
headers: {
103+
'Content-Type': 'application/json',
104+
},
105+
});
106+
107+
if (!response.ok) {
108+
return {
109+
id: 1,
110+
jsonrpc: '2.0',
111+
error: {
112+
code: response.status,
113+
message: response.statusText,
114+
},
115+
} satisfies z.infer<typeof responseSchema>;
116+
}
117+
118+
const parsed = JSONParseBigInt(await response.text());
119+
const result = responseSchema.safeParse(parsed);
120+
121+
if (result.success === false) {
122+
const message =
123+
result.error.issues.length > 0
124+
? result.error.issues[0].message
125+
: 'Failed to parse credits';
126+
127+
return {
128+
id: 1,
129+
jsonrpc: '2.0',
130+
error: {
131+
code: response.status,
132+
message,
133+
},
134+
} satisfies z.infer<typeof responseSchema>;
135+
}
136+
137+
return result.data;
138+
} catch (error) {
139+
logger.error('Error fetching credits', error);
140+
throw error;
141+
}
142+
}
143+
144+
export function getQueryOptions(
145+
rpcEndpoint: string,
146+
activeSubstrateAddress: string | null,
147+
) {
148+
return queryOptions({
149+
queryKey: [ReactQueryKey.GetCredits, rpcEndpoint, activeSubstrateAddress],
150+
queryFn: () => fetcher(rpcEndpoint, activeSubstrateAddress),
151+
retry: 3,
152+
refetchInterval: 30000, // Refetch every 30 seconds
153+
placeholderData: (prev) => prev,
154+
});
155+
}

0 commit comments

Comments
 (0)