From 7c57880dc7d6236d8b3c2ef21a0618110117b32d Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 1 Apr 2026 09:00:52 +0400 Subject: [PATCH 1/2] feat: add a chain name sponsoring mechanism --- .env.example | 5 + .github/workflows/deploy_develop.yaml | 2 + .github/workflows/deploy_main.yaml | 1 + .github/workflows/deploy_staging.yaml | 1 + .github/workflows/ssh_deploy.yaml | 6 + docs/profile-chain-name-manual-migration.sql | 61 ++ .../profile-chain-name.controller.spec.ts | 59 ++ .../profile-chain-name.controller.ts | 51 ++ .../dto/ae-account-address.validator.ts | 19 + .../dto/create-chain-name-challenge.dto.ts | 13 + src/profile/dto/request-chain-name.dto.ts | 48 ++ .../profile-chain-name-challenge.entity.ts | 48 ++ .../profile-chain-name-claim.entity.ts | 98 +++ src/profile/profile.constants.ts | 23 + src/profile/profile.module.ts | 11 +- .../profile-chain-name.service.spec.ts | 666 ++++++++++++++++ .../services/profile-chain-name.service.ts | 742 ++++++++++++++++++ .../services/profile-signature.util.ts | 26 + .../services/profile-x-invite.service.spec.ts | 3 +- .../services/profile-x-invite.service.ts | 33 +- 20 files changed, 1881 insertions(+), 35 deletions(-) create mode 100644 docs/profile-chain-name-manual-migration.sql create mode 100644 src/profile/controllers/profile-chain-name.controller.spec.ts create mode 100644 src/profile/controllers/profile-chain-name.controller.ts create mode 100644 src/profile/dto/ae-account-address.validator.ts create mode 100644 src/profile/dto/create-chain-name-challenge.dto.ts create mode 100644 src/profile/dto/request-chain-name.dto.ts create mode 100644 src/profile/entities/profile-chain-name-challenge.entity.ts create mode 100644 src/profile/entities/profile-chain-name-claim.entity.ts create mode 100644 src/profile/services/profile-chain-name.service.spec.ts create mode 100644 src/profile/services/profile-chain-name.service.ts create mode 100644 src/profile/services/profile-signature.util.ts diff --git a/.env.example b/.env.example index a2a86da6..47beaf2e 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,11 @@ PROFILE_X_INVITE_LINK_BASE_URL= PROFILE_X_INVITE_CHALLENGE_TTL_SECONDS=300 PROFILE_X_INVITE_PENDING_TIMEOUT_SECONDS=300 +# AENS sponsored chain name claiming +PROFILE_CHAIN_NAME_PRIVATE_KEY= +PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS=300 +PROFILE_CHAIN_NAME_MAX_RETRIES=10 + # X (Twitter) OAuth 2.0 – required for /api/profile/x/attestation with code flow (PKCE) X_CLIENT_ID= X_CLIENT_SECRET= diff --git a/.github/workflows/deploy_develop.yaml b/.github/workflows/deploy_develop.yaml index 9bf94772..d463886e 100644 --- a/.github/workflows/deploy_develop.yaml +++ b/.github/workflows/deploy_develop.yaml @@ -39,6 +39,7 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.DEV_PROFILE_REGISTRY_CONTRACT_ADDRESS }} PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.DEV_PROFILE_ATTESTATION_SIGNER_ADDRESS }} PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.DEV_PROFILE_ATTESTATION_PRIVATE_KEY }} + PROFILE_CHAIN_NAME_PRIVATE_KEY: ${{ secrets.DEV_PROFILE_CHAIN_NAME_PRIVATE_KEY }} GIPHY_API_KEY: ${{ secrets.DEV_GIPHY_API_KEY }} deploy_testnet: name: api-testnet @@ -64,4 +65,5 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.TESTNET_PROFILE_REGISTRY_CONTRACT_ADDRESS }} PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.TESTNET_PROFILE_ATTESTATION_SIGNER_ADDRESS }} PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.TESTNET_PROFILE_ATTESTATION_PRIVATE_KEY }} + PROFILE_CHAIN_NAME_PRIVATE_KEY: ${{ secrets.TESTNET_PROFILE_CHAIN_NAME_PRIVATE_KEY }} GIPHY_API_KEY: ${{ secrets.DEV_GIPHY_API_KEY }} diff --git a/.github/workflows/deploy_main.yaml b/.github/workflows/deploy_main.yaml index 370d1173..1f59eb49 100644 --- a/.github/workflows/deploy_main.yaml +++ b/.github/workflows/deploy_main.yaml @@ -39,5 +39,6 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.PROD_PROFILE_REGISTRY_CONTRACT_ADDRESS }} PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.PROD_PROFILE_ATTESTATION_SIGNER_ADDRESS }} PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.PROD_PROFILE_ATTESTATION_PRIVATE_KEY }} + PROFILE_CHAIN_NAME_PRIVATE_KEY: ${{ secrets.PROD_PROFILE_CHAIN_NAME_PRIVATE_KEY }} GIPHY_API_KEY: ${{ secrets.PROD_GIPHY_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/deploy_staging.yaml b/.github/workflows/deploy_staging.yaml index 9b364f5c..234c8538 100644 --- a/.github/workflows/deploy_staging.yaml +++ b/.github/workflows/deploy_staging.yaml @@ -39,4 +39,5 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.STAG_PROFILE_REGISTRY_CONTRACT_ADDRESS }} PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.STAG_PROFILE_ATTESTATION_SIGNER_ADDRESS }} PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.STAG_PROFILE_ATTESTATION_PRIVATE_KEY }} + PROFILE_CHAIN_NAME_PRIVATE_KEY: ${{ secrets.STAG_PROFILE_CHAIN_NAME_PRIVATE_KEY }} GIPHY_API_KEY: ${{ secrets.STAG_GIPHY_API_KEY }} diff --git a/.github/workflows/ssh_deploy.yaml b/.github/workflows/ssh_deploy.yaml index 9dfe16e6..25a6eb4a 100644 --- a/.github/workflows/ssh_deploy.yaml +++ b/.github/workflows/ssh_deploy.yaml @@ -68,6 +68,9 @@ on: PROFILE_ATTESTATION_PRIVATE_KEY: description: "Profile attestation signer private key" required: false + PROFILE_CHAIN_NAME_PRIVATE_KEY: + description: "Private key used to sponsor chain name claims" + required: false jobs: deploy: name: Deploy via ssh @@ -90,6 +93,7 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS: "${{ secrets.PROFILE_REGISTRY_CONTRACT_ADDRESS }}" PROFILE_ATTESTATION_SIGNER_ADDRESS: "${{ secrets.PROFILE_ATTESTATION_SIGNER_ADDRESS }}" PROFILE_ATTESTATION_PRIVATE_KEY: "${{ secrets.PROFILE_ATTESTATION_PRIVATE_KEY }}" + PROFILE_CHAIN_NAME_PRIVATE_KEY: "${{ secrets.PROFILE_CHAIN_NAME_PRIVATE_KEY }}" GIPHY_API_KEY: "${{ secrets.GIPHY_API_KEY }}" with: host: "${{ secrets.DEPLOY_HOST }}" @@ -108,6 +112,7 @@ jobs: PROFILE_REGISTRY_CONTRACT_ADDRESS, PROFILE_ATTESTATION_SIGNER_ADDRESS, PROFILE_ATTESTATION_PRIVATE_KEY, + PROFILE_CHAIN_NAME_PRIVATE_KEY, GIPHY_API_KEY, SHA script: | @@ -133,6 +138,7 @@ jobs: -e PROFILE_REGISTRY_CONTRACT_ADDRESS \ -e PROFILE_ATTESTATION_SIGNER_ADDRESS \ -e PROFILE_ATTESTATION_PRIVATE_KEY \ + -e PROFILE_CHAIN_NAME_PRIVATE_KEY \ -e GIPHY_API_KEY \ -e NODE_ENV=production \ -e REDIS_HOST=${{ inputs.CONTAINER_NAME }}-redis \ diff --git a/docs/profile-chain-name-manual-migration.sql b/docs/profile-chain-name-manual-migration.sql new file mode 100644 index 00000000..a183b105 --- /dev/null +++ b/docs/profile-chain-name-manual-migration.sql @@ -0,0 +1,61 @@ +BEGIN; + +DO $$ +BEGIN + CREATE TYPE profile_chain_name_claims_status_enum AS ENUM ( + 'pending', + 'preclaimed', + 'claimed', + 'completed', + 'failed' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +CREATE TABLE IF NOT EXISTS profile_chain_name_challenges ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + nonce character varying NOT NULL, + address character varying NOT NULL, + expires_at timestamp NOT NULL, + consumed_at timestamp NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +); + +CREATE UNIQUE INDEX IF NOT EXISTS profile_chain_name_challenges_nonce_uq + ON profile_chain_name_challenges (nonce); +CREATE INDEX IF NOT EXISTS profile_chain_name_challenges_address_idx + ON profile_chain_name_challenges (address); + +CREATE TABLE IF NOT EXISTS profile_chain_name_claims ( + address character varying PRIMARY KEY, + name character varying NOT NULL, + status profile_chain_name_claims_status_enum NOT NULL DEFAULT 'pending', + salt text NULL, + preclaim_height integer NULL, + preclaim_tx_hash character varying NULL, + claim_tx_hash character varying NULL, + update_tx_hash character varying NULL, + transfer_tx_hash character varying NULL, + error text NULL, + retry_count integer NOT NULL DEFAULT 0, + next_retry_at timestamp NULL, + last_attempt_at timestamp NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +); + +CREATE UNIQUE INDEX IF NOT EXISTS profile_chain_name_claims_name_uq + ON profile_chain_name_claims (name); +CREATE INDEX IF NOT EXISTS profile_chain_name_claims_preclaim_tx_hash_idx + ON profile_chain_name_claims (preclaim_tx_hash); +CREATE INDEX IF NOT EXISTS profile_chain_name_claims_claim_tx_hash_idx + ON profile_chain_name_claims (claim_tx_hash); +CREATE INDEX IF NOT EXISTS profile_chain_name_claims_update_tx_hash_idx + ON profile_chain_name_claims (update_tx_hash); +CREATE INDEX IF NOT EXISTS profile_chain_name_claims_transfer_tx_hash_idx + ON profile_chain_name_claims (transfer_tx_hash); + +COMMIT; diff --git a/src/profile/controllers/profile-chain-name.controller.spec.ts b/src/profile/controllers/profile-chain-name.controller.spec.ts new file mode 100644 index 00000000..16e89a6f --- /dev/null +++ b/src/profile/controllers/profile-chain-name.controller.spec.ts @@ -0,0 +1,59 @@ +import { ProfileChainNameController } from './profile-chain-name.controller'; + +describe('ProfileChainNameController', () => { + const getController = (overrides?: { profileChainNameService?: any }) => { + const profileChainNameService = + overrides?.profileChainNameService || ({} as any); + const controller = new ProfileChainNameController(profileChainNameService); + return { controller, profileChainNameService }; + }; + + it('creates a chain name challenge', async () => { + const profileChainNameService = { + createChallenge: jest.fn().mockResolvedValue({ nonce: 'n' }), + } as any; + const { controller } = getController({ profileChainNameService }); + + await controller.createChallenge({ + address: 'ak_1', + } as any); + + expect(profileChainNameService.createChallenge).toHaveBeenCalledWith( + 'ak_1', + ); + }); + + it('verifies challenge proof before starting a claim', async () => { + const profileChainNameService = { + requestChainName: jest.fn().mockResolvedValue({ status: 'ok' }), + } as any; + const { controller } = getController({ profileChainNameService }); + + await controller.requestChainName({ + address: 'ak_1', + name: 'myuniquename123', + challenge_nonce: 'a'.repeat(24), + challenge_expires_at: '123', + signature_hex: 'b'.repeat(128), + } as any); + + expect(profileChainNameService.requestChainName).toHaveBeenCalledWith({ + address: 'ak_1', + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: 123, + signatureHex: 'b'.repeat(128), + }); + }); + + it('gets claim status by address', async () => { + const profileChainNameService = { + getClaimStatus: jest.fn().mockResolvedValue({ status: 'pending' }), + } as any; + const { controller } = getController({ profileChainNameService }); + + await controller.getChainNameClaimStatus('ak_1'); + + expect(profileChainNameService.getClaimStatus).toHaveBeenCalledWith('ak_1'); + }); +}); diff --git a/src/profile/controllers/profile-chain-name.controller.ts b/src/profile/controllers/profile-chain-name.controller.ts new file mode 100644 index 00000000..a95120d7 --- /dev/null +++ b/src/profile/controllers/profile-chain-name.controller.ts @@ -0,0 +1,51 @@ +import { RateLimitGuard } from '@/api-core/guards/rate-limit.guard'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateChainNameChallengeDto } from '../dto/create-chain-name-challenge.dto'; +import { RequestChainNameDto } from '../dto/request-chain-name.dto'; +import { ProfileChainNameService } from '../services/profile-chain-name.service'; + +@Controller('profile') +@ApiTags('ProfileChainName') +export class ProfileChainNameController { + constructor( + private readonly profileChainNameService: ProfileChainNameService, + ) {} + + @Post('chain-name/challenge') + @UseGuards(RateLimitGuard) + @ApiOperation({ + operationId: 'createChainNameChallenge', + summary: + 'Create a wallet-signing challenge for sponsored chain name claims', + }) + async createChallenge(@Body() body: CreateChainNameChallengeDto) { + return this.profileChainNameService.createChallenge(body.address); + } + + @Post('chain-name/claim') + @UseGuards(RateLimitGuard) + @ApiOperation({ + operationId: 'requestChainName', + summary: + 'Verify wallet ownership and request a sponsored AENS chain name registration.', + }) + async requestChainName(@Body() body: RequestChainNameDto) { + return this.profileChainNameService.requestChainName({ + address: body.address, + name: body.name, + challengeNonce: body.challenge_nonce, + challengeExpiresAt: Number(body.challenge_expires_at), + signatureHex: body.signature_hex, + }); + } + + @Get(':address/chain-name-claim') + @ApiOperation({ + operationId: 'getChainNameClaimStatus', + summary: 'Get the status of a sponsored chain name claim for an address', + }) + async getChainNameClaimStatus(@Param('address') address: string) { + return this.profileChainNameService.getClaimStatus(address); + } +} diff --git a/src/profile/dto/ae-account-address.validator.ts b/src/profile/dto/ae-account-address.validator.ts new file mode 100644 index 00000000..228b8b27 --- /dev/null +++ b/src/profile/dto/ae-account-address.validator.ts @@ -0,0 +1,19 @@ +import { Encoding, isEncoded } from '@aeternity/aepp-sdk'; +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isAeAccountAddress', async: false }) +export class AeAccountAddressConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return ( + typeof value === 'string' && isEncoded(value, Encoding.AccountAddress) + ); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a valid account address`; + } +} diff --git a/src/profile/dto/create-chain-name-challenge.dto.ts b/src/profile/dto/create-chain-name-challenge.dto.ts new file mode 100644 index 00000000..c133fd56 --- /dev/null +++ b/src/profile/dto/create-chain-name-challenge.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Validate } from 'class-validator'; +import { AeAccountAddressConstraint } from './ae-account-address.validator'; + +export class CreateChainNameChallengeDto { + @ApiProperty({ + description: 'Account address (ak_...) that will own the claimed name', + example: 'ak_2519mBsgjJEVEFoRgno1ryDsn3BEaCZGRbXPEjThWYLX9MTpmk', + }) + @IsString() + @Validate(AeAccountAddressConstraint) + address: string; +} diff --git a/src/profile/dto/request-chain-name.dto.ts b/src/profile/dto/request-chain-name.dto.ts new file mode 100644 index 00000000..88c55f58 --- /dev/null +++ b/src/profile/dto/request-chain-name.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MinLength, Validate } from 'class-validator'; +import { AeAccountAddressConstraint } from './ae-account-address.validator'; + +export class RequestChainNameDto { + @ApiProperty({ + description: 'Account address (ak_...)', + example: 'ak_2519mBsgjJEVEFoRgno1ryDsn3BEaCZGRbXPEjThWYLX9MTpmk', + }) + @IsString() + @Validate(AeAccountAddressConstraint) + address: string; + + @ApiProperty({ + description: + 'Desired chain name without the .chain suffix. Must be longer than 12 characters.', + example: 'myuniquename123', + }) + @IsString() + @MinLength(13) + @Matches(/^[a-z0-9]+$/, { + message: 'name must contain only lowercase letters and digits', + }) + name: string; + + @ApiProperty({ + description: 'Challenge nonce returned by the challenge endpoint', + example: 'a7f3d58f7fba7acfb35cb2097d364f0c1d6473a9126a4d6d', + }) + @IsString() + challenge_nonce: string; + + @ApiProperty({ + description: + 'Challenge expiry timestamp returned by the challenge endpoint', + example: '1711974659000', + }) + @IsString() + challenge_expires_at: string; + + @ApiProperty({ + description: + 'Wallet signature for the returned challenge message, as hex or sg_ string', + example: 'f'.repeat(128), + }) + @IsString() + signature_hex: string; +} diff --git a/src/profile/entities/profile-chain-name-challenge.entity.ts b/src/profile/entities/profile-chain-name-challenge.entity.ts new file mode 100644 index 00000000..1cb1005e --- /dev/null +++ b/src/profile/entities/profile-chain-name-challenge.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ + name: 'profile_chain_name_challenges', +}) +export class ProfileChainNameChallenge { + @PrimaryGeneratedColumn() + id: number; + + @Index({ unique: true }) + @Column() + nonce: string; + + @Index() + @Column() + address: string; + + @Column({ + type: 'timestamp', + }) + expires_at: Date; + + @Column({ + type: 'timestamp', + nullable: true, + }) + consumed_at: Date | null; + + @CreateDateColumn({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP(6)', + }) + created_at: Date; + + @UpdateDateColumn({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP(6)', + onUpdate: 'CURRENT_TIMESTAMP(6)', + }) + updated_at: Date; +} diff --git a/src/profile/entities/profile-chain-name-claim.entity.ts b/src/profile/entities/profile-chain-name-claim.entity.ts new file mode 100644 index 00000000..60e0aa14 --- /dev/null +++ b/src/profile/entities/profile-chain-name-claim.entity.ts @@ -0,0 +1,98 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type ChainNameClaimStatus = + | 'pending' + | 'preclaimed' + | 'claimed' + | 'completed' + | 'failed'; + +@Entity({ + name: 'profile_chain_name_claims', +}) +export class ProfileChainNameClaim { + @PrimaryColumn() + address: string; + + @Index({ unique: true }) + @Column() + name: string; + + @Column({ + enum: ['pending', 'preclaimed', 'claimed', 'completed', 'failed'], + default: 'pending', + }) + status: ChainNameClaimStatus; + + @Column({ + type: 'text', + nullable: true, + }) + salt: string | null; + + @Column({ + type: 'int', + nullable: true, + }) + preclaim_height: number | null; + + @Index() + @Column({ nullable: true }) + preclaim_tx_hash: string | null; + + @Index() + @Column({ nullable: true }) + claim_tx_hash: string | null; + + @Index() + @Column({ nullable: true }) + update_tx_hash: string | null; + + @Index() + @Column({ nullable: true }) + transfer_tx_hash: string | null; + + @Column({ + type: 'text', + nullable: true, + }) + error: string | null; + + @Column({ + type: 'int', + default: 0, + }) + retry_count: number; + + @Column({ + type: 'timestamp', + nullable: true, + }) + next_retry_at: Date | null; + + @Column({ + type: 'timestamp', + nullable: true, + }) + last_attempt_at: Date | null; + + @CreateDateColumn({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP(6)', + }) + created_at: Date; + + @UpdateDateColumn({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP(6)', + onUpdate: 'CURRENT_TIMESTAMP(6)', + }) + updated_at: Date; +} diff --git a/src/profile/profile.constants.ts b/src/profile/profile.constants.ts index 9def46fd..d17bbde7 100644 --- a/src/profile/profile.constants.ts +++ b/src/profile/profile.constants.ts @@ -122,6 +122,29 @@ export const PROFILE_X_INVITE_PENDING_TIMEOUT_SECONDS = parseInt( 10, ); +export const PROFILE_CHAIN_NAME_PRIVATE_KEY = + process.env.PROFILE_CHAIN_NAME_PRIVATE_KEY || ''; + +export const PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS = parseInt( + process.env.PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS || '300', + 10, +); + +export const PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS = parseInt( + process.env.PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS || '30', + 10, +); + +export const PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS = parseInt( + process.env.PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS || '3600', + 10, +); + +export const PROFILE_CHAIN_NAME_MAX_RETRIES = parseInt( + process.env.PROFILE_CHAIN_NAME_MAX_RETRIES || '10', + 10, +); + export const PROFILE_MUTATION_FUNCTIONS = [ 'set_profile', 'set_profile_full', diff --git a/src/profile/profile.module.ts b/src/profile/profile.module.ts index 9b0e35d4..28e8ea6d 100644 --- a/src/profile/profile.module.ts +++ b/src/profile/profile.module.ts @@ -12,6 +12,7 @@ import { ProfileXInviteCredit } from './entities/profile-x-invite-credit.entity' import { ProfileXInviteMilestoneReward } from './entities/profile-x-invite-milestone-reward.entity'; import { ProfileXPostingReward } from './entities/profile-x-posting-reward.entity'; import { ProfileXVerificationReward } from './entities/profile-x-verification-reward.entity'; +import { ProfileChainNameController } from './controllers/profile-chain-name.controller'; import { ProfileAttestationService } from './services/profile-attestation.service'; import { ProfileContractService } from './services/profile-contract.service'; import { ProfileIndexerService } from './services/profile-indexer.service'; @@ -22,6 +23,9 @@ import { ProfileXApiClientService } from './services/profile-x-api-client.servic import { ProfileXInviteService } from './services/profile-x-invite.service'; import { ProfileXPostingRewardService } from './services/profile-x-posting-reward.service'; import { ProfileXVerificationRewardService } from './services/profile-x-verification-reward.service'; +import { ProfileChainNameChallenge } from './entities/profile-chain-name-challenge.entity'; +import { ProfileChainNameClaim } from './entities/profile-chain-name-claim.entity'; +import { ProfileChainNameService } from './services/profile-chain-name.service'; @Module({ imports: [ @@ -36,6 +40,8 @@ import { ProfileXVerificationRewardService } from './services/profile-x-verifica ProfileXInvite, ProfileXInviteCredit, ProfileXInviteMilestoneReward, + ProfileChainNameChallenge, + ProfileChainNameClaim, Account, Invitation, ]), @@ -51,10 +57,9 @@ import { ProfileXVerificationRewardService } from './services/profile-x-verifica ProfileXInviteService, ProfileXPostingRewardService, ProfileXVerificationRewardService, + ProfileChainNameService, ], - // Keep internal profile services available to other modules while disabling - // all public `/profile` HTTP endpoints. - controllers: [], + controllers: [ProfileChainNameController], exports: [TypeOrmModule, ProfileReadService, ProfileContractService], }) export class ProfileModule {} diff --git a/src/profile/services/profile-chain-name.service.spec.ts b/src/profile/services/profile-chain-name.service.spec.ts new file mode 100644 index 00000000..0d6df495 --- /dev/null +++ b/src/profile/services/profile-chain-name.service.spec.ts @@ -0,0 +1,666 @@ +jest.mock('../profile.constants', () => ({ + PROFILE_CHAIN_NAME_PRIVATE_KEY: + '1111111111111111111111111111111111111111111111111111111111111111', + PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS: 300, + PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS: 30, + PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS: 3600, + PROFILE_CHAIN_NAME_MAX_RETRIES: 10, +})); + +jest.mock('@aeternity/aepp-sdk', () => { + const actual = jest.requireActual('@aeternity/aepp-sdk'); + return { + ...actual, + buildTxAsync: jest.fn(), + sendTransaction: jest.fn(), + verifyMessageSignature: jest.fn().mockReturnValue(true), + decode: jest.fn(), + isEncoded: jest.fn().mockReturnValue(true), + }; +}); + +import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; +import { + buildTxAsync, + sendTransaction, + Tag, + verifyMessageSignature, +} from '@aeternity/aepp-sdk'; +import { ProfileChainNameService } from './profile-chain-name.service'; +import { ProfileChainNameClaim } from '../entities/profile-chain-name-claim.entity'; +import { ProfileChainNameChallenge } from '../entities/profile-chain-name-challenge.entity'; + +describe('ProfileChainNameService', () => { + const validAddress = 'ak_2A9A8vXrX3tQzN5xW1TfFjBgfDkJtN2gQq7mB7cDgY7xT2R9s'; + + const getService = () => { + const claimRepository = { + findOne: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + save: jest.fn().mockImplementation(async (value) => value), + create: jest.fn().mockImplementation((value) => value), + } as any; + const challengeRepository = { + save: jest.fn().mockImplementation(async (value) => value), + create: jest.fn().mockImplementation((value) => value), + } as any; + const lockedClaimRepository = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }), + save: jest.fn().mockImplementation(async (value) => value), + } as any; + const lockedChallengeRepository = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }), + save: jest.fn().mockImplementation(async (value) => value), + } as any; + const manager = { + getRepository: jest.fn().mockImplementation((entity) => { + if (entity === ProfileChainNameChallenge) + return lockedChallengeRepository; + if (entity === ProfileChainNameClaim) return lockedClaimRepository; + return null; + }), + }; + const dataSource = { + transaction: jest.fn().mockImplementation(async (cb) => cb(manager)), + } as any; + const aeSdkService = { + sdk: { + getContext: jest.fn().mockReturnValue({ onNode: {} }), + getHeight: jest.fn().mockResolvedValue(100), + getBalance: jest.fn().mockResolvedValue('100000000000000000000'), + }, + } as any; + const profileSpendQueueService = { + enqueueSpend: jest.fn().mockImplementation(async (_key, work) => work()), + getRewardAccount: jest.fn().mockReturnValue({ address: validAddress }), + } as any; + + const service = new ProfileChainNameService( + claimRepository, + challengeRepository, + dataSource, + aeSdkService, + profileSpendQueueService, + ); + + return { + service, + claimRepository, + challengeRepository, + lockedClaimRepository, + lockedChallengeRepository, + dataSource, + aeSdkService, + profileSpendQueueService, + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('creates a wallet challenge with a signed message payload', async () => { + const { service, challengeRepository } = getService(); + + const result = await service.createChallenge(validAddress); + + expect(result.nonce).toBeTruthy(); + expect(result.message).toContain( + `profile_chain_name_claim:${validAddress}:`, + ); + expect(challengeRepository.save).toHaveBeenCalledTimes(1); + }); + + it('treats the same in-flight name request as idempotent', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + const fundsSpy = jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockRejectedValue(new ServiceUnavailableException('no funds')); + const processSpy = jest + .spyOn(service as any, 'processClaimWithGuard') + .mockResolvedValue(undefined); + claimRepository.findOne.mockResolvedValueOnce({ + address: validAddress, + name: 'myuniquename123.chain', + status: 'pending', + }); + + const result = await service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }); + + expect(result.status).toBe('pending'); + expect(claimRepository.save).not.toHaveBeenCalled(); + expect(fundsSpy).not.toHaveBeenCalled(); + expect(processSpy).toHaveBeenCalledWith(validAddress); + }); + + it('does not consume the challenge when sponsor funds are insufficient', async () => { + const { service } = getService(); + const verifySpy = jest.spyOn(service as any, 'verifyAndConsumeChallenge'); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockRejectedValue( + new ServiceUnavailableException( + 'Chain name claiming is temporarily unavailable due to insufficient sponsor funds', + ), + ); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow(ServiceUnavailableException); + + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it('rejects a second completed sponsored name for the same address', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + claimRepository.findOne.mockResolvedValueOnce({ + address: validAddress, + name: 'claimedname123.chain', + status: 'completed', + }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow(ConflictException); + }); + + it('rejects a different in-progress name for the same address', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + claimRepository.findOne.mockResolvedValueOnce({ + address: validAddress, + name: 'anothername123.chain', + status: 'preclaimed', + }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('Address already has an in-progress chain name claim'); + }); + + it('rejects a name already being actively claimed by another address', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + claimRepository.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce({ + address: 'ak_other', + name: 'myuniquename123.chain', + status: 'preclaimed', + }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('This name is already being claimed by another address'); + }); + + it('allows reusing a name from another address when its old claim failed', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + jest.spyOn(service as any, 'getNameStateIfPresent').mockResolvedValue(null); + jest + .spyOn(service as any, 'processClaimWithGuard') + .mockResolvedValue(undefined); + claimRepository.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce({ + address: 'ak_other', + name: 'myuniquename123.chain', + status: 'failed', + }); + + const result = await service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }); + + expect(result.status).toBe('ok'); + expect(claimRepository.delete).toHaveBeenCalledWith({ + address: 'ak_other', + }); + expect(claimRepository.save).toHaveBeenCalled(); + }); + + it('rejects a name already taken on-chain by someone else', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'getNameStateIfPresent') + .mockResolvedValue({ owner: 'ak_someone_else' }); + claimRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('This name is already taken on-chain'); + }); + + it('converts unique constraint races into a conflict error', async () => { + const { service, claimRepository } = getService(); + jest + .spyOn(service as any, 'verifyAndConsumeChallenge') + .mockResolvedValue(undefined); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + jest.spyOn(service as any, 'getNameStateIfPresent').mockResolvedValue(null); + claimRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + claimRepository.save.mockRejectedValueOnce({ + driverError: { code: '23505' }, + }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('This name is already being claimed by another address'); + }); + + it('builds a name claim tx using the persisted salt value', async () => { + const { service } = getService(); + (buildTxAsync as jest.Mock).mockResolvedValue('tx_unsigned'); + (sendTransaction as jest.Mock).mockResolvedValue({ hash: 'th_claim_1' }); + + await (service as any).submitClaimTransaction( + 'myuniquename123.chain', + '123456789', + ); + + expect(buildTxAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tag: Tag.NameClaimTx, + name: 'myuniquename123.chain', + nameSalt: 123456789, + }), + ); + expect(sendTransaction).toHaveBeenCalledWith( + 'tx_unsigned', + expect.objectContaining({ + onAccount: expect.any(Object), + }), + ); + }); + + it('rejects an invalid persisted salt before building the claim tx', async () => { + const { service } = getService(); + + await expect( + (service as any).submitClaimTransaction( + 'myuniquename123.chain', + 'not-a-number', + ), + ).rejects.toThrow('Invalid persisted name salt'); + + expect(buildTxAsync).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it('consumes a matching signature challenge', async () => { + const { service, dataSource } = getService(); + const challenge = { + nonce: 'a'.repeat(24), + address: validAddress, + expires_at: new Date(Date.now() + 10_000), + consumed_at: null, + }; + const challengeRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(challenge), + }), + save: jest.fn().mockImplementation(async (value) => value), + }; + dataSource.transaction.mockImplementation(async (cb) => + cb({ + getRepository: jest.fn().mockReturnValue(challengeRepo), + }), + ); + + await (service as any).verifyAndConsumeChallenge({ + address: validAddress, + nonce: challenge.nonce, + expiresAt: challenge.expires_at.getTime(), + signatureHex: 'b'.repeat(128), + }); + + expect(verifyMessageSignature).toHaveBeenCalled(); + expect(challengeRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + consumed_at: expect.any(Date), + }), + ); + }); + + it('rejects challenge verification when proof fields are missing', async () => { + const { service } = getService(); + + await expect( + (service as any).verifyAndConsumeChallenge({ + address: validAddress, + nonce: '', + expiresAt: 0, + signatureHex: '', + }), + ).rejects.toThrow('Challenge proof is required'); + }); + + it('rejects challenge verification when challenge is not found', async () => { + const { service, dataSource } = getService(); + const challengeRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }), + }; + dataSource.transaction.mockImplementation(async (cb) => + cb({ + getRepository: jest.fn().mockReturnValue(challengeRepo), + }), + ); + + await expect( + (service as any).verifyAndConsumeChallenge({ + address: validAddress, + nonce: 'a'.repeat(24), + expiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('Challenge not found'); + }); + + it('rejects challenge verification on expiry mismatch', async () => { + const { service, dataSource } = getService(); + const challenge = { + nonce: 'a'.repeat(24), + address: validAddress, + expires_at: new Date(Date.now() + 20_000), + consumed_at: null, + }; + const challengeRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(challenge), + }), + }; + dataSource.transaction.mockImplementation(async (cb) => + cb({ + getRepository: jest.fn().mockReturnValue(challengeRepo), + }), + ); + + await expect( + (service as any).verifyAndConsumeChallenge({ + address: validAddress, + nonce: challenge.nonce, + expiresAt: challenge.expires_at.getTime() - 1, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('Challenge expiry mismatch'); + }); + + it('rejects challenge verification on invalid signature', async () => { + const { service, dataSource } = getService(); + const challenge = { + nonce: 'a'.repeat(24), + address: validAddress, + expires_at: new Date(Date.now() + 10_000), + consumed_at: null, + }; + const challengeRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(challenge), + }), + }; + dataSource.transaction.mockImplementation(async (cb) => + cb({ + getRepository: jest.fn().mockReturnValue(challengeRepo), + }), + ); + jest.spyOn(service as any, 'verifyAddressSignature').mockReturnValue(false); + + await expect( + (service as any).verifyAndConsumeChallenge({ + address: validAddress, + nonce: challenge.nonce, + expiresAt: challenge.expires_at.getTime(), + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('Invalid challenge signature'); + }); + + it('marks retry when processing cannot continue because sponsor funds are unavailable', async () => { + const { service } = getService(); + const markRetrySpy = jest + .spyOn(service as any, 'markRetry') + .mockResolvedValue(undefined); + jest.spyOn(service as any, 'withLockedClaim').mockResolvedValue({ + address: validAddress, + name: 'myuniquename123.chain', + status: 'pending', + }); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockRejectedValue(new ServiceUnavailableException('no funds')); + + await (service as any).processClaimInternal(validAddress); + + expect(markRetrySpy).toHaveBeenCalledWith( + validAddress, + 'Sponsor account has insufficient funds', + ); + }); + + it('resets an expired preclaim back to pending', async () => { + const { service, claimRepository, aeSdkService } = getService(); + const save = jest.fn().mockResolvedValue(undefined); + const entry = { + address: validAddress, + name: 'myuniquename123.chain', + status: 'preclaimed', + salt: '123', + preclaim_height: 1, + preclaim_tx_hash: 'th_preclaim', + error: 'old', + retry_count: 5, + next_retry_at: null, + }; + claimRepository.findOne.mockResolvedValue(entry); + aeSdkService.sdk.getHeight.mockResolvedValue(1000); + jest.spyOn(service as any, 'getNameStateIfPresent').mockResolvedValue(null); + jest + .spyOn(service as any, 'withLockedClaim') + .mockImplementation(async (_address, work) => + (work as any)({ save } as any, entry), + ); + + await (service as any).stepClaim(validAddress); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'pending', + salt: null, + preclaim_height: null, + preclaim_tx_hash: null, + retry_count: 0, + }), + ); + }); + + it('waits for the next height before claiming after preclaim', async () => { + const { service, claimRepository, aeSdkService, profileSpendQueueService } = + getService(); + const save = jest.fn().mockResolvedValue(undefined); + const entry = { + address: validAddress, + name: 'myuniquename123.chain', + status: 'preclaimed', + salt: '123', + preclaim_height: 100, + next_retry_at: null, + }; + claimRepository.findOne.mockResolvedValue(entry); + aeSdkService.sdk.getHeight.mockResolvedValue(100); + jest.spyOn(service as any, 'getNameStateIfPresent').mockResolvedValue(null); + jest + .spyOn(service as any, 'withLockedClaim') + .mockImplementation(async (_address, work) => + (work as any)({ save } as any, entry), + ); + + await (service as any).stepClaim(validAddress); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + next_retry_at: expect.any(Date), + }), + ); + expect(profileSpendQueueService.enqueueSpend).not.toHaveBeenCalled(); + }); + + it('marks a claim complete when the name is already owned by the user', async () => { + const { service, claimRepository } = getService(); + const save = jest.fn().mockResolvedValue(undefined); + const entry = { + address: validAddress, + name: 'myuniquename123.chain', + status: 'claimed', + next_retry_at: new Date(), + }; + claimRepository.findOne.mockResolvedValue(entry); + jest + .spyOn(service as any, 'getNameStateIfPresent') + .mockResolvedValue({ owner: validAddress }); + jest + .spyOn(service as any, 'withLockedClaim') + .mockImplementation(async (_address, work) => + (work as any)({ save } as any, entry), + ); + + await (service as any).stepUpdatePointer(validAddress); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + next_retry_at: null, + }), + ); + }); + + it('marks a claim failed once max retries is reached', async () => { + const { service } = getService(); + const save = jest.fn().mockResolvedValue(undefined); + const entry = { + address: validAddress, + status: 'pending', + retry_count: 9, + next_retry_at: new Date(), + error: null, + }; + jest + .spyOn(service as any, 'withLockedClaim') + .mockImplementation(async (_address, work) => + (work as any)({ save } as any, entry), + ); + + await (service as any).markRetry(validAddress, 'boom'); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + retry_count: 10, + error: 'boom', + next_retry_at: null, + }), + ); + }); +}); diff --git a/src/profile/services/profile-chain-name.service.ts b/src/profile/services/profile-chain-name.service.ts new file mode 100644 index 00000000..e91d4bae --- /dev/null +++ b/src/profile/services/profile-chain-name.service.ts @@ -0,0 +1,742 @@ +import { AeSdkService } from '@/ae/ae-sdk.service'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + Name, + MemoryAccount, + Encoding, + buildTxAsync, + decode, + isEncoded, + sendTransaction, + Tag, + verifyMessageSignature, +} from '@aeternity/aepp-sdk'; +import { randomBytes } from 'crypto'; +import { DataSource, In, IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { + ChainNameClaimStatus, + ProfileChainNameClaim, +} from '../entities/profile-chain-name-claim.entity'; +import { ProfileChainNameChallenge } from '../entities/profile-chain-name-challenge.entity'; +import { + PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS, + PROFILE_CHAIN_NAME_MAX_RETRIES, + PROFILE_CHAIN_NAME_PRIVATE_KEY, + PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS, + PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS, +} from '../profile.constants'; +import { ProfileSpendQueueService } from './profile-spend-queue.service'; + +const RETRYABLE_STATUSES: ChainNameClaimStatus[] = [ + 'pending', + 'preclaimed', + 'claimed', +]; +const BATCH_SIZE = 50; +const MAX_PRECLAIM_AGE_BLOCKS = 250; + +@Injectable() +export class ProfileChainNameService { + private readonly logger = new Logger(ProfileChainNameService.name); + private readonly processingByAddress = new Map>(); + private isCronRunning = false; + + constructor( + @InjectRepository(ProfileChainNameClaim) + private readonly claimRepository: Repository, + @InjectRepository(ProfileChainNameChallenge) + private readonly challengeRepository: Repository, + private readonly dataSource: DataSource, + private readonly aeSdkService: AeSdkService, + private readonly profileSpendQueueService: ProfileSpendQueueService, + ) {} + + async createChallenge(address: string): Promise<{ + nonce: string; + expires_at: number; + message: string; + }> { + this.assertValidAddress(address); + + const nonce = randomBytes(24).toString('hex'); + const expiresAt = + Date.now() + PROFILE_CHAIN_NAME_CHALLENGE_TTL_SECONDS * 1000; + const message = this.createChallengeMessage(address, nonce, expiresAt); + + await this.challengeRepository.save( + this.challengeRepository.create({ + nonce, + address, + expires_at: new Date(expiresAt), + consumed_at: null, + }), + ); + + return { + nonce, + expires_at: expiresAt, + message, + }; + } + + async requestChainName(params: { + address: string; + name: string; + challengeNonce: string; + challengeExpiresAt: number; + signatureHex: string; + }): Promise<{ status: string; message: string }> { + if (!PROFILE_CHAIN_NAME_PRIVATE_KEY) { + this.logger.error( + 'PROFILE_CHAIN_NAME_PRIVATE_KEY is not configured, cannot process chain name claims', + ); + throw new ServiceUnavailableException( + 'Chain name claiming is not available at this time', + ); + } + + this.assertValidAddress(params.address); + await this.verifyAndConsumeChallenge({ + address: params.address, + nonce: params.challengeNonce, + expiresAt: params.challengeExpiresAt, + signatureHex: params.signatureHex, + }); + + const fullName = `${params.name}.chain`; + const existing = await this.claimRepository.findOne({ + where: { address: params.address }, + }); + if (existing?.status === 'completed') { + throw new ConflictException( + `Address already has a claimed chain name: ${existing.name}`, + ); + } + if (existing && existing.status !== 'failed') { + if (existing.name !== fullName) { + throw new ConflictException( + `Address already has an in-progress chain name claim: ${existing.name}`, + ); + } + void this.processClaimWithGuard(params.address); + return { + status: existing.status, + message: `Chain name ${fullName} claim is already in progress for ${params.address}`, + }; + } + + await this.assertSponsorHasFunds(fullName); + + const existingName = await this.claimRepository.findOne({ + where: { name: fullName }, + }); + if ( + existingName && + existingName.address !== params.address && + existingName.status !== 'failed' + ) { + throw new ConflictException( + 'This name is already being claimed by another address', + ); + } + const failedNameClaimToReplace = + existingName && + existingName.address !== params.address && + existingName.status === 'failed' + ? existingName + : null; + + const onChainState = await this.getNameStateIfPresent(fullName); + const sponsorAddress = this.getSponsorAccount().address; + const ownedByThisFlow = + existing?.address === params.address && + (onChainState?.owner === sponsorAddress || + onChainState?.owner === params.address); + if (onChainState && !ownedByThisFlow) { + throw new BadRequestException('This name is already taken on-chain'); + } + + const claim = + existing || + this.claimRepository.create({ + address: params.address, + }); + claim.name = fullName; + claim.status = 'pending'; + claim.error = null; + claim.retry_count = 0; + claim.next_retry_at = new Date(); + claim.salt = null; + claim.preclaim_height = null; + claim.preclaim_tx_hash = null; + claim.claim_tx_hash = null; + claim.update_tx_hash = null; + claim.transfer_tx_hash = null; + + try { + if (failedNameClaimToReplace) { + await this.claimRepository.delete({ + address: failedNameClaimToReplace.address, + }); + } + await this.claimRepository.save(claim); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + throw new ConflictException( + 'This name is already being claimed by another address', + ); + } + throw error; + } + + void this.processClaimWithGuard(params.address); + + return { + status: 'ok', + message: `Chain name ${fullName} claim started for ${params.address}`, + }; + } + + async getClaimStatus(address: string): Promise<{ + status: ChainNameClaimStatus | 'not_started'; + name: string | null; + preclaim_tx_hash: string | null; + claim_tx_hash: string | null; + update_tx_hash: string | null; + transfer_tx_hash: string | null; + error: string | null; + retry_count: number; + }> { + this.assertValidAddress(address); + + const claim = await this.claimRepository.findOne({ where: { address } }); + if (!claim) { + return { + status: 'not_started', + name: null, + preclaim_tx_hash: null, + claim_tx_hash: null, + update_tx_hash: null, + transfer_tx_hash: null, + error: null, + retry_count: 0, + }; + } + return { + status: claim.status, + name: claim.name, + preclaim_tx_hash: claim.preclaim_tx_hash, + claim_tx_hash: claim.claim_tx_hash, + update_tx_hash: claim.update_tx_hash, + transfer_tx_hash: claim.transfer_tx_hash, + error: claim.error, + retry_count: claim.retry_count, + }; + } + + @Cron('*/30 * * * * *') + async processDueClaims(): Promise { + if (this.isCronRunning) return; + this.isCronRunning = true; + try { + const now = new Date(); + const dueClaims = await this.claimRepository.find({ + where: [ + { status: In(RETRYABLE_STATUSES), next_retry_at: IsNull() }, + { + status: In(RETRYABLE_STATUSES), + next_retry_at: LessThanOrEqual(now), + }, + ], + order: { next_retry_at: 'ASC', updated_at: 'ASC' }, + take: BATCH_SIZE, + }); + for (const claim of dueClaims) { + await this.processClaimWithGuard(claim.address); + } + } catch (error) { + this.logger.error('Failed to process due chain name claims', error); + } finally { + this.isCronRunning = false; + } + } + + private async processClaimWithGuard(address: string): Promise { + const existing = this.processingByAddress.get(address); + if (existing) return existing; + + const work = this.processClaimInternal(address).catch((error) => { + this.logger.error( + `Failed to process chain name claim for ${address}`, + error instanceof Error ? error.stack : String(error), + ); + }); + this.processingByAddress.set(address, work); + try { + await work; + } finally { + if (this.processingByAddress.get(address) === work) { + this.processingByAddress.delete(address); + } + } + } + + private async processClaimInternal(address: string): Promise { + const claim = await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status === 'completed' || entry.status === 'failed') { + return null; + } + entry.last_attempt_at = new Date(); + await repo.save(entry); + return entry; + }); + if (!claim) return; + + switch (claim.status) { + case 'pending': + await this.stepPreclaim(address); + break; + case 'preclaimed': + await this.stepClaim(address); + break; + case 'claimed': + await this.stepUpdatePointer(address); + break; + } + } + + private async stepPreclaim(address: string): Promise { + try { + await this.profileSpendQueueService.enqueueSpend( + PROFILE_CHAIN_NAME_PRIVATE_KEY, + async () => { + const claim = await this.claimRepository.findOne({ + where: { address }, + }); + if (!claim || claim.status !== 'pending') return; + + const nameInstance = this.createNameInstance(claim.name); + const preclaimResult = await nameInstance.preclaim(); + + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'pending') return; + entry.status = 'preclaimed'; + entry.salt = String(preclaimResult.nameSalt); + entry.preclaim_tx_hash = preclaimResult.hash || null; + entry.preclaim_height = preclaimResult.blockHeight ?? null; + entry.error = null; + entry.next_retry_at = new Date(Date.now() + 30_000); + await repo.save(entry); + }); + + this.logger.log( + `Preclaim submitted for ${claim.name} (${address}), tx: ${preclaimResult.hash}`, + ); + }, + ); + } catch (error) { + await this.markRetry( + address, + error instanceof Error ? error.message : String(error), + ); + this.logger.warn(`Preclaim failed for ${address}, scheduled retry`); + } + } + + private async stepClaim(address: string): Promise { + try { + const claim = await this.claimRepository.findOne({ + where: { address }, + }); + if (!claim || claim.status !== 'preclaimed' || !claim.salt) return; + + const sponsorAddress = this.getSponsorAccount().address; + const onChainState = await this.getNameStateIfPresent(claim.name); + if (onChainState?.owner === sponsorAddress) { + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'preclaimed') return; + entry.status = 'claimed'; + entry.error = null; + entry.next_retry_at = new Date(); + await repo.save(entry); + }); + return; + } + if (onChainState?.owner === address) { + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'preclaimed') return; + entry.status = 'completed'; + entry.error = null; + entry.next_retry_at = null; + await repo.save(entry); + }); + return; + } + + const currentHeight = await this.aeSdkService.sdk.getHeight(); + + if ( + claim.preclaim_height != null && + currentHeight - claim.preclaim_height > MAX_PRECLAIM_AGE_BLOCKS + ) { + this.logger.warn( + `Preclaim expired for ${claim.name} (${address}), resetting to pending`, + ); + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'preclaimed') return; + entry.status = 'pending'; + entry.salt = null; + entry.preclaim_height = null; + entry.preclaim_tx_hash = null; + entry.error = null; + entry.retry_count = 0; + entry.next_retry_at = new Date(); + await repo.save(entry); + }); + return; + } + + if ( + claim.preclaim_height != null && + currentHeight <= claim.preclaim_height + ) { + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry) return; + entry.next_retry_at = new Date(Date.now() + 15_000); + await repo.save(entry); + }); + return; + } + + await this.profileSpendQueueService.enqueueSpend( + PROFILE_CHAIN_NAME_PRIVATE_KEY, + async () => { + const freshClaim = await this.claimRepository.findOne({ + where: { address }, + }); + if ( + !freshClaim || + freshClaim.status !== 'preclaimed' || + !freshClaim.salt + ) { + return; + } + + const claimResult = await this.submitClaimTransaction( + freshClaim.name, + freshClaim.salt, + ); + + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'preclaimed') return; + entry.status = 'claimed'; + entry.claim_tx_hash = claimResult.hash || null; + entry.error = null; + entry.next_retry_at = new Date(); + await repo.save(entry); + }); + + this.logger.log( + `Claim submitted for ${freshClaim.name} (${address}), tx: ${claimResult.hash}`, + ); + }, + ); + } catch (error) { + await this.markRetry( + address, + error instanceof Error ? error.message : String(error), + ); + this.logger.warn(`Claim failed for ${address}, scheduled retry`); + } + } + + private async stepUpdatePointer(address: string): Promise { + try { + const claim = await this.claimRepository.findOne({ + where: { address }, + }); + if (!claim || claim.status !== 'claimed') return; + + const sponsorAddress = this.getSponsorAccount().address; + const onChainState = await this.getNameStateIfPresent(claim.name); + if (onChainState?.owner === address) { + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'claimed') return; + entry.status = 'completed'; + entry.error = null; + entry.next_retry_at = null; + await repo.save(entry); + }); + return; + } + if (onChainState && onChainState.owner !== sponsorAddress) { + throw new BadRequestException( + `Chain name ${claim.name} is owned by an unexpected address`, + ); + } + + await this.profileSpendQueueService.enqueueSpend( + PROFILE_CHAIN_NAME_PRIVATE_KEY, + async () => { + const freshClaim = await this.claimRepository.findOne({ + where: { address }, + }); + if (!freshClaim || freshClaim.status !== 'claimed') return; + + const nameInstance = this.createNameInstance(freshClaim.name); + const updateResult = await nameInstance.update({ + account_pubkey: address as `ak_${string}`, + }); + const transferResult = await nameInstance.transfer( + address as `ak_${string}`, + ); + + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status !== 'claimed') return; + entry.status = 'completed'; + entry.update_tx_hash = updateResult.hash || null; + entry.transfer_tx_hash = transferResult.hash || null; + entry.error = null; + entry.next_retry_at = null; + await repo.save(entry); + }); + + this.logger.log( + `Pointer set and ownership transferred for ${freshClaim.name} -> ${address}, update tx: ${updateResult.hash}, transfer tx: ${transferResult.hash}`, + ); + }, + ); + } catch (error) { + await this.markRetry( + address, + error instanceof Error ? error.message : String(error), + ); + this.logger.warn(`Pointer update failed for ${address}, scheduled retry`); + } + } + + private createNameInstance(chainName: string): Name { + const sponsorAccount = this.getSponsorAccount(); + return new Name(chainName as `${string}.chain`, { + ...this.aeSdkService.sdk.getContext(), + onAccount: sponsorAccount, + }); + } + + private getSponsorAccount(): MemoryAccount { + return this.profileSpendQueueService.getRewardAccount( + PROFILE_CHAIN_NAME_PRIVATE_KEY, + 'PROFILE_CHAIN_NAME_PRIVATE_KEY', + ); + } + + private async submitClaimTransaction(chainName: string, salt: string) { + const sponsorAccount = this.getSponsorAccount(); + const context = this.aeSdkService.sdk.getContext(); + const parsedSalt = Number(salt); + + if (!Number.isSafeInteger(parsedSalt) || parsedSalt < 0) { + throw new Error(`Invalid persisted name salt for ${chainName}`); + } + + const tx = await buildTxAsync({ + tag: Tag.NameClaimTx, + onNode: context.onNode, + onAccount: sponsorAccount, + accountId: sponsorAccount.address, + name: chainName as `${string}.chain`, + nameSalt: parsedSalt, + }); + return sendTransaction(tx, { + onNode: context.onNode, + onAccount: sponsorAccount, + }); + } + + private async getNameStateIfPresent(fullName: string) { + try { + const nameObj = new Name( + fullName as `${string}.chain`, + this.aeSdkService.sdk.getContext(), + ); + return await nameObj.getState(); + } catch (error) { + if (this.isNotFoundError(error)) { + return null; + } + throw new ServiceUnavailableException( + 'Unable to verify chain name availability right now', + ); + } + } + + private async markRetry( + address: string, + errorMessage: string, + ): Promise { + await this.withLockedClaim(address, async (repo, entry) => { + if (!entry || entry.status === 'completed' || entry.status === 'failed') { + return; + } + const retryCount = (entry.retry_count || 0) + 1; + entry.retry_count = retryCount; + entry.error = errorMessage; + if (retryCount >= Math.max(PROFILE_CHAIN_NAME_MAX_RETRIES, 1)) { + entry.status = 'failed'; + entry.next_retry_at = null; + } else { + entry.next_retry_at = new Date( + Date.now() + this.getRetryDelayMs(retryCount), + ); + } + await repo.save(entry); + }); + } + + private async verifyAndConsumeChallenge(params: { + address: string; + nonce: string; + expiresAt: number; + signatureHex: string; + }): Promise { + if (!params.nonce || !params.signatureHex || !params.expiresAt) { + throw new BadRequestException('Challenge proof is required'); + } + const now = Date.now(); + if (params.expiresAt <= now) { + throw new BadRequestException('Challenge has expired'); + } + + await this.dataSource.transaction(async (manager) => { + const challengeRepo = manager.getRepository(ProfileChainNameChallenge); + const challenge = await challengeRepo + .createQueryBuilder('challenge') + .setLock('pessimistic_write') + .where('challenge.nonce = :nonce', { nonce: params.nonce }) + .andWhere('challenge.address = :address', { address: params.address }) + .andWhere('challenge.consumed_at IS NULL') + .getOne(); + if (!challenge) { + throw new BadRequestException('Challenge not found'); + } + if (challenge.expires_at.getTime() !== params.expiresAt) { + throw new BadRequestException('Challenge expiry mismatch'); + } + if (challenge.expires_at.getTime() <= now) { + throw new BadRequestException('Challenge has expired'); + } + + const message = this.createChallengeMessage( + params.address, + params.nonce, + params.expiresAt, + ); + if ( + !this.verifyAddressSignature( + params.address, + message, + params.signatureHex, + ) + ) { + throw new BadRequestException('Invalid challenge signature'); + } + + challenge.consumed_at = new Date(); + await challengeRepo.save(challenge); + }); + } + + private verifyAddressSignature( + address: string, + message: string, + signatureHex: string, + ): boolean { + try { + let signatureBytes: Uint8Array; + if (signatureHex.startsWith('sg_')) { + signatureBytes = Uint8Array.from(decode(signatureHex as any)); + } else { + signatureBytes = Uint8Array.from(Buffer.from(signatureHex, 'hex')); + } + if (signatureBytes.length !== 64) { + return false; + } + return verifyMessageSignature( + message, + signatureBytes, + address as `ak_${string}`, + ); + } catch { + return false; + } + } + + private createChallengeMessage( + address: string, + nonce: string, + expiresAt: number, + ): string { + return `profile_chain_name_claim:${address}:${nonce}:${expiresAt}`; + } + + private assertValidAddress(address: string): void { + if (!address || !isEncoded(address, Encoding.AccountAddress)) { + throw new BadRequestException('Invalid address'); + } + } + + private isNotFoundError(error: unknown): boolean { + const status = Number( + (error as any)?.statusCode ?? + (error as any)?.status ?? + (error as any)?.code, + ); + const message = String( + (error as any)?.message || (error as any)?.reason || '', + ).toLowerCase(); + return ( + status === 404 || + message.includes('not found') || + message.includes('name not found') + ); + } + + private isUniqueConstraintError(error: unknown): boolean { + const driverError = (error as any)?.driverError || error; + return String(driverError?.code || '') === '23505'; + } + + private async withLockedClaim( + address: string, + work: ( + repo: Repository, + entry: ProfileChainNameClaim | null, + ) => Promise, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(ProfileChainNameClaim); + const entry = await repo + .createQueryBuilder('claim') + .setLock('pessimistic_write') + .where('claim.address = :address', { address }) + .getOne(); + return work(repo, entry); + }); + } + + private getRetryDelayMs(retryCount: number): number { + const base = Math.max(PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS, 1); + const max = Math.max(PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS, base); + const exponent = Math.max(retryCount - 1, 0); + const delay = base * 2 ** Math.min(exponent, 10); + return Math.min(delay, max) * 1000; + } +} diff --git a/src/profile/services/profile-signature.util.ts b/src/profile/services/profile-signature.util.ts new file mode 100644 index 00000000..5155a5bb --- /dev/null +++ b/src/profile/services/profile-signature.util.ts @@ -0,0 +1,26 @@ +import { decode, verifyMessageSignature } from '@aeternity/aepp-sdk'; + +export function verifyAeAddressSignature( + address: string, + message: string, + signatureHex: string, +): boolean { + try { + let signatureBytes: Uint8Array; + if (signatureHex.startsWith('sg_')) { + signatureBytes = Uint8Array.from(decode(signatureHex as any)); + } else { + signatureBytes = Uint8Array.from(Buffer.from(signatureHex, 'hex')); + } + if (signatureBytes.length !== 64) { + return false; + } + return verifyMessageSignature( + message, + signatureBytes, + address as `ak_${string}`, + ); + } catch { + return false; + } +} diff --git a/src/profile/services/profile-x-invite.service.spec.ts b/src/profile/services/profile-x-invite.service.spec.ts index 8649729c..974272d2 100644 --- a/src/profile/services/profile-x-invite.service.spec.ts +++ b/src/profile/services/profile-x-invite.service.spec.ts @@ -9,6 +9,7 @@ jest.mock('../profile.constants', () => ({ })); import { ProfileXInviteService } from './profile-x-invite.service'; +import * as profileSignatureUtil from './profile-signature.util'; describe.skip('ProfileXInviteService', () => { const getService = () => { @@ -268,7 +269,7 @@ describe.skip('ProfileXInviteService', () => { save, }); const verifyAddressSignature = jest - .spyOn(service as any, 'verifyAddressSignature') + .spyOn(profileSignatureUtil, 'verifyAeAddressSignature') .mockReturnValue(true); const signatureHex = 'sg_AbCdEfGhJkLmNoPqRsTuVwXyZ123456789'; diff --git a/src/profile/services/profile-x-invite.service.ts b/src/profile/services/profile-x-invite.service.ts index 959d789f..bafb2ec1 100644 --- a/src/profile/services/profile-x-invite.service.ts +++ b/src/profile/services/profile-x-invite.service.ts @@ -1,6 +1,5 @@ import { AeSdkService } from '@/ae/ae-sdk.service'; import { InjectRepository } from '@nestjs/typeorm'; -import { decode, verifyMessageSignature } from '@aeternity/aepp-sdk'; import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { DataSource, Repository } from 'typeorm'; @@ -17,6 +16,7 @@ import { ProfileXInvite } from '../entities/profile-x-invite.entity'; import { ProfileXInviteCredit } from '../entities/profile-x-invite-credit.entity'; import { ProfileXInviteMilestoneReward } from '../entities/profile-x-invite-milestone-reward.entity'; import { ProfileSpendQueueService } from './profile-spend-queue.service'; +import { verifyAeAddressSignature } from './profile-signature.util'; import { getRewardAmountAettos, isValidAeAmount, @@ -548,11 +548,7 @@ export class ProfileXInviteService { params.expiresAt, ); if ( - !this.verifyAddressSignature( - params.address, - message, - params.signatureHex, - ) + !verifyAeAddressSignature(params.address, message, params.signatureHex) ) { throw new BadRequestException('Invalid challenge signature'); } @@ -561,29 +557,4 @@ export class ProfileXInviteService { await challengeRepo.save(challenge); }); } - - private verifyAddressSignature( - address: string, - message: string, - signatureHex: string, - ): boolean { - try { - let signatureBytes: Uint8Array; - if (signatureHex.startsWith('sg_')) { - signatureBytes = Uint8Array.from(decode(signatureHex as any)); - } else { - signatureBytes = Uint8Array.from(Buffer.from(signatureHex, 'hex')); - } - if (signatureBytes.length !== 64) { - return false; - } - return verifyMessageSignature( - message, - signatureBytes, - address as `ak_${string}`, - ); - } catch { - return false; - } - } } From efaf6f9f160c973255d24c8683fd29d33ba0a705 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 1 Apr 2026 16:31:09 +0400 Subject: [PATCH 2/2] feat(claim-name): handle no fund more gracefully --- .../profile-chain-name.service.spec.ts | 157 +++++++++++--- .../services/profile-chain-name.service.ts | 192 +++++++++++++----- 2 files changed, 273 insertions(+), 76 deletions(-) diff --git a/src/profile/services/profile-chain-name.service.spec.ts b/src/profile/services/profile-chain-name.service.spec.ts index 0d6df495..22ae351c 100644 --- a/src/profile/services/profile-chain-name.service.spec.ts +++ b/src/profile/services/profile-chain-name.service.spec.ts @@ -19,7 +19,11 @@ jest.mock('@aeternity/aepp-sdk', () => { }; }); -import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ServiceUnavailableException, +} from '@nestjs/common'; import { buildTxAsync, sendTransaction, @@ -27,6 +31,7 @@ import { verifyMessageSignature, } from '@aeternity/aepp-sdk'; import { ProfileChainNameService } from './profile-chain-name.service'; +import * as profileSignatureUtil from './profile-signature.util'; import { ProfileChainNameClaim } from '../entities/profile-chain-name-claim.entity'; import { ProfileChainNameChallenge } from '../entities/profile-chain-name-challenge.entity'; @@ -50,6 +55,7 @@ describe('ProfileChainNameService', () => { where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(null), }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), save: jest.fn().mockImplementation(async (value) => value), } as any; const lockedChallengeRepository = { @@ -178,9 +184,7 @@ describe('ProfileChainNameService', () => { it('rejects a second completed sponsored name for the same address', async () => { const { service, claimRepository } = getService(); - jest - .spyOn(service as any, 'verifyAndConsumeChallenge') - .mockResolvedValue(undefined); + const verifySpy = jest.spyOn(service as any, 'verifyAndConsumeChallenge'); jest .spyOn(service as any, 'assertSponsorHasFunds') .mockResolvedValue(undefined); @@ -199,6 +203,8 @@ describe('ProfileChainNameService', () => { signatureHex: 'b'.repeat(128), }), ).rejects.toThrow(ConflictException); + + expect(verifySpy).not.toHaveBeenCalled(); }); it('rejects a different in-progress name for the same address', async () => { @@ -228,9 +234,7 @@ describe('ProfileChainNameService', () => { it('rejects a name already being actively claimed by another address', async () => { const { service, claimRepository } = getService(); - jest - .spyOn(service as any, 'verifyAndConsumeChallenge') - .mockResolvedValue(undefined); + const verifySpy = jest.spyOn(service as any, 'verifyAndConsumeChallenge'); jest .spyOn(service as any, 'assertSponsorHasFunds') .mockResolvedValue(undefined); @@ -249,13 +253,18 @@ describe('ProfileChainNameService', () => { signatureHex: 'b'.repeat(128), }), ).rejects.toThrow('This name is already being claimed by another address'); + + expect(verifySpy).not.toHaveBeenCalled(); }); it('allows reusing a name from another address when its old claim failed', async () => { - const { service, claimRepository } = getService(); - jest - .spyOn(service as any, 'verifyAndConsumeChallenge') - .mockResolvedValue(undefined); + const { + service, + claimRepository, + lockedClaimRepository, + lockedChallengeRepository, + dataSource, + } = getService(); jest .spyOn(service as any, 'assertSponsorHasFunds') .mockResolvedValue(undefined); @@ -263,10 +272,23 @@ describe('ProfileChainNameService', () => { jest .spyOn(service as any, 'processClaimWithGuard') .mockResolvedValue(undefined); - claimRepository.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce({ - address: 'ak_other', - name: 'myuniquename123.chain', - status: 'failed', + (service as any).claimRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + address: 'ak_other', + name: 'myuniquename123.chain', + status: 'failed', + }); + lockedChallengeRepository.createQueryBuilder.mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + nonce: 'a'.repeat(24), + address: validAddress, + expires_at: new Date(Date.now() + 10_000), + consumed_at: null, + }), }); const result = await service.requestChainName({ @@ -278,17 +300,69 @@ describe('ProfileChainNameService', () => { }); expect(result.status).toBe('ok'); - expect(claimRepository.delete).toHaveBeenCalledWith({ + expect(lockedClaimRepository.delete).toHaveBeenCalledWith({ address: 'ak_other', + name: 'myuniquename123.chain', + status: 'failed', }); - expect(claimRepository.save).toHaveBeenCalled(); + expect(lockedClaimRepository.save).toHaveBeenCalled(); + expect(claimRepository.delete).not.toHaveBeenCalled(); + expect(claimRepository.save).not.toHaveBeenCalled(); + expect(dataSource.transaction).toHaveBeenCalled(); }); - it('rejects a name already taken on-chain by someone else', async () => { - const { service, claimRepository } = getService(); + it('does not delete another address revived in-progress claim during failed-claim replacement race', async () => { + const { + service, + claimRepository, + lockedClaimRepository, + lockedChallengeRepository, + } = getService(); jest - .spyOn(service as any, 'verifyAndConsumeChallenge') + .spyOn(service as any, 'assertSponsorHasFunds') .mockResolvedValue(undefined); + jest.spyOn(service as any, 'getNameStateIfPresent').mockResolvedValue(null); + claimRepository.findOne.mockResolvedValueOnce(null).mockResolvedValueOnce({ + address: 'ak_other', + name: 'myuniquename123.chain', + status: 'failed', + }); + lockedChallengeRepository.createQueryBuilder.mockReturnValue({ + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + nonce: 'a'.repeat(24), + address: validAddress, + expires_at: new Date(Date.now() + 10_000), + consumed_at: null, + }), + }); + lockedClaimRepository.delete.mockResolvedValueOnce({ affected: 0 }); + lockedClaimRepository.save.mockRejectedValueOnce({ + driverError: { code: '23505' }, + }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow('This name is already being claimed by another address'); + + expect(lockedClaimRepository.delete).toHaveBeenCalledWith({ + address: 'ak_other', + name: 'myuniquename123.chain', + status: 'failed', + }); + }); + + it('rejects a name already taken on-chain by someone else', async () => { + const { service, claimRepository } = getService(); + const verifySpy = jest.spyOn(service as any, 'verifyAndConsumeChallenge'); jest .spyOn(service as any, 'assertSponsorHasFunds') .mockResolvedValue(undefined); @@ -308,10 +382,45 @@ describe('ProfileChainNameService', () => { signatureHex: 'b'.repeat(128), }), ).rejects.toThrow('This name is already taken on-chain'); + + expect(verifySpy).not.toHaveBeenCalled(); }); - it('converts unique constraint races into a conflict error', async () => { + it('does not treat a sponsor-owned on-chain name as this flow when the existing failed claim is for another name', async () => { const { service, claimRepository } = getService(); + const verifySpy = jest.spyOn(service as any, 'verifyAndConsumeChallenge'); + jest + .spyOn(service as any, 'assertSponsorHasFunds') + .mockResolvedValue(undefined); + claimRepository.findOne + .mockResolvedValueOnce({ + address: validAddress, + name: 'differentfailedname123.chain', + status: 'failed', + }) + .mockResolvedValueOnce(null); + jest + .spyOn(service as any, 'getNameStateIfPresent') + .mockResolvedValue({ owner: validAddress }); + jest + .spyOn(service as any, 'getSponsorAccount') + .mockReturnValue({ address: validAddress }); + + await expect( + service.requestChainName({ + address: validAddress, + name: 'myuniquename123', + challengeNonce: 'a'.repeat(24), + challengeExpiresAt: Date.now() + 10_000, + signatureHex: 'b'.repeat(128), + }), + ).rejects.toThrow(BadRequestException); + + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it('converts unique constraint races into a conflict error', async () => { + const { service, claimRepository, lockedClaimRepository } = getService(); jest .spyOn(service as any, 'verifyAndConsumeChallenge') .mockResolvedValue(undefined); @@ -322,7 +431,7 @@ describe('ProfileChainNameService', () => { claimRepository.findOne .mockResolvedValueOnce(null) .mockResolvedValueOnce(null); - claimRepository.save.mockRejectedValueOnce({ + lockedClaimRepository.save.mockRejectedValueOnce({ driverError: { code: '23505' }, }); @@ -506,7 +615,9 @@ describe('ProfileChainNameService', () => { getRepository: jest.fn().mockReturnValue(challengeRepo), }), ); - jest.spyOn(service as any, 'verifyAddressSignature').mockReturnValue(false); + jest + .spyOn(profileSignatureUtil, 'verifyAeAddressSignature') + .mockReturnValue(false); await expect( (service as any).verifyAndConsumeChallenge({ diff --git a/src/profile/services/profile-chain-name.service.ts b/src/profile/services/profile-chain-name.service.ts index e91d4bae..007f6337 100644 --- a/src/profile/services/profile-chain-name.service.ts +++ b/src/profile/services/profile-chain-name.service.ts @@ -12,15 +12,25 @@ import { Name, MemoryAccount, Encoding, + buildTx, buildTxAsync, - decode, + commitmentHash, + getExecutionCost, + getMinimumNameFee, isEncoded, + produceNameId, sendTransaction, Tag, - verifyMessageSignature, } from '@aeternity/aepp-sdk'; import { randomBytes } from 'crypto'; -import { DataSource, In, IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { + DataSource, + EntityManager, + In, + IsNull, + LessThanOrEqual, + Repository, +} from 'typeorm'; import { ChainNameClaimStatus, ProfileChainNameClaim, @@ -33,6 +43,7 @@ import { PROFILE_CHAIN_NAME_RETRY_BASE_SECONDS, PROFILE_CHAIN_NAME_RETRY_MAX_SECONDS, } from '../profile.constants'; +import { verifyAeAddressSignature } from './profile-signature.util'; import { ProfileSpendQueueService } from './profile-spend-queue.service'; const RETRYABLE_STATUSES: ChainNameClaimStatus[] = [ @@ -42,6 +53,9 @@ const RETRYABLE_STATUSES: ChainNameClaimStatus[] = [ ]; const BATCH_SIZE = 50; const MAX_PRECLAIM_AGE_BLOCKS = 250; +const STUB_ADDRESS: `ak_${string}` = + 'ak_11111111111111111111111111111111273Yts'; +const STUB_NONCE = 1; @Injectable() export class ProfileChainNameService { @@ -104,13 +118,6 @@ export class ProfileChainNameService { } this.assertValidAddress(params.address); - await this.verifyAndConsumeChallenge({ - address: params.address, - nonce: params.challengeNonce, - expiresAt: params.challengeExpiresAt, - signatureHex: params.signatureHex, - }); - const fullName = `${params.name}.chain`; const existing = await this.claimRepository.findOne({ where: { address: params.address }, @@ -158,6 +165,7 @@ export class ProfileChainNameService { const sponsorAddress = this.getSponsorAccount().address; const ownedByThisFlow = existing?.address === params.address && + existing?.name === fullName && (onChainState?.owner === sponsorAddress || onChainState?.owner === params.address); if (onChainState && !ownedByThisFlow) { @@ -182,12 +190,26 @@ export class ProfileChainNameService { claim.transfer_tx_hash = null; try { - if (failedNameClaimToReplace) { - await this.claimRepository.delete({ - address: failedNameClaimToReplace.address, - }); - } - await this.claimRepository.save(claim); + await this.dataSource.transaction(async (manager) => { + await this.verifyAndConsumeChallenge( + { + address: params.address, + nonce: params.challengeNonce, + expiresAt: params.challengeExpiresAt, + signatureHex: params.signatureHex, + }, + manager, + ); + const claimRepo = manager.getRepository(ProfileChainNameClaim); + if (failedNameClaimToReplace) { + await claimRepo.delete({ + address: failedNameClaimToReplace.address, + name: failedNameClaimToReplace.name, + status: 'failed', + }); + } + await claimRepo.save(claim); + }); } catch (error) { if (this.isUniqueConstraintError(error)) { throw new ConflictException( @@ -300,6 +322,13 @@ export class ProfileChainNameService { }); if (!claim) return; + try { + await this.assertSponsorHasFunds(claim.name); + } catch { + await this.markRetry(address, 'Sponsor account has insufficient funds'); + return; + } + switch (claim.status) { case 'pending': await this.stepPreclaim(address); @@ -601,12 +630,15 @@ export class ProfileChainNameService { }); } - private async verifyAndConsumeChallenge(params: { - address: string; - nonce: string; - expiresAt: number; - signatureHex: string; - }): Promise { + private async verifyAndConsumeChallenge( + params: { + address: string; + nonce: string; + expiresAt: number; + signatureHex: string; + }, + manager?: EntityManager, + ): Promise { if (!params.nonce || !params.signatureHex || !params.expiresAt) { throw new BadRequestException('Challenge proof is required'); } @@ -615,8 +647,8 @@ export class ProfileChainNameService { throw new BadRequestException('Challenge has expired'); } - await this.dataSource.transaction(async (manager) => { - const challengeRepo = manager.getRepository(ProfileChainNameChallenge); + const work = async (txManager: EntityManager) => { + const challengeRepo = txManager.getRepository(ProfileChainNameChallenge); const challenge = await challengeRepo .createQueryBuilder('challenge') .setLock('pessimistic_write') @@ -640,43 +672,23 @@ export class ProfileChainNameService { params.expiresAt, ); if ( - !this.verifyAddressSignature( - params.address, - message, - params.signatureHex, - ) + !verifyAeAddressSignature(params.address, message, params.signatureHex) ) { throw new BadRequestException('Invalid challenge signature'); } challenge.consumed_at = new Date(); await challengeRepo.save(challenge); - }); - } + }; - private verifyAddressSignature( - address: string, - message: string, - signatureHex: string, - ): boolean { - try { - let signatureBytes: Uint8Array; - if (signatureHex.startsWith('sg_')) { - signatureBytes = Uint8Array.from(decode(signatureHex as any)); - } else { - signatureBytes = Uint8Array.from(Buffer.from(signatureHex, 'hex')); - } - if (signatureBytes.length !== 64) { - return false; - } - return verifyMessageSignature( - message, - signatureBytes, - address as `ak_${string}`, - ); - } catch { - return false; + if (manager) { + await work(manager); + return; } + + await this.dataSource.transaction(async (txManager) => { + await work(txManager); + }); } private createChallengeMessage( @@ -687,6 +699,80 @@ export class ProfileChainNameService { return `profile_chain_name_claim:${address}:${nonce}:${expiresAt}`; } + private async assertSponsorHasFunds(fullName: string): Promise { + const sponsor = this.getSponsorAccount(); + try { + const balance = await this.aeSdkService.sdk.getBalance( + sponsor.address as `ak_${string}`, + ); + const requiredBalance = this.estimateTotalClaimCost(fullName); + if (BigInt(balance) < requiredBalance) { + this.logger.error( + `Sponsor account ${sponsor.address} balance too low: ${balance} aettos, need ${requiredBalance}`, + ); + throw new ServiceUnavailableException( + 'Chain name claiming is temporarily unavailable due to insufficient sponsor funds', + ); + } + } catch (error) { + if (error instanceof ServiceUnavailableException) throw error; + this.logger.error( + 'Failed to check sponsor balance', + error instanceof Error ? error.stack : String(error), + ); + throw new ServiceUnavailableException( + 'Chain name claiming is temporarily unavailable', + ); + } + } + + private estimateTotalClaimCost(fullName: string): bigint { + const nameTyped = fullName as `${string}.chain`; + const nameId = produceNameId(nameTyped); + + const preclaimCost = getExecutionCost( + buildTx({ + tag: Tag.NamePreclaimTx, + accountId: STUB_ADDRESS, + nonce: STUB_NONCE, + commitmentId: commitmentHash(nameTyped, STUB_NONCE), + }), + ); + + const claimCost = getExecutionCost( + buildTx({ + tag: Tag.NameClaimTx, + accountId: STUB_ADDRESS, + nonce: STUB_NONCE, + name: nameTyped, + nameSalt: 0, + nameFee: getMinimumNameFee(nameTyped), + }), + ); + + const updateCost = getExecutionCost( + buildTx({ + tag: Tag.NameUpdateTx, + accountId: STUB_ADDRESS, + nonce: STUB_NONCE, + nameId, + pointers: [{ key: 'account_pubkey', id: STUB_ADDRESS }], + }), + ); + + const transferCost = getExecutionCost( + buildTx({ + tag: Tag.NameTransferTx, + accountId: STUB_ADDRESS, + nonce: STUB_NONCE, + nameId, + recipientId: STUB_ADDRESS, + }), + ); + + return preclaimCost + claimCost + updateCost + transferCost; + } + private assertValidAddress(address: string): void { if (!address || !isEncoded(address, Encoding.AccountAddress)) { throw new BadRequestException('Invalid address');