Skip to content

Commit

Permalink
feat: allow users to transfer money to a .stark name (#437) (#449)
Browse files Browse the repository at this point in the history
* feat: transfer to a stark name

* fix: lint errors

* test: update getAddrFromStarkName network

* fix: update to new RPC structure

* chore: lint

* chore: lint

* fix: racing condition in SendModal

* fix: move validate logic out of addressInput & update requests made to snap

* fix: simplify name check in frontend

* fix: resolved addr is validated by superstruct

* chore: add snap permission boundary and update UI text

* chore: update RpcController import location

---------

Co-authored-by: Iris <[email protected]>
  • Loading branch information
stanleyyconsensys and irisdv authored Dec 4, 2024
1 parent 1e80070 commit c9e2c64
Show file tree
Hide file tree
Showing 15 changed files with 344 additions and 11 deletions.
34 changes: 34 additions & 0 deletions packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,40 @@
}
},
"errors": []
},
{
"name": "starkNet_getAddrFromStarkName",
"summary": "Get address from a stark name",
"paramStructure": "by-name",
"params": [
{
"name": "starkName",
"summary": "stark name of the user",
"description": "stark name of the user",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "chainId",
"summary": "Id of the target Starknet network",
"description": "Id of the target Starknet network (default to Starknet Goerli Testnet)",
"required": false,
"schema": {
"$ref": "#/components/schemas/CHAIN_ID"
}
}
],
"result": {
"name": "result",
"summary": "Address of the given stark name",
"description": "Address of the given stark name",
"schema": {
"$ref": "#/components/schemas/ADDRESS"
}
},
"errors": []
}
],
"components": {
Expand Down
7 changes: 7 additions & 0 deletions packages/starknet-snap/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
GetDeploymentDataParams,
DeclareContractParams,
WatchAssetParams,
GetAddrFromStarkNameParams,
GetTransactionStatusParams,
} from './rpcs';
import {
Expand All @@ -49,6 +50,7 @@ import {
switchNetwork,
getDeploymentData,
watchAsset,
getAddrFromStarkName,
getTransactionStatus,
} from './rpcs';
import { signDeployAccountTransaction } from './signDeployAccountTransaction';
Expand Down Expand Up @@ -269,6 +271,11 @@ export const onRpcRequest: OnRpcRequestHandler = async ({
apiParams.requestParams as unknown as GetDeploymentDataParams,
);

case RpcMethod.GetAddressByStarkName:
return await getAddrFromStarkName.execute(
apiParams.requestParams as unknown as GetAddrFromStarkNameParams,
);

default:
throw new MethodNotFoundError() as unknown as Error;
}
Expand Down
58 changes: 58 additions & 0 deletions packages/starknet-snap/src/rpcs/get-addr-from-starkname.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { constants } from 'starknet';

import { InvalidRequestParamsError } from '../utils/exceptions';
import * as starknetUtils from '../utils/starknetUtils';
import {
getAddrFromStarkName,
type GetAddrFromStarkNameParams,
} from './get-addr-from-starkname';

jest.mock('../utils/snap');
jest.mock('../utils/logger');

const prepareMockGetAddrFromStarkName = ({
chainId,
starkName,
}: {
chainId: constants.StarknetChainId;
starkName: string;
}) => {
const request = {
chainId,
starkName,
} as unknown as GetAddrFromStarkNameParams;

const getAddrFromStarkNameSpy = jest.spyOn(
starknetUtils,
'getAddrFromStarkNameUtil',
);
getAddrFromStarkNameSpy.mockResolvedValue(
'0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f',
);

return {
request,
};
};

describe('getAddrFromStarkName', () => {
it('get address from stark name correctly', async () => {
const chainId = constants.StarknetChainId.SN_SEPOLIA;
const { request } = prepareMockGetAddrFromStarkName({
chainId,
starkName: 'testname.stark',
});

const result = await getAddrFromStarkName.execute(request);

expect(result).toBe(
'0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f',
);
});

it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => {
await expect(
getAddrFromStarkName.execute({} as unknown as GetAddrFromStarkNameParams),
).rejects.toThrow(InvalidRequestParamsError);
});
});
87 changes: 87 additions & 0 deletions packages/starknet-snap/src/rpcs/get-addr-from-starkname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Infer } from 'superstruct';
import { assign, object } from 'superstruct';

