Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { useCommunityFactory } from '@/hooks/useCommunityFactory';
import { useInvitation } from '@/hooks/useInvitation';
import { cn } from '@/lib/utils';
import { Decimal } from '@/libs/decimal';
import { ensureSdkInitializeContract } from '@/libs/initializeContractTyped';
import { SETTINGS } from '@/utils/constants';
import { normalizeSecretKey } from '@/utils/secretKey';

interface CollectInvitationLinkCardProps {
className?: string;
Expand Down Expand Up @@ -57,8 +59,8 @@ const CollectInvitationLinkCard = ({

/**
* Retrieves the invitation reward amount for a given invitation code.
* This function sets the loading state, initializes the AeSdk, retrieves the affiliation contract,
* and updates the invitation details if the invitation code exists.
* This function sets the loading state, initializes the AeSdk,
* retrieves the affiliation contract, and updates invitation details.
*/
const getInvitationRewardAmount = useCallback(async () => {
if (!invitationCode) return;
Expand All @@ -67,7 +69,8 @@ const CollectInvitationLinkCard = ({
setLoadingInvitation(true);

try {
const account = new MemoryAccount(invitationCode);
const normalizedInvitationKey = normalizeSecretKey(invitationCode);
const account = new MemoryAccount(normalizedInvitationKey);

const tempSdk = new AeSdk({
onCompiler: new CompilerHttp('https://v7.compiler.aepps.com'),
Expand Down Expand Up @@ -118,7 +121,8 @@ const CollectInvitationLinkCard = ({
setSuccessMessage(undefined);

try {
const account = new MemoryAccount(invitationCode);
const normalizedInvitationKey = normalizeSecretKey(invitationCode);
const account = new MemoryAccount(normalizedInvitationKey);

if (isRevoking) {
// Revoke invitation
Expand All @@ -129,11 +133,17 @@ const CollectInvitationLinkCard = ({
setSuccessMessage('Invitation reward has been revoked successfully.');
} else {
// Claim invitation
staticAeSdk.addAccount(account, { select: true });
const affiliationTreasury = await getAffiliationTreasury(staticAeSdk);
await affiliationTreasury.redeemInvitationCode(
invitationCode,
if (!staticAeSdk) throw new Error('SDK not available');

const claimSdk = ensureSdkInitializeContract(staticAeSdk as any) as typeof staticAeSdk;
await claimSdk.addAccount(account, { select: true });

const affiliationTreasury = await getAffiliationTreasury(claimSdk);
await affiliationTreasury.contract.redeem_invitation_code(
activeAccount,
{
onAccount: account,
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claimed invitation detail parsing may lose data

Medium Severity

The claim call changed from affiliationTreasury.redeemInvitationCode(invitationCode, activeAccount) (wrapper passing two values) to affiliationTreasury.contract.redeem_invitation_code(activeAccount, { onAccount: account }) (direct call with one contract argument). The transaction-parsing code in useInvitations.ts still reads tx.tx?.arguments?.[1]?.value to extract the claimer address, based on the old two-argument layout. With only one on-chain argument now, arguments[1] will be undefined, causing the code to fall back to a bare true instead of returning detailed ClaimedInfo (claimedBy, claimedAt, claimTxHash).

Additional Locations (1)
Fix in Cursor Fix in Web

);
setSuccessMessage('Invitation reward has been claimed successfully!');
}
Expand All @@ -146,6 +156,8 @@ const CollectInvitationLinkCard = ({
setErrorMessage(
'This account has already claimed an invitation reward.',
);
} else if (error?.message?.includes('Secret key')) {
setErrorMessage('This invite link uses an unsupported or corrupted secret key format.');
} else if (error?.message?.includes('ALREADY_REDEEMED')) {
setErrorMessage('This invitation has already been claimed.');
} else {
Expand Down
6 changes: 5 additions & 1 deletion src/features/trending/hooks/useInvitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Decimal } from '../../../libs/decimal';
import { useAccount } from '../../../hooks/useAccount';
import { useAeSdk } from '../../../hooks/useAeSdk';
import { getAffiliationTreasury } from '../../../libs/affiliation';
import { normalizeSecretKey } from '../../../utils/secretKey';
import { fetchJson } from '../../../utils/common';
import {
invitationListAtom,
Expand Down Expand Up @@ -65,7 +66,10 @@ export function useInvitations() {
const checkedInviteesRef = useRef<Set<string>>(new Set());

// Helper functions
const prepareInviteLink = useCallback((secretKey: string): string => `${window.location.protocol}//${window.location.host}#${INVITE_CODE_QUERY_KEY}=${secretKey}`, []);
const prepareInviteLink = useCallback(
(secretKey: string): string => `${window.location.protocol}//${window.location.host}#${INVITE_CODE_QUERY_KEY}=${normalizeSecretKey(secretKey)}`,
[],
);

const getInvitationRevokeStatus = useCallback((invitee: string): ITransaction | boolean => {
const revokeTx = transactionList.find((tx) => {
Expand Down
3 changes: 2 additions & 1 deletion src/features/trending/libs/createCommunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CreateCommunityOptions, Denomination, denominationTokenDecimals, estimateInitialBuyPriceAetto, toTokenDecimals,
} from 'bctsl-sdk';
import BigNumber from 'bignumber.js';
import { initializeContractTyped } from '@/libs/initializeContractTyped';

async function feePercentage(contract: Contract<ContractMethodsBase>): Promise<number> {
if (typeof contract.fee_percentage !== 'function') return undefined;
Expand All @@ -26,7 +27,7 @@ export async function createCommunity(
denomination?: Denomination,
communityFactoryAddress?: Encoded.ContractAddress,
): Promise<Encoded.TxHash> {
const contract = await aeSdk.initializeContract({
const contract = await initializeContractTyped<ContractMethodsBase>(aeSdk, {
address: communityFactoryAddress,
aci: COMMUNITY_FACTORY_CONTRACT_ACI,
});
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/useCommunityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

import { AppService } from '../api/generated';
import { activeFactorySchemaAtom } from '../atoms/factoryAtoms';
import { ensureSdkInitializeContract } from '../libs/initializeContractTyped';
import { ICommunityFactorySchema } from '../utils/types';
import { useAeSdk } from './useAeSdk';

Expand Down Expand Up @@ -56,7 +57,10 @@ export function useCommunityFactory() {
throw new Error('SDK not available');
}

return initCommunityFactory(targetSdk, newFactorySchema.address);
return initCommunityFactory(
ensureSdkInitializeContract(targetSdk as AeSdkAepp | AeSdk),
newFactorySchema.address,
);
}, [sdk, activeFactorySchema, loadFactorySchema]);

const getAffiliationTreasury = useCallback(async (
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MemoryAccount } from '@aeternity/aepp-sdk';
import type { Encoded } from '@aeternity/aepp-sdk';

import { Decimal } from '../libs/decimal';
import { normalizeSecretKey } from '../utils/secretKey';
import { useAccount } from './useAccount';
import { useCommunityFactory } from './useCommunityFactory';

Expand Down Expand Up @@ -44,7 +45,7 @@ function getActiveAccountInviteList(inviter: Encoded.AccountAddress): Invitation

function prepareInviteLink(secretKey: string): string {
// eslint-disable-next-line no-restricted-globals
return `${location.protocol}//${location.host}#${INVITE_CODE_QUERY_KEY}=${secretKey}`;
return `${location.protocol}//${location.host}#${INVITE_CODE_QUERY_KEY}=${normalizeSecretKey(secretKey)}`;
}

let initialized = false;
Expand Down
6 changes: 5 additions & 1 deletion src/libs/affiliation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppService } from '@/api/generated';
import type { AeSdk } from '@aeternity/aepp-sdk';
import { initCommunityFactory } from 'bctsl-sdk';
import { ensureSdkInitializeContract } from './initializeContractTyped';

/**
* Returns the active Community Factory address from the Trendminer backend schema.
Expand All @@ -18,7 +19,10 @@ export async function getFactoryAddress(): Promise<string> {
*/
export async function getAffiliationTreasury(sdk: AeSdk) {
const factoryAddress = await getFactoryAddress();
const factory = await initCommunityFactory(sdk as any, factoryAddress);
const factory = await initCommunityFactory(
ensureSdkInitializeContract(sdk as any),
factoryAddress,
);
return factory.affiliationTreasury();
}

Expand Down
43 changes: 32 additions & 11 deletions src/libs/initializeContractTyped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,43 @@ type InitializeContractOptions = {
omitUnknown?: boolean;
};

type InitializableSdk = {
getContext: () => object;
initializeContract?: (options: Record<string, unknown>) => ReturnType<typeof Contract.initialize>;
};

/**
* Adds back `sdk.initializeContract(...)` for SDK instances or wrappers that
* still reach libraries built against aepp-sdk < v14.
*/
export function ensureSdkInitializeContract<T extends InitializableSdk>(
sdk: T,
): T & Required<Pick<InitializableSdk, 'initializeContract'>> {
const compatibleSdk = sdk as T & Required<Pick<InitializableSdk, 'initializeContract'>>;

if (typeof compatibleSdk.initializeContract !== 'function') {
Object.assign(compatibleSdk, {
initializeContract: (options: Record<string, unknown>) => Contract.initialize({
...(sdk.getContext() as Record<string, unknown>),
...options,
} as Parameters<typeof Contract.initialize>[0]),
});
}

return compatibleSdk;
}

/**
* Initialize a typed contract using sdk.initializeContract when available,
* and fall back to Contract.initialize for sdk variants exposing only getContext().
* Initialize a typed contract via a compatibility-normalized
* `sdk.initializeContract(...)` implementation.
*/
export async function initializeContractTyped<M extends ContractMethodsBase>(
sdk: any,
options: InitializeContractOptions,
): Promise<InitializedContract & M> {
if (typeof sdk?.initializeContract === 'function') {
return sdk.initializeContract(options as Record<string, unknown>) as Promise<
InitializedContract & M
>;
}
const compatibleSdk = ensureSdkInitializeContract(sdk);

return Contract.initialize({
...sdk.getContext(),
...options,
}) as Promise<InitializedContract & M>;
return compatibleSdk.initializeContract(options as Record<string, unknown>) as Promise<
InitializedContract & M
>;
}
30 changes: 7 additions & 23 deletions src/services/payForProfileTx.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import {
AeSdk,
Encoded,
encode,
Encoding,
MemoryAccount,
Node,
Tag,
unpackTx,
} from '@aeternity/aepp-sdk';
import { normalizeSecretKey } from '@/utils/secretKey';

const PROFILE_FUNCTIONS = new Set([
'set_profile',
Expand Down Expand Up @@ -35,31 +34,16 @@ const getPayerSecret = () => (
|| '') as string
).trim();

const hexToBytes = (hex: string): Uint8Array => {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) {
throw new Error('VITE_PAY_FOR_TX_ACCOUNT_PRIVATE_KEY has invalid hex format');
}
const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
};

const normalizePayerSecret = (rawSecret: string): `sk_${string}` => {
const secret = rawSecret.trim();
if (secret.startsWith('sk_')) return secret as `sk_${string}`;

const secretBytes = hexToBytes(secret);
if (secretBytes.length < 32) {
try {
return normalizeSecretKey(rawSecret);
} catch (error: any) {
throw new Error(
'VITE_PAY_FOR_TX_ACCOUNT_PRIVATE_KEY hex value must contain at least 32 bytes',
`VITE_PAY_FOR_TX_ACCOUNT_PRIVATE_KEY is invalid: ${
error?.message || 'unknown format'
}`,
);
}
// Legacy secrets can contain the full keypair payload.
// sdk v14 expects sk_-encoded 32-byte secret.
return encode(secretBytes.subarray(0, 32), Encoding.AccountSecretKey) as `sk_${string}`;
};

const getPayerSdk = (): AeSdk => {
Expand Down
37 changes: 37 additions & 0 deletions src/utils/secretKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { encode, Encoding } from '@aeternity/aepp-sdk';

const hexToBytes = (hex: string): Uint8Array => {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) {
throw new Error('Secret key has invalid hex format');
}

const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
};

/**
* aepp-sdk v14 expects `sk_` encoded 32-byte secrets.
* Older persisted values can be raw hex or a longer keypair payload.
*/
export const normalizeSecretKey = (rawSecret: string): `sk_${string}` => {
const secret = rawSecret.trim();
if (!secret) {
throw new Error('Secret key is missing');
}

if (secret.startsWith('sk_')) return secret as `sk_${string}`;

const secretBytes = hexToBytes(secret);
if (secretBytes.length < 32) {
throw new Error('Secret key must contain at least 32 bytes');
}

return encode(
secretBytes.subarray(0, 32),
Encoding.AccountSecretKey,
) as `sk_${string}`;
};
Loading