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:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
+134
View File
@@ -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);
+144
View File
@@ -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);
+88
View File
@@ -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);
+57
View File
@@ -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);
+326
View File
@@ -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
View File
@@ -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;
}
+8
View File
@@ -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');
}
+14
View File
@@ -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);
+26
View File
@@ -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"}');
});
});
+144
View File
@@ -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;
}
}