import { NetworkStateManager } from '../state/network-state-manager';
import type { Network } from '../types/snapState';
import { AddressStruct, BaseRequestStruct, StarkNameStruct } from '../utils';
import { InvalidNetworkError } from '../utils/exceptions';
import { getAddrFromStarkNameUtil } from '../utils/starknetUtils';
import { RpcController } from './abstract/base-rpc-controller';

export const GetAddrFromStarkNameRequestStruct = assign(
object({
starkName: StarkNameStruct,
}),
BaseRequestStruct,
);

export const GetAddrFromStarkNameResponseStruct = AddressStruct;

export type GetAddrFromStarkNameParams = Infer<
typeof GetAddrFromStarkNameRequestStruct
>;

export type GetAddrFromStarkNameResponse = Infer<
typeof GetAddrFromStarkNameResponseStruct
>;

/**
* The RPC handler to get a StarkName by a Starknet address.
*/
export class GetAddrFromStarkNameRpc extends RpcController<
GetAddrFromStarkNameParams,
GetAddrFromStarkNameResponse
> {
protected requestStruct = GetAddrFromStarkNameRequestStruct;

protected responseStruct = GetAddrFromStarkNameResponseStruct;

protected readonly networkStateMgr: NetworkStateManager;

constructor() {
super();
this.networkStateMgr = new NetworkStateManager();
}

/**
* Execute the get address from stark name request handler.
*
* @param params - The parameters of the request.
* @param params.starkName - The stark name of the user.
* @param params.chainId - The chain id of the network.
* @returns A promise that resolves to an address.
* @throws {Error} If the network with the chain id is not supported.
*/
async execute(
params: GetAddrFromStarkNameParams,
): Promise<GetAddrFromStarkNameResponse> {
return super.execute(params);
}

protected async getNetworkFromChainId(chainId: string): Promise<Network> {
const network = await this.networkStateMgr.getNetwork({
chainId,
});

// It should be never happen, as the chainId should be validated by the superstruct
if (!network) {
throw new InvalidNetworkError() as unknown as Error;
}

return network;
}

protected async handleRequest(
params: GetAddrFromStarkNameParams,
): Promise<GetAddrFromStarkNameResponse> {
const { chainId, starkName } = params;

const network = await this.getNetworkFromChainId(chainId);

const address = await getAddrFromStarkNameUtil(network, starkName);

return address;
}
}

export const getAddrFromStarkName = new GetAddrFromStarkNameRpc();
1 change: 1 addition & 0 deletions packages/starknet-snap/src/rpcs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from './verify-signature';
export * from './switch-network';
export * from './get-deployment-data';
export * from './watch-asset';
export * from './get-addr-from-starkname';
export * from './get-transaction-status';
1 change: 1 addition & 0 deletions packages/starknet-snap/src/utils/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('validateOrigin', () => {
RpcMethod.GetTransactions,
RpcMethod.UpgradeAccContract,
RpcMethod.GetStarkName,
RpcMethod.GetAddressByStarkName,
RpcMethod.ReadContract,
RpcMethod.GetStoredErc20Tokens,
];
Expand Down
1 change: 1 addition & 0 deletions packages/starknet-snap/src/utils/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export enum RpcMethod {
GetTransactions = 'starkNet_getTransactions',
UpgradeAccContract = 'starkNet_upgradeAccContract',
GetStarkName = 'starkNet_getStarkName',
GetAddressByStarkName = 'starkNet_getAddrFromStarkName',
ReadContract = 'starkNet_getValue',
GetStoredErc20Tokens = 'starkNet_getStoredErc20Tokens',
}
Expand Down
34 changes: 34 additions & 0 deletions packages/starknet-snap/src/utils/starknetUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,37 @@ describe('getEstimatedFees', () => {
});
});
});

