Skip to content

Commit

Permalink
feat(xcm-api): Add support for Snowbridge ❄️
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeldev5 authored and dudo50 committed Aug 11, 2024
1 parent 8c0b76d commit 6866142
Show file tree
Hide file tree
Showing 11 changed files with 379 additions and 13 deletions.
1 change: 1 addition & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@polkadot/extension-inject": "^0.49.3",
"@polkadot/keyring": "^13.0.2",
"@polkadot/util": "^13.0.2",
"@snowbridge/contract-types": "^0.1.18",
"@tabler/icons-react": "^3.11.0",
"axios": "^1.7.2",
"ethers": "^6.13.2",
Expand Down
89 changes: 86 additions & 3 deletions apps/playground/src/components/eth-bridge/EthBridgeTransfer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { Stack, Title, Box, Button } from "@mantine/core";
import { useDisclosure, useScrollIntoView } from "@mantine/hooks";
import { useState, useEffect } from "react";
import { BrowserProvider, ethers } from "ethers";
import { BrowserProvider, ethers, LogDescription } from "ethers";
import ErrorAlert from "../ErrorAlert";
import EthBridgeTransferForm, {
FormValues,
FormValuesTransformed,
} from "./EthBridgeTransferForm";
import { EvmBuilder } from "@paraspell/sdk";
import { fetchFromApi } from "../../utils/submitUsingApi";
import { IGateway__factory } from "@snowbridge/contract-types";
import { MultiAddressStruct } from "@snowbridge/contract-types/dist/IGateway";

interface ApiResponse {
token: string;
destinationParaId: number;
destinationFee: string;
amount: string;
fee: string;
}

const EthBridgeTransfer = () => {
const [selectedAccount, setSelectedAccount] = useState<string | null>(null);
Expand Down Expand Up @@ -76,7 +87,7 @@ const EthBridgeTransfer = () => {
}
}, [error, scrollIntoView]);

