mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-13 17:31:07 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { TxCallback } from '@pezkuwi/react-components/Status/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { BalanceOf, EthereumAddress, StatementKind } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, Card, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { ClaimStyles } from './Claim.js';
|
||||
import Statement from './Statement.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import { getStatement } from './util.js';
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
ethereumAddress?: EthereumAddress | string | null;
|
||||
onSuccess?: TxCallback;
|
||||
statementKind?: StatementKind | null;
|
||||
systemChain: string;
|
||||
}
|
||||
|
||||
function Attest ({ accountId, className, ethereumAddress, onSuccess, statementKind, systemChain }: Props): React.ReactElement<Props> | null {
|
||||
const accounts = useAccounts();
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [claimValue, setClaimValue] = useState<BN | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
useEffect((): void => {
|
||||
if (!ethereumAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
|
||||
api.query.claims
|
||||
.claims<Option<BalanceOf>>(ethereumAddress)
|
||||
.then((claim): void => {
|
||||
setClaimValue(claim.unwrapOr(BN_ZERO));
|
||||
setIsBusy(false);
|
||||
})
|
||||
.catch((error): void => {
|
||||
console.error(error);
|
||||
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [api, ethereumAddress]);
|
||||
|
||||
const statementSentence = useMemo(
|
||||
() => getStatement(systemChain, statementKind)?.sentence,
|
||||
[systemChain, statementKind]
|
||||
);
|
||||
|
||||
if (isBusy || !claimValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noClaim = claimValue.isZero();
|
||||
|
||||
if (noClaim || !statementSentence) {
|
||||
return (
|
||||
<Card isError>
|
||||
<StyledDiv className={className}>
|
||||
{noClaim && (
|
||||
<p>{t('There is no on-chain claimable balance associated with the Ethereum account {{ethereumAddress}}', {
|
||||
replace: { ethereumAddress }
|
||||
})}</p>
|
||||
)}
|
||||
{!statementSentence && (
|
||||
<p>{t('There is no on-chain attestation statement associated with the Ethereum account {{ethereumAddress}}', {
|
||||
replace: { ethereumAddress }
|
||||
})}</p>
|
||||
)}
|
||||
</StyledDiv>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accounts.isAccount(accountId)) {
|
||||
return (
|
||||
<Card isError>
|
||||
<StyledDiv className={className}>
|
||||
{t('We found a pre-claim with this Pezkuwi address. However, attesting requires signing with this account. To continue with attesting, please add this account as an owned account first.')}
|
||||
<h2>
|
||||
<FormatBalance
|
||||
label={t('Account balance:')}
|
||||
value={claimValue}
|
||||
/>
|
||||
</h2>
|
||||
</StyledDiv>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card isSuccess>
|
||||
<StyledDiv className={className}>
|
||||
<Statement
|
||||
kind={statementKind}
|
||||
systemChain={systemChain}
|
||||
/>
|
||||
<h2>
|
||||
<FormatBalance
|
||||
label={t('Account balance:')}
|
||||
value={claimValue}
|
||||
/>
|
||||
</h2>
|
||||
<Button.Group>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='paper-plane'
|
||||
isDisabled={!statementSentence}
|
||||
label={t('I agree')}
|
||||
onSuccess={onSuccess}
|
||||
params={[statementSentence]}
|
||||
tx={api.tx.claims.attest}
|
||||
/>
|
||||
</Button.Group>
|
||||
</StyledDiv>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`${ClaimStyles}`;
|
||||
|
||||
export default React.memo(Attest);
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { TxCallback } from '@pezkuwi/react-components/Status/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { BalanceOf, EthereumAddress, EthereumSignature, StatementKind } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Card, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
import { addrToChecksum, getStatement } from './util.js';
|
||||
|
||||
interface Props {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
ethereumAddress?: EthereumAddress | string | null;
|
||||
ethereumSignature?: EthereumSignature | string | null;
|
||||
// Do we sign with `claims.claimAttest` (new) instead of `claims.claim` (old)?
|
||||
isOldClaimProcess: boolean;
|
||||
onSuccess?: TxCallback;
|
||||
statementKind?: StatementKind | null;
|
||||
}
|
||||
|
||||
interface ConstructTx {
|
||||
params?: unknown[];
|
||||
tx?: (...args: unknown[]) => SubmittableExtrinsic<'promise'>;
|
||||
}
|
||||
|
||||
// Depending on isOldClaimProcess, construct the correct tx.
|
||||
// FIXME We actually want to return the constructed extrinsic here (probably in useMemo)
|
||||
function constructTx (api: ApiPromise, systemChain: string, accountId: string, ethereumSignature: EthereumSignature | string | undefined | null, kind: StatementKind | undefined | null, isOldClaimProcess: boolean): ConstructTx {
|
||||
if (!ethereumSignature) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return isOldClaimProcess || !kind
|
||||
? { params: [accountId, ethereumSignature], tx: api.tx.claims.claim }
|
||||
: { params: [accountId, ethereumSignature, getStatement(systemChain, kind)?.sentence], tx: api.tx.claims.claimAttest };
|
||||
}
|
||||
|
||||
function Claim ({ accountId, className = '', ethereumAddress, ethereumSignature, isOldClaimProcess, onSuccess, statementKind }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, systemChain } = useApi();
|
||||
const [claimValue, setClaimValue] = useState<BN | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
useEffect((): void => {
|
||||
if (!ethereumAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
|
||||
api.query.claims
|
||||
.claims<Option<BalanceOf>>(ethereumAddress)
|
||||
.then((claim): void => {
|
||||
setClaimValue(claim.unwrapOr(BN_ZERO));
|
||||
setIsBusy(false);
|
||||
})
|
||||
.catch((error): void => {
|
||||
console.error(error);
|
||||
|
||||
setIsBusy(false);
|
||||
});
|
||||
}, [api, ethereumAddress]);
|
||||
|
||||
if (!ethereumAddress || isBusy || !claimValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasClaim = claimValue.gt(BN_ZERO);
|
||||
|
||||
return (
|
||||
<Card
|
||||
isError={!hasClaim}
|
||||
isSuccess={hasClaim}
|
||||
>
|
||||
<StyledDiv className={className}>
|
||||
{t('Your Ethereum account')}
|
||||
<h2>{addrToChecksum(ethereumAddress.toString())}</h2>
|
||||
{hasClaim
|
||||
? (
|
||||
<>
|
||||
{t('has a valid claim for')}
|
||||
<h2><FormatBalance value={claimValue} /></h2>
|
||||
<Button.Group>
|
||||
<TxButton
|
||||
icon='paper-plane'
|
||||
isUnsigned
|
||||
label={t('Claim')}
|
||||
onSuccess={onSuccess}
|
||||
{...constructTx(api, systemChain, accountId, ethereumSignature, statementKind, isOldClaimProcess)}
|
||||
/>
|
||||
</Button.Group>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{t('does not appear to have a valid claim. Please double check that you have signed the transaction correctly on the correct ETH account.')}
|
||||
</>
|
||||
)}
|
||||
</StyledDiv>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClaimStyles = `
|
||||
font-size: var(--font-size-h3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 12rem;
|
||||
align-items: center;
|
||||
margin: 0 1rem;
|
||||
|
||||
h3 {
|
||||
font-family: monospace;
|
||||
font-size: 1.5rem;
|
||||
max-width: 100%;
|
||||
margin: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0.5rem 0 2rem;
|
||||
font-family: monospace;
|
||||
font-size: 2.5rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDiv = styled.div`${ClaimStyles}`;
|
||||
|
||||
export default React.memo(Claim);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StatementKind } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
import { getStatement } from './util.js';
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
kind?: StatementKind | null;
|
||||
systemChain: string;
|
||||
}
|
||||
|
||||
// Get the full hardcoded text for a statement
|
||||
function StatementFullText ({ statementUrl, systemChain }: { statementUrl?: string; systemChain: string }): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (systemChain) {
|
||||
case 'Pezkuwi':
|
||||
case 'Pezkuwi CC1':
|
||||
return statementUrl
|
||||
? <iframe src={statementUrl} />
|
||||
: null;
|
||||
|
||||
default:
|
||||
return <p>{t('Warning: we did not find any attest statement for {{chain}}', { replace: { chain: systemChain } })}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function Statement ({ className, kind, systemChain }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const statementUrl = getStatement(systemChain, kind)?.url;
|
||||
|
||||
if (!statementUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{t('Please read these terms and conditions carefully. By submitting this statement, you are deemed to have accepted these Terms and Conditions. If you do not agree to these terms, please refrain from accessing or proceeding. You can also find them at:')}
|
||||
<a
|
||||
className='statementUrl'
|
||||
href={statementUrl}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>{statementUrl}</a>
|
||||
<div className='statement'>
|
||||
<StatementFullText
|
||||
statementUrl={statementUrl}
|
||||
systemChain={systemChain}
|
||||
/>
|
||||
</div>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.statement{
|
||||
border: 1px solid #c2c2c2;
|
||||
background: #f2f2f2;
|
||||
height: 15rem;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
white-space: normal;
|
||||
|
||||
p {
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.statementUrl{
|
||||
margin-left: 0.3rem
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Statement);
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressMini, Card, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
import usePezkuwiPreclaims from './usePezkuwiPreclaims.js';
|
||||
|
||||
export interface Props{
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Warning ({ className }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const needsAttest = usePezkuwiPreclaims();
|
||||
|
||||
if (!needsAttest.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card isError>
|
||||
<StyledDiv className={className}>
|
||||
{
|
||||
needsAttest.length > 1
|
||||
? t('You need to sign an attestation for the following accounts:')
|
||||
: t('You need to sign an attestation for the following account:')
|
||||
}{
|
||||
needsAttest.map((address) => (
|
||||
<AddressMini
|
||||
key={address}
|
||||
value={address}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</StyledDiv>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
font-size: var(--font-size-h3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 8rem;
|
||||
align-items: center;
|
||||
margin: 0 1rem;
|
||||
|
||||
.ui--AddressMini-address {
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Warning);
|
||||
@@ -0,0 +1,326 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { EcdsaSignature, EthereumAddress, StatementKind } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Columar, Input, InputAddress, styled, Tabs, Tooltip } from '@pezkuwi/react-components';
|
||||
import { TokenUnit } from '@pezkuwi/react-components/InputConsts/units';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { u8aToHex, u8aToString } from '@pezkuwi/util';
|
||||
import { decodeAddress } from '@pezkuwi/util-crypto';
|
||||
|
||||
import AttestDisplay from './Attest.js';
|
||||
import ClaimDisplay from './Claim.js';
|
||||
import Statement from './Statement.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import { getStatement, recoverFromJSON } from './util.js';
|
||||
import Warning from './Warning.js';
|
||||
|
||||
export { default as useCounter } from './useCounter.js';
|
||||
|
||||
enum Step {
|
||||
Account = 0,
|
||||
ETHAddress = 1,
|
||||
Sign = 2,
|
||||
Claim = 3,
|
||||
}
|
||||
|
||||
const PRECLAIMS_LOADING = 'PRECLAIMS_LOADING';
|
||||
|
||||
// FIXME no embedded components (hossible to tweak)
|
||||
const Payload = styled.pre`
|
||||
cursor: copy;
|
||||
font: var(--font-mono);
|
||||
border: 1px dashed #c2c2c2;
|
||||
background: #f2f2f2;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
const Signature = styled.textarea`
|
||||
font: var(--font-mono);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 0.25rem;
|
||||
margin: 1rem 0;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&::-ms-input-placeholder {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&:-ms-input-placeholder {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const transformStatement = {
|
||||
transform: (option: Option<StatementKind>) => option.unwrapOr(null)
|
||||
};
|
||||
|
||||
function ClaimsApp ({ basePath }: Props): React.ReactElement<Props> {
|
||||
const [didCopy, setDidCopy] = useState(false);
|
||||
const [ethereumAddress, setEthereumAddress] = useState<string | undefined | null>(null);
|
||||
const [signature, setSignature] = useState<EcdsaSignature | null>(null);
|
||||
const [step, setStep] = useState<Step>(Step.Account);
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const { api, systemChain } = useApi();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// This preclaimEthereumAddress holds the result of `api.query.claims.preclaims`:
|
||||
// - an `EthereumAddress` when there's a preclaim
|
||||
// - null if no preclaim
|
||||
// - `PRECLAIMS_LOADING` if we're fetching the results
|
||||
const [preclaimEthereumAddress, setPreclaimEthereumAddress] = useState<string | null | undefined>(PRECLAIMS_LOADING);
|
||||
const isPreclaimed = !!preclaimEthereumAddress && preclaimEthereumAddress !== PRECLAIMS_LOADING;
|
||||
|
||||
const itemsRef = useRef([{
|
||||
isRoot: true,
|
||||
name: 'create',
|
||||
text: t('Claim tokens')
|
||||
}]);
|
||||
|
||||
// Everytime we change account, reset everything, and check if the accountId
|
||||
// has a preclaim.
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStep(Step.Account);
|
||||
setEthereumAddress(null);
|
||||
setPreclaimEthereumAddress(PRECLAIMS_LOADING);
|
||||
|
||||
if (!api.query.claims || !api.query.claims.preclaims) {
|
||||
return setPreclaimEthereumAddress(null);
|
||||
}
|
||||
|
||||
api.query.claims
|
||||
.preclaims<Option<EthereumAddress>>(accountId)
|
||||
.then((preclaim): void => {
|
||||
const address = preclaim.unwrapOr(null)?.toString();
|
||||
|
||||
setEthereumAddress(address);
|
||||
setPreclaimEthereumAddress(address);
|
||||
})
|
||||
.catch((error): void => {
|
||||
console.error(error);
|
||||
|
||||
setPreclaimEthereumAddress(null);
|
||||
});
|
||||
}, [accountId, api.query.claims, api.query.claims.preclaims]);
|
||||
|
||||
// Old claim process used `api.tx.claims.claim`, and didn't have attest
|
||||
const isOldClaimProcess = !api.tx.claims.claimAttest;
|
||||
|
||||
useEffect(() => {
|
||||
if (didCopy) {
|
||||
setTimeout((): void => {
|
||||
setDidCopy(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [didCopy]);
|
||||
|
||||
const goToStepAccount = useCallback(() => {
|
||||
setStep(Step.Account);
|
||||
}, []);
|
||||
|
||||
const goToStepSign = useCallback(() => {
|
||||
setStep(Step.Sign);
|
||||
}, []);
|
||||
|
||||
const goToStepClaim = useCallback(() => {
|
||||
setStep(Step.Claim);
|
||||
}, []);
|
||||
|
||||
// Depending on the account, decide which step to show.
|
||||
const handleAccountStep = useCallback(() => {
|
||||
if (isPreclaimed) {
|
||||
goToStepClaim();
|
||||
} else if (ethereumAddress || isOldClaimProcess) {
|
||||
goToStepSign();
|
||||
} else {
|
||||
setStep(Step.ETHAddress);
|
||||
}
|
||||
}, [ethereumAddress, goToStepClaim, goToStepSign, isPreclaimed, isOldClaimProcess]);
|
||||
|
||||
const onChangeSignature = useCallback((event: React.SyntheticEvent<Element>) => {
|
||||
const { value: signatureJson } = event.target as HTMLInputElement;
|
||||
|
||||
const { ethereumAddress, signature } = recoverFromJSON(signatureJson);
|
||||
|
||||
setEthereumAddress(ethereumAddress?.toString());
|
||||
setSignature(signature);
|
||||
}, []);
|
||||
|
||||
const onChangeEthereumAddress = useCallback((value: string) => {
|
||||
// FIXME We surely need a better check than just a trim
|
||||
setEthereumAddress(value.trim());
|
||||
}, []);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
setDidCopy(true);
|
||||
}, []);
|
||||
|
||||
// If it's 1/ not preclaimed and 2/ not the old claiming process, fetch the
|
||||
// statement kind to sign.
|
||||
const statementKind = useCall<StatementKind | null>(!isPreclaimed && !isOldClaimProcess && !!ethereumAddress && api.query.claims.signing, [ethereumAddress], transformStatement);
|
||||
|
||||
const statementSentence = getStatement(systemChain, statementKind)?.sentence || '';
|
||||
const prefix = u8aToString(api.consts.claims.prefix.toU8a(true));
|
||||
const payload = accountId
|
||||
? `${prefix}${u8aToHex(decodeAddress(accountId), -1, false)}${statementSentence}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={itemsRef.current}
|
||||
/>
|
||||
{!isOldClaimProcess && <Warning />}
|
||||
<h1>
|
||||
<Trans>Claim your <em>{TokenUnit.abbr}</em> tokens</Trans>
|
||||
</h1>
|
||||
<Columar>
|
||||
<Columar.Column>
|
||||
<Card withBottomMargin>
|
||||
<h2>{t('1. Select your {{chain}} account', {
|
||||
replace: {
|
||||
chain: systemChain
|
||||
}
|
||||
})}</h2>
|
||||
<InputAddress
|
||||
defaultValue={accountId}
|
||||
label={t('claim to account')}
|
||||
onChange={setAccountId}
|
||||
type='all'
|
||||
/>
|
||||
{(step === Step.Account) && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='sign-in-alt'
|
||||
isDisabled={preclaimEthereumAddress === PRECLAIMS_LOADING}
|
||||
label={preclaimEthereumAddress === PRECLAIMS_LOADING
|
||||
? t('Loading')
|
||||
: t('Continue')
|
||||
}
|
||||
onClick={handleAccountStep}
|
||||
/>
|
||||
</Button.Group>
|
||||
)}
|
||||
</Card>
|
||||
{
|
||||
// We need to know the ethereuem address only for the new process
|
||||
// to be able to know the statement kind so that the users can sign it
|
||||
(step >= Step.ETHAddress && !isPreclaimed && !isOldClaimProcess) && (
|
||||
<Card withBottomMargin>
|
||||
<h2>{t('2. Enter the ETH address from the sale.')}</h2>
|
||||
<Input
|
||||
autoFocus
|
||||
className='full'
|
||||
label={t('Pre-sale ethereum address')}
|
||||
onChange={onChangeEthereumAddress}
|
||||
value={ethereumAddress || ''}
|
||||
/>
|
||||
{(step === Step.ETHAddress) && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='sign-in-alt'
|
||||
isDisabled={!ethereumAddress}
|
||||
label={t('Continue')}
|
||||
onClick={goToStepSign}
|
||||
/>
|
||||
</Button.Group>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{(step >= Step.Sign && !isPreclaimed) && (
|
||||
<Card>
|
||||
<h2>{t('{{step}}. Sign with your ETH address', { replace: { step: isOldClaimProcess ? '2' : '3' } })}</h2>
|
||||
{!isOldClaimProcess && (
|
||||
<Statement
|
||||
kind={statementKind}
|
||||
systemChain={systemChain}
|
||||
/>
|
||||
)}
|
||||
<div>{t('Copy the following string and sign it with the Ethereum account you used during the pre-sale in the wallet of your choice, using the string as the payload, and then paste the transaction signature object below:')}</div>
|
||||
<CopyToClipboard
|
||||
onCopy={onCopy}
|
||||
text={payload}
|
||||
>
|
||||
<Payload
|
||||
data-for='tx-payload'
|
||||
data-tip
|
||||
>
|
||||
{payload}
|
||||
</Payload>
|
||||
</CopyToClipboard>
|
||||
<Tooltip
|
||||
place='right'
|
||||
text={didCopy ? t('copied') : t('click to copy')}
|
||||
trigger='tx-payload'
|
||||
/>
|
||||
<div>{t('Paste the signed message into the field below. The placeholder text is there as a hint to what the message should look like:')}</div>
|
||||
<Signature
|
||||
onChange={onChangeSignature}
|
||||
placeholder={`{\n "address": "0x ...",\n "msg": "${prefix}:...",\n "sig": "0x ...",\n "version": "2"\n}`}
|
||||
rows={10}
|
||||
/>
|
||||
{(step === Step.Sign) && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='sign-in-alt'
|
||||
isDisabled={!accountId || !signature}
|
||||
label={t('Confirm claim')}
|
||||
onClick={goToStepClaim}
|
||||
/>
|
||||
</Button.Group>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
{accountId && (step >= Step.Claim) && (
|
||||
isPreclaimed
|
||||
? (
|
||||
<AttestDisplay
|
||||
accountId={accountId}
|
||||
ethereumAddress={ethereumAddress}
|
||||
onSuccess={goToStepAccount}
|
||||
statementKind={statementKind}
|
||||
systemChain={systemChain}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ClaimDisplay
|
||||
accountId={accountId}
|
||||
ethereumAddress={ethereumAddress}
|
||||
ethereumSignature={signature}
|
||||
isOldClaimProcess={isOldClaimProcess}
|
||||
onSuccess={goToStepAccount}
|
||||
statementKind={statementKind}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ClaimsApp);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
declare module 'secp256k1/elliptic.js' {
|
||||
export function publicKeyConvert (publicKey: Buffer, expanded: boolean): Buffer;
|
||||
export function recover (msgHash: Buffer, signature: Buffer, recovery: number): Buffer;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-claims');
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import usePezkuwiPreclaims from './usePezkuwiPreclaims.js';
|
||||
|
||||
function useCounterImpl (): number {
|
||||
const needAttest = usePezkuwiPreclaims();
|
||||
|
||||
return needAttest.length;
|
||||
}
|
||||
|
||||
export default createNamedHook('useCounter', useCounterImpl);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-settings authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { QueryableStorageEntry } from '@pezkuwi/api/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { EthereumAddress } from '@pezkuwi/types/interfaces';
|
||||
import type { Codec } from '@pezkuwi/types/types';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useCall, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
|
||||
function usePezkuwiPreclaimsImpl (): string[] {
|
||||
const { allAccounts } = useAccounts();
|
||||
const { api } = useApi();
|
||||
const mountedRef = useIsMountedRef();
|
||||
const [needsAttest, setNeedsAttest] = useState<string[]>([]);
|
||||
|
||||
// find all own preclaims
|
||||
const preclaims = useCall<[string, EthereumAddress][]>(api.query.claims?.preclaims?.multi, [allAccounts], {
|
||||
transform: (preclaims: Option<EthereumAddress>[]) =>
|
||||
preclaims
|
||||
.map((opt, index): [string, Option<EthereumAddress>] => [allAccounts[index], opt])
|
||||
.filter(([, opt]) => opt.isSome)
|
||||
.map(([address, opt]) => [address, opt.unwrap()])
|
||||
});
|
||||
|
||||
// Filter the accounts that need attest. They are accounts that
|
||||
// - already preclaimed
|
||||
// - has a balance, either vested or normal
|
||||
useEffect((): void => {
|
||||
preclaims && api.queryMulti(
|
||||
preclaims.reduce((result: [QueryableStorageEntry<'promise'>, EthereumAddress][], [, ethAddr]) =>
|
||||
result.concat([
|
||||
[api.query.claims.claims, ethAddr],
|
||||
[api.query.claims.vesting, ethAddr]
|
||||
]),
|
||||
[]), (opts: Option<Codec>[]): void => {
|
||||
// filter the cases where either claims or vesting has a value
|
||||
mountedRef.current && setNeedsAttest(
|
||||
preclaims
|
||||
.filter((_, index) => opts[index * 2].isSome || opts[(index * 2) + 1].isSome)
|
||||
.map(([address]) => address)
|
||||
);
|
||||
}
|
||||
).catch(console.error);
|
||||
}, [api, allAccounts, mountedRef, preclaims]);
|
||||
|
||||
return needsAttest;
|
||||
}
|
||||
|
||||
export default createNamedHook('usePezkuwiPreclaims', usePezkuwiPreclaimsImpl);
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2017-2025 @pezkuwi/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { hexToU8a } from '@pezkuwi/util';
|
||||
|
||||
import { publicToAddr, recoverFromJSON } from './util.js';
|
||||
|
||||
describe('util', (): void => {
|
||||
it('converts a publicKey to address via publicToAddr', (): void => {
|
||||
expect(
|
||||
publicToAddr(
|
||||
hexToU8a(
|
||||
'0x836b35a026743e823a90a0ee3b91bf615c6a757e2b60b9e1dc1826fd0dd16106f7bc1e8179f665015f43c6c81f39062fc2086ed849625c06e04697698b21855e'
|
||||
)
|
||||
)
|
||||
).toEqual('0x0BED7ABd61247635c1973eB38474A2516eD1D884');
|
||||
});
|
||||
|
||||
it('converts to valid signature via recoverFromJSON', (): void => {
|
||||
expect(
|
||||
JSON.stringify(recoverFromJSON('{"address":"0x002309df96687e44280bb72c3818358faeeb699c","msg":"Pay KSMs to the Dicle account:88dc3417d5058ec4b4503e0c12ea1a0a89be200fe98922423d4334014fa6b0ee","sig":"0x55bd020bdbbdc02de34e915effc9b18a99002f4c29f64e22e8dcbb69e722ea6c28e1bb53b9484063fbbfd205e49dcc1f620929f520c9c4c3695150f05a28f52a01","version":"2"}'))
|
||||
).toEqual('{"error":null,"ethereumAddress":"0x002309DF96687e44280BB72c3818358FAEeB699c","signature":"0x55bd020bdbbdc02de34e915effc9b18a99002f4c29f64e22e8dcbb69e722ea6c28e1bb53b9484063fbbfd205e49dcc1f620929f520c9c4c3695150f05a28f52a01"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-claims authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { EcdsaSignature, EthereumAddress, StatementKind } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import secp256k1 from 'secp256k1/elliptic.js';
|
||||
|
||||
import { statics } from '@pezkuwi/react-api/statics';
|
||||
import { assert, hexToU8a, stringToU8a, u8aConcat, u8aToBuffer } from '@pezkuwi/util';
|
||||
import { keccakAsHex, keccakAsU8a } from '@pezkuwi/util-crypto';
|
||||
|
||||
interface RecoveredSignature {
|
||||
error: Error | null;
|
||||
ethereumAddress: EthereumAddress | null;
|
||||
signature: EcdsaSignature | null;
|
||||
}
|
||||
|
||||
interface SignatureParts {
|
||||
recovery: number;
|
||||
signature: Buffer;
|
||||
}
|
||||
|
||||
// converts an Ethereum address to a checksum representation
|
||||
export function addrToChecksum (_address: string): string {
|
||||
const address = _address.toLowerCase();
|
||||
const hash = keccakAsHex(address.substring(2)).substring(2);
|
||||
let result = '0x';
|
||||
|
||||
for (let n = 0; n < 40; n++) {
|
||||
result = `${result}${
|
||||
parseInt(hash[n], 16) > 7
|
||||
? address[n + 2].toUpperCase()
|
||||
: address[n + 2]
|
||||
}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// convert a give public key to an Ethereum address (the last 20 bytes of an _exapnded_ key keccack)
|
||||
export function publicToAddr (publicKey: Uint8Array): string {
|
||||
return addrToChecksum(`0x${keccakAsHex(publicKey).slice(-40)}`);
|
||||
}
|
||||
|
||||
// hash a message for use in signature recovery, adding the standard Ethereum header
|
||||
export function hashMessage (message: string): Buffer {
|
||||
const expanded = stringToU8a(`\x19Ethereum Signed Message:\n${message.length.toString()}${message}`);
|
||||
const hashed = keccakAsU8a(expanded);
|
||||
|
||||
return u8aToBuffer(hashed);
|
||||
}
|
||||
|
||||
// split is 65-byte signature into the r, s (combined) and recovery number (derived from v)
|
||||
export function sigToParts (_signature: string): SignatureParts {
|
||||
const signature = hexToU8a(_signature);
|
||||
|
||||
assert(signature.length === 65, `Invalid signature length, expected 65 found ${signature.length}`);
|
||||
|
||||
let v = signature[64];
|
||||
|
||||
if (v < 27) {
|
||||
v += 27;
|
||||
}
|
||||
|
||||
const recovery = v - 27;
|
||||
|
||||
assert(recovery === 0 || recovery === 1, 'Invalid signature v value');
|
||||
|
||||
return {
|
||||
recovery,
|
||||
signature: u8aToBuffer(signature.slice(0, 64))
|
||||
};
|
||||
}
|
||||
|
||||
// recover an address from a given message and a recover/signature combination
|
||||
export function recoverAddress (message: string, { recovery, signature }: SignatureParts): string {
|
||||
const msgHash = hashMessage(message);
|
||||
const senderPubKey = secp256k1.recover(msgHash, signature, recovery);
|
||||
|
||||
return publicToAddr(
|
||||
secp256k1.publicKeyConvert(senderPubKey, false).subarray(1)
|
||||
);
|
||||
}
|
||||
|
||||
// recover an address from a signature JSON (as supplied by e.g. MyCrypto)
|
||||
export function recoverFromJSON (signatureJson: string | null): RecoveredSignature {
|
||||
try {
|
||||
const { msg, sig } = JSON.parse(signatureJson || '{}') as Record<string, string>;
|
||||
|
||||
if (!msg || !sig) {
|
||||
throw new Error('Invalid signature object');
|
||||
}
|
||||
|
||||
const parts = sigToParts(sig);
|
||||
|
||||
return {
|
||||
error: null,
|
||||
ethereumAddress: statics.registry.createType('EthereumAddress', recoverAddress(msg, parts)),
|
||||
signature: statics.registry.createType('EcdsaSignature', u8aConcat(parts.signature, new Uint8Array([parts.recovery])))
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return {
|
||||
error: error as Error,
|
||||
ethereumAddress: null,
|
||||
signature: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface Statement {
|
||||
sentence: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function getPezkuwi (kind?: StatementKind | null): Statement | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = kind.isRegular
|
||||
? 'https://statement.pezkuwi.network/regular.html'
|
||||
: 'https://statement.pezkuwi.network/saft.html';
|
||||
const hash = kind.isRegular
|
||||
? 'Qmc1XYqT6S39WNp2UeiRUrZichUWUPpGEThDE6dAb3f6Ny'
|
||||
: 'QmXEkMahfhHJPzT3RjkXiZVFi77ZeVeuxtAjhojGRNYckz';
|
||||
|
||||
return {
|
||||
sentence: `I hereby agree to the terms of the statement whose SHA-256 multihash is ${hash}. (This may be found at the URL: ${url})`,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
export function getStatement (network: string, kind?: StatementKind | null): Statement | undefined {
|
||||
switch (network) {
|
||||
case 'Pezkuwi':
|
||||
case 'Pezkuwi CC1':
|
||||
return getPezkuwi(kind);
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user