describe('isValidStarkName', () => {
it.each([
{ starkName: 'valid.stark', expected: true },
{ starkName: 'valid-name.stark', expected: true },
{ starkName: 'valid123.stark', expected: true },
{ starkName: 'valid-name123.stark', expected: true },
{ starkName: 'valid.subdomain.stark', expected: true },
{ starkName: '1-valid.stark', expected: true },
{
starkName: 'valid-name-with-many-subdomains.valid.subdomain.stark',
expected: true,
},
{
starkName: 'too-long-stark-domain-name-more-than-48-characters.stark',
expected: false,
},
{ starkName: 'invalid..stark', expected: false },
{ starkName: 'invalid@stark', expected: false },
{ starkName: 'invalid_name.stark', expected: false },
{ starkName: 'invalid space.stark', expected: false },
{ starkName: 'invalid.starknet', expected: false },
{ starkName: '.invalid.stark', expected: false },
{ starkName: 'invalid.', expected: false },
{ starkName: 'invalid.stark.', expected: false },
{ starkName: '', expected: false },
])(
'validates `$starkName` correctly and returns $expected',
({ starkName, expected }) => {
const result = starknetUtils.isValidStarkName(starkName);
expect(result).toBe(expected);
},
);
});
14 changes: 14 additions & 0 deletions packages/starknet-snap/src/utils/starknetUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,3 +1367,17 @@ export const validateAccountRequireUpgradeOrDeploy = async (
throw new DeployRequiredError();
}
};

export const getAddrFromStarkNameUtil = async (
network: Network,
starkName: string,
) => {
const provider = getProvider(network);
return Account.getAddressFromStarkName(provider, starkName);
};

export const isValidStarkName = (starkName: string): boolean => {
return /^(?:[a-z0-9-]{1,48}(?:[a-z0-9-]{1,48}[a-z0-9-])?\.)*[a-z0-9-]{1,48}\.stark$/.test(
starkName,
);
};
12 changes: 12 additions & 0 deletions packages/starknet-snap/src/utils/superstruct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
MAXIMUM_TOKEN_NAME_LENGTH,
MAXIMUM_TOKEN_SYMBOL_LENGTH,
} from './constants';
import { isValidStarkName } from './starknetUtils';
import { isValidAsciiStrField } from './string';

export const TokenNameStruct = refine(
Expand Down Expand Up @@ -188,6 +189,17 @@ export const DeclareSignDetailsStruct = assign(
}),
);

export const StarkNameStruct = refine(
string(),
'StarkNameStruct',
(value: string) => {
if (isValidStarkName(value)) {
return true;
}
return `The given stark name is invalid`;
},
);

/* ------------------------------ Contract Struct ------------------------------ */
/* eslint-disable */
export const SierraContractEntryPointFieldsStruct = object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,11 @@ export const Icon = styled(FontAwesomeIcon).attrs<IIcon>((props) => ({
? props.theme.palette.error.main
: props.theme.palette.success.main,
}))<IIcon>``;

export const InfoText = styled.div`
font-size: ${(props) => props.theme.typography.p2.fontSize};
font-family: ${(props) => props.theme.typography.p2.fontFamily};
color: ${(props) => props.theme.palette.grey.black};
padding-top: ${(props) => props.theme.spacing.tiny};
padding-left: ${(props) => props.theme.spacing.small};
`;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { KeyboardEvent, ChangeEvent } from 'react';
import { KeyboardEvent, ChangeEvent, useEffect } from 'react';
import {
InputHTMLAttributes,
useRef,
Expand All @@ -22,20 +22,30 @@ import { STARKNET_ADDRESS_LENGTH } from 'utils/constants';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
setIsValidAddress?: Dispatch<SetStateAction<boolean>>;
disableValidate?: boolean;
validateError?: string;
}

export const AddressInputView = ({
disabled,
onChange,
label,
setIsValidAddress,
disableValidate,
validateError,
...otherProps
}: Props) => {
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
const [valid, setValid] = useState(false);

useEffect(() => {
if (!disableValidate || !inputRef.current) return;
setValid(inputRef.current.value !== '' && validateError === '');
setError(validateError ?? '');
}, [disableValidate, validateError]);

const displayIcon = () => {
return valid || error !== '';
};
Expand All @@ -54,9 +64,10 @@ export const AddressInputView = ({
//Check if valid address
onChange && onChange(event);

if (!inputRef.current) {
return;
}
if (!inputRef.current) return;

if (disableValidate) return;

const isValid =
inputRef.current.value !== '' && isValidAddress(inputRef.current.value);
if (isValid) {
Expand Down
Loading

0 comments on commit c9e2c64

Please sign in to comment.