const submitEthTransaction = async ({
const submitEthTransactionSdk = async ({
to,
amount,
currency,
Expand All @@ -101,6 +112,74 @@ const EthBridgeTransfer = () => {
.build();
};

const submitEthTransactionApi = async (formValues: FormValuesTransformed) => {
if (!provider) {
throw new Error("Provider not initialized");
}

const signer = await provider.getSigner();

if (!signer) {
throw new Error("Signer not initialized");
}

const apiResonse = (await fetchFromApi(
{
...formValues,
destAddress: formValues.address,
address: await signer.getAddress(),
currency: formValues.currency?.symbol,
},
"/x-transfer-eth",
"POST",
true
)) as ApiResponse;

const GATEWAY_CONTRACT = "0xEDa338E4dC46038493b885327842fD3E301CaB39";

const contract = IGateway__factory.connect(GATEWAY_CONTRACT, signer);

const abi = ethers.AbiCoder.defaultAbiCoder();

const address: MultiAddressStruct = {
data: abi.encode(["bytes32"], [formValues.address]),
kind: 1,
};

const response = await contract.sendToken(
apiResonse.token,
apiResonse.destinationParaId,
address,
apiResonse.destinationFee,
apiResonse.amount,
{
value: apiResonse.fee,
}
);
const receipt = await response.wait(1);

if (receipt === null) {
throw new Error("Error waiting for transaction completion");
}

if (receipt?.status !== 1) {
throw new Error("Transaction failed");
}

const events: LogDescription[] = [];
receipt.logs.forEach((log) => {
const event = contract.interface.parseLog({
topics: [...log.topics],
data: log.data,
});
if (event !== null) {
events.push(event);
}
});

return true;
};

const submit = async (formValues: FormValues) => {
if (!selectedAccount) {
alert("No account selected, connect wallet first");
Expand All @@ -110,7 +189,11 @@ const EthBridgeTransfer = () => {
setLoading(true);

try {
await submitEthTransaction(formValues);
if (formValues.useApi) {
await submitEthTransactionApi(formValues);
} else {
await submitEthTransactionSdk(formValues);
}
alert("Transaction was successful!");
} catch (e) {
if (e instanceof Error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useForm } from "@mantine/form";
import { FC } from "react";
import { Button, Select, Stack, TextInput } from "@mantine/core";
import { Button, Checkbox, Select, Stack, TextInput } from "@mantine/core";
import {
NODES_WITH_RELAY_CHAINS,
TAsset,
Expand All @@ -14,6 +14,7 @@ export type FormValues = {
currencyOptionId: string;
address: string;
amount: string;
useApi: boolean;
};

export type FormValuesTransformed = FormValues & {
Expand All @@ -32,6 +33,7 @@ const EthBridgeTransferForm: FC<Props> = ({ onSubmit, loading }) => {
currencyOptionId: "",
amount: "1000000000",
address: "5F5586mfsnM6durWRLptYt3jSUs55KEmahdodQ5tQMr9iY96",
useApi: false,
},

validate: {
Expand Down Expand Up @@ -102,6 +104,8 @@ const EthBridgeTransferForm: FC<Props> = ({ onSubmit, loading }) => {
{...form.getInputProps("amount")}
/>

<Checkbox label="Use XCM API" {...form.getInputProps("useApi")} />

<Button type="submit" loading={loading}>
Submit transaction
</Button>
Expand Down
2 changes: 2 additions & 0 deletions apps/xcm-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@
"@polkadot/types": "^12.2.2",
"@polkadot/util": "^13.0.2",
"@sentry/node": "^7.73.0",
"@snowbridge/api": "^0.1.17",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ethers": "^6.13.2",
"express": "^4.19.2",
"googleapis": "^140.0.1",
"mixpanel": "^0.18.0",
Expand Down
1 change: 1 addition & 0 deletions apps/xcm-api/src/analytics/EventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum EventName {
GET_DEFAULT_PALLET = 'Get Default Pallet',
GET_SUPPORTED_PALLETS = 'Get Supported Pallets',
GENERATE_XCM_CALL = 'Generate XCM Call',
GENERATE_ETH_CALL = 'Generate ETH Call',
GENERATE_API_KEY = 'Generate API Key',
GENERATE_ROUTER_EXTRINSICS = 'Generate router extrinsics',
CLAIM_ASSETS = 'Claim assets',
Expand Down
2 changes: 2 additions & 0 deletions apps/xcm-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import path from 'path';
import { AssetClaimModule } from './asset-claim/asset-claim.module.js';
import { TransferInfoModule } from './transfer-info/transfer-info.module.js';
import { XcmAnalyserModule } from './xcm-analyser/xcm-analyser.module.js';
import { XTransferEthModule } from './x-transfer-eth/x-transfer-eth.module.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -32,6 +33,7 @@ const __dirname = path.dirname(__filename);
imports: [
AnalyticsModule,
XTransferModule,
XTransferEthModule,
AssetClaimModule,
TransferInfoModule,
XcmAnalyserModule,
Expand Down
24 changes: 24 additions & 0 deletions apps/xcm-api/src/x-transfer-eth/dto/x-transfer-eth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

export const XTransferEthDtoSchema = z.object({
to: z.string(),
amount: z.union([
z.string().refine(
(val) => {
const num = parseFloat(val);
return !isNaN(num) && num > 0;
},
{
message: 'Amount must be a positive number',
},
),
z.number().positive({ message: 'Amount must be a positive number' }),
]),
address: z.string().min(1, { message: 'Source address is required' }),
destAddress: z
.string()
.min(1, { message: 'Destination address is required' }),
currency: z.string(),
});

export type XTransferEthDto = z.infer<typeof XTransferEthDtoSchema>;
37 changes: 37 additions & 0 deletions apps/xcm-api/src/x-transfer-eth/x-transfer-eth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Body, Controller, Post, Req, UsePipes } from '@nestjs/common';
import { AnalyticsService } from '../analytics/analytics.service.js';
import { XTransferEthService } from './x-transfer-eth.service.js';
import { EventName } from '../analytics/EventName.js';
import { ZodValidationPipe } from '../zod-validation-pipe.js';
import {
XTransferEthDtoSchema,
XTransferEthDto,
} from './dto/x-transfer-eth.dto.js';

@Controller('x-transfer-eth')
export class XTransferEthController {
constructor(
private xTransferEthService: XTransferEthService,
private analyticsService: AnalyticsService,
) {}

private trackAnalytics(
eventName: EventName,
req: Request,
params: XTransferEthDto,
) {
const { to, address, currency } = params;
this.analyticsService.track(eventName, req, {
to,
address,
currency,
});
}

@Post()
@UsePipes(new ZodValidationPipe(XTransferEthDtoSchema))
generateXcmCall(@Body() bodyParams: XTransferEthDto, @Req() req: Request) {
this.trackAnalytics(EventName.GENERATE_ETH_CALL, req, bodyParams);
return this.xTransferEthService.generateEthCall(bodyParams);
}
}
9 changes: 9 additions & 0 deletions apps/xcm-api/src/x-transfer-eth/x-transfer-eth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { XTransferEthController } from './x-transfer-eth.controller.js';
import { XTransferEthService } from './x-transfer-eth.service.js';

@Module({
controllers: [XTransferEthController],
providers: [XTransferEthService],
})
export class XTransferEthModule {}
103 changes: 103 additions & 0 deletions apps/xcm-api/src/x-transfer-eth/x-transfer-eth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import {
getOtherAssets,
getParaId,
InvalidCurrencyError,
NODE_NAMES,
TNode,
} from '@paraspell/sdk';
import { isValidPolkadotAddress } from '../utils.js';
import { XTransferEthDto } from './dto/x-transfer-eth.dto.js';
import { toPolkadot, environment, contextFactory } from '@snowbridge/api';

@Injectable()
export class XTransferEthService {
async generateEthCall({
to,
amount,
address,
destAddress,
currency,
}: XTransferEthDto) {
const toNode = to as TNode;

if (!NODE_NAMES.includes(toNode)) {
throw new BadRequestException(
`Node ${toNode} is not valid. Check docs for valid nodes.`,
);
}

if (!isValidPolkadotAddress(destAddress)) {
throw new BadRequestException('Invalid wallet address.');
}

const ethAssets = getOtherAssets('Ethereum');
const ethAsset = ethAssets.find((asset) => asset.symbol === currency);
if (!ethAsset) {
throw new InvalidCurrencyError(
`Currency ${currency} is not supported for Ethereum transfers`,
);
}

const env = environment.SNOWBRIDGE_ENV['polkadot_mainnet'];
const { config } = env;

const EXECUTION_URL = 'https://eth.llamarpc.com';

const context = await contextFactory({
ethereum: {
execution_url: EXECUTION_URL,
beacon_url: config.BEACON_HTTP_API,
},
polkadot: {
url: {
bridgeHub: config.BRIDGE_HUB_URL,
assetHub: config.ASSET_HUB_URL,
relaychain: config.RELAY_CHAIN_URL,
parachains: config.PARACHAINS,
},
},
appContracts: {
gateway: config.GATEWAY_CONTRACT,
beefy: config.BEEFY_CONTRACT,
},
});
const destParaId = getParaId(toNode);

const signer = {
getAddress: () => Promise.resolve(address),
};

try {
const plan = await toPolkadot.validateSend(
context,
signer,
destAddress,
ethAsset.assetId,
destParaId,
BigInt(amount),
BigInt(0),
);

if (plan.failure) {
throw new Error(
`Failed to validate send: ${plan.failure.errors.map((e) => e.message).join('\n\n')}`,
);
}

return {
token: plan.success.token,
destinationParaId: plan.success.destinationParaId,
destinationFee: plan.success.destinationFee,
amount: plan.success.amount,
};
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
throw new InternalServerErrorException(e.message);
}
}
}
Loading

0 comments on commit 6866142

Please sign in to comment.