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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy_develop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
1 change: 1 addition & 0 deletions .github/workflows/deploy_main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

1 change: 1 addition & 0 deletions .github/workflows/deploy_staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions .github/workflows/ssh_deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}"
Expand All @@ -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: |
Expand All @@ -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 \
Expand Down
61 changes: 61 additions & 0 deletions docs/profile-chain-name-manual-migration.sql
Original file line number Diff line number Diff line change
@@ -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;
59 changes: 59 additions & 0 deletions src/profile/controllers/profile-chain-name.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
51 changes: 51 additions & 0 deletions src/profile/controllers/profile-chain-name.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
19 changes: 19 additions & 0 deletions src/profile/dto/ae-account-address.validator.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
}
13 changes: 13 additions & 0 deletions src/profile/dto/create-chain-name-challenge.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions src/profile/dto/request-chain-name.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions src/profile/entities/profile-chain-name-challenge.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading