Skip to content

Commit dc56218

Browse files
authored
Merge pull request #27 from syscoin/sponsor-wallet-implementation
feat(sponsor-wallet): add implementation
2 parents e9913ef + cdfbc7a commit dc56218

File tree

18 files changed

+2070
-79
lines changed

18 files changed

+2070
-79
lines changed

.github/workflows/aws.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
push:
77
branches:
88
- main
9-
- mongodb-migrate
9+
- sponsor-wallet-implementation
1010

1111
env:
1212
AWS_REGION: us-east-1 # set this to your preferred AWS region, e.g. us-west-1
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it, jest } from "@jest/globals";
2+
const SponsorWalletMock = jest.mock("models/sponsor-wallet");
3+
// Mock util/get-web3
4+
5+
const web3Mock = {
6+
eth: {
7+
accounts: {
8+
privateKeyToAccount: jest.fn(),
9+
},
10+
getBalance: jest.fn(),
11+
sendSignedTransaction: jest.fn(),
12+
},
13+
utils: {
14+
fromWei: jest.fn(),
15+
},
16+
};
17+
jest.mock("utils/get-web3", () => {
18+
return jest.fn(() => web3Mock);
19+
});
20+
import SponsorWalletService from "../sponsor-wallet";
21+
22+
describe("SponsorWalletService", () => {
23+
describe("createSponsorWallet", () => {
24+
it("should throw an error if privateKey provided is invalid", async () => {
25+
const service = new SponsorWalletService();
26+
27+
await expect(
28+
service.createSponsorWallet("invalidPrivateKey")
29+
).rejects.toThrow("Private key must be 32 bytes long");
30+
});
31+
32+
/**
33+
* To Do Test:
34+
* 1. It should throw error if address balance is less than 0.01
35+
* 2. It should create a new wallet if address balance is greater than 0.01
36+
*/
37+
});
38+
39+
describe("sponsorTransaction", () => {
40+
/**
41+
* To Do Test:
42+
* 1. It should throw error if no wallet available
43+
* 2. When there is a wallet available
44+
* 2.1 It should throw error if transaction config is invalid
45+
* 2.2 It should throw return tx hash from the receipt if transaction config is valid
46+
*/
47+
// Jest Mock Mongoose Model SponsorWallet
48+
});
49+
});

api/services/sponsor-wallet.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import SponsorWallet, { ISponsorWallet } from "models/sponsor-wallet";
2+
import SponsorWalletTransactions, {
3+
ISponsorWalletTransaction,
4+
SponsorWalletTransactionStatus,
5+
} from "models/sponsor-wallet-transactions";
6+
import web3 from "utils/get-web3";
7+
import { TransactionConfig } from "web3-core";
8+
9+
export class SponsorWalletService {
10+
public async createSponsorWallet(privateKey: string) {
11+
const account = web3.eth.accounts.privateKeyToAccount(privateKey);
12+
13+
const balanceInWei = await web3.eth.getBalance(account.address);
14+
15+
const balanceEth = web3.utils.fromWei(balanceInWei, "ether");
16+
17+
if (Number(balanceEth) < 0.01) {
18+
throw new Error(`${account.address} Balance is less than 0.01`);
19+
}
20+
21+
const wallet = new SponsorWallet({
22+
address: account.address,
23+
privateKey,
24+
});
25+
await wallet.save();
26+
return wallet.toObject();
27+
}
28+
29+
public async sponsorTransaction(
30+
transferId: string,
31+
transactionConfig: Omit<TransactionConfig, "nonce">
32+
): Promise<ISponsorWalletTransaction> {
33+
const wallet = await this.getAvailableWalletForSigning();
34+
35+
if (!wallet) {
36+
throw new Error("No Sponsor Wallet available");
37+
}
38+
39+
const existingTransaction = await SponsorWalletTransactions.findOne({
40+
transferId: transferId,
41+
});
42+
43+
if (existingTransaction) {
44+
return existingTransaction;
45+
}
46+
47+
const nonce = await this.getWalletNextNonce(wallet.id);
48+
49+
const sender = web3.eth.accounts.privateKeyToAccount(wallet.privateKey);
50+
51+
const signedTransaction = await sender.signTransaction({
52+
...transactionConfig,
53+
nonce,
54+
});
55+
56+
if (
57+
signedTransaction.rawTransaction === undefined ||
58+
signedTransaction.transactionHash === undefined
59+
) {
60+
throw new Error("Raw transaction is undefined");
61+
}
62+
63+
let walletTransaction = new SponsorWalletTransactions({
64+
transferId: transferId,
65+
walletId: wallet.id,
66+
transactionHash: signedTransaction.transactionHash,
67+
status: "pending",
68+
});
69+
70+
walletTransaction = await walletTransaction.save();
71+
72+
let receipt = await web3.eth
73+
.getTransactionReceipt(signedTransaction.transactionHash)
74+
.catch(() => undefined);
75+
let status: SponsorWalletTransactionStatus = "success";
76+
77+
if (!receipt) {
78+
receipt = await web3.eth.sendSignedTransaction(
79+
signedTransaction.rawTransaction
80+
);
81+
status = "pending";
82+
}
83+
84+
walletTransaction.status = status;
85+
86+
return walletTransaction.save();
87+
}
88+
89+
public async updateSponsorWalletTransactionStatus(transactionHash: string) {
90+
const transaction = await SponsorWalletTransactions.findOne({
91+
transactionHash,
92+
});
93+
94+
if (!transaction || transaction.status !== "pending") {
95+
return;
96+
}
97+
98+
const receipt = await web3.eth.getTransactionReceipt(transactionHash);
99+
if (!receipt?.blockNumber) {
100+
return;
101+
}
102+
103+
transaction.status = receipt.status ? "success" : "failed";
104+
await transaction.save();
105+
}
106+
107+
private async getWalletNextNonce(walletId: string): Promise<number> {
108+
const wallet = await SponsorWallet.findOne({ id: walletId });
109+
if (!wallet) {
110+
throw new Error("Wallet not found");
111+
}
112+
const lastTransaction = await SponsorWalletTransactions.findOne({
113+
walletId: wallet?.id,
114+
}).sort({ createdAt: -1 });
115+
116+
if (!lastTransaction) {
117+
const nonce = await web3.eth.getTransactionCount(
118+
wallet.address,
119+
"pending"
120+
);
121+
return nonce;
122+
}
123+
124+
const transaction = await web3.eth.getTransaction(
125+
lastTransaction.transactionHash
126+
);
127+
128+
return transaction.nonce + 1;
129+
}
130+
131+
private async getAvailableWalletForSigning(): Promise<ISponsorWallet> {
132+
const walletWithNoPendingTransactions =
133+
await this.getWalletWithNoPendingTransactions();
134+
135+
if (walletWithNoPendingTransactions.length > 0) {
136+
return walletWithNoPendingTransactions[0];
137+
}
138+
139+
const walletWithLeastPendingTransactions =
140+
await this.getWalletWithLeastPendingTransactions();
141+
142+
return walletWithLeastPendingTransactions;
143+
}
144+
145+
private getWalletWithNoPendingTransactions(): Promise<ISponsorWallet[]> {
146+
return SponsorWallet.aggregate([
147+
{
148+
$lookup: {
149+
from: "sponsorwallettransactions",
150+
localField: "_id",
151+
foreignField: "walletId",
152+
as: "transactions",
153+
},
154+
},
155+
{
156+
$match: {
157+
"transactions.status": {
158+
$ne: "pending",
159+
},
160+
},
161+
},
162+
]);
163+
}
164+
165+
private getWalletWithLeastPendingTransactions(): Promise<ISponsorWallet> {
166+
return SponsorWallet.aggregate([
167+
{
168+
$lookup: {
169+
from: "sponsorwallettransactions",
170+
localField: "_id",
171+
foreignField: "walletId",
172+
as: "transactions",
173+
},
174+
},
175+
{
176+
$match: {
177+
"transactions.status": "pending",
178+
},
179+
},
180+
{
181+
$project: {
182+
id: "$_id",
183+
address: 1,
184+
privateKey: 1,
185+
pendingTransactionCount: {
186+
$size: "$transactions",
187+
},
188+
},
189+
},
190+
{
191+
$sort: {
192+
pendingTransactionCount: 1,
193+
},
194+
},
195+
{
196+
$limit: 1,
197+
},
198+
]).then((wallets) => wallets[0]);
199+
}
200+
}
201+
202+
export default SponsorWalletService;

api/services/transfer.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { ITransfer } from "@contexts/Transfer/types";
22
import { UserCredential, signInWithEmailAndPassword } from "firebase/auth";
33
import firebase from "firebase-setup";
44
import { doc, getDoc } from "firebase/firestore";
5-
import dbConnect from "lib/mongodb";
65
import TransferModel from "models/transfer";
6+
import { SponsorWalletService } from "./sponsor-wallet";
77

88
export class TransferService {
99
private isAuthenticated = false;
10+
private sponsorWalletService = new SponsorWalletService();
1011
constructor() {
1112
this.authenticate();
1213
}
@@ -68,6 +69,17 @@ export class TransferService {
6869
}
6970

7071
async upsertTransfer(transfer: ITransfer): Promise<ITransfer> {
72+
const submitProofsTxLog = transfer.logs.find(
73+
(log) => log.status === "submit-proofs"
74+
);
75+
if (submitProofsTxLog) {
76+
const { hash } = submitProofsTxLog.payload.data;
77+
if (hash) {
78+
await this.sponsorWalletService.updateSponsorWalletTransactionStatus(
79+
hash
80+
);
81+
}
82+
}
7183
const results = await TransferModel.updateOne(
7284
{ id: transfer.id },
7385
{ ...transfer, updatedAt: Date.now() },

components/Bridge/v3/Steps/SubmitProofs.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ const SubmitProofs: React.FC<Props> = ({ successStatus }) => {
4545
const foundation = useSyscoinSubmitProofs(transfer, onSuccess);
4646
const self = useSubmitProof(transfer, spvProof);
4747

48+
const foundationFundingAvailable = isEnabled("foundationFundingAvailable");
49+
4850
const {
4951
mutate: submitProofs,
5052
isLoading: isSigning,
5153
isError: isSignError,
5254
error: signError,
53-
} = isEnabled("foundationFundingAvailable") ? foundation : self;
55+
} = foundationFundingAvailable ? foundation : self;
5456

5557
const sign = () => {
5658
submitProofs(undefined, { onSuccess });
@@ -65,7 +67,13 @@ const SubmitProofs: React.FC<Props> = ({ successStatus }) => {
6567
}
6668

6769
if (isSigning) {
68-
return <Alert severity="info">Check NEVM Wallet for signing</Alert>;
70+
return (
71+
<Alert severity="info">
72+
{foundationFundingAvailable
73+
? "Submitting proofs..."
74+
: "Check NEVM Wallet for signing"}
75+
</Alert>
76+
);
6977
}
7078

7179
if (isSignError) {

components/Bridge/v3/hooks/useFeatureFlags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useQuery } from "react-query";
22

33
type FeatureFlags = {
44
foundationFundingAvailable: boolean;
5+
adminEnabled: boolean;
56
};
67

78
export const useFeatureFlags = () => {

components/Bridge/v3/hooks/useSyscoinSubmitProofs.tsx

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ITransfer } from "@contexts/Transfer/types";
22
import { useMutation } from "react-query";
33
import { useWeb3 } from "../context/Web";
4+
import { ISponsorWalletTransaction } from "models/sponsor-wallet-transactions";
45

56
const useSyscoinSubmitProofs = (
67
transfer: ITransfer,
@@ -10,7 +11,7 @@ const useSyscoinSubmitProofs = (
1011
return useMutation(
1112
["syscoin-submit-proofs", transfer.id],
1213
async () => {
13-
const submitProofsResponse: { signedTx: string } = await fetch(
14+
const submitProofsResponse: ISponsorWalletTransaction = await fetch(
1415
`/api/transfer/${transfer.id}/signed-submit-proofs-tx`
1516
).then((res) => {
1617
if (res.ok) {
@@ -19,32 +20,11 @@ const useSyscoinSubmitProofs = (
1920
return res.json().then(({ message }) => Promise.reject(message));
2021
});
2122

22-
const { signedTx } = submitProofsResponse;
23-
24-
return new Promise((resolve, reject) => {
25-
web3.eth
26-
.sendSignedTransaction(signedTx)
27-
.once("transactionHash", (hash: string | { success: false }) => {
28-
if (typeof hash !== "string" && !hash.success) {
29-
reject("Failed to submit proofs. Check browser logs");
30-
console.error("Submission failed", hash);
31-
} else {
32-
resolve(hash);
33-
}
34-
})
35-
.on("error", (error: { message: string }) => {
36-
if (/might still be mined/.test(error.message)) {
37-
resolve("");
38-
} else {
39-
console.error(error);
40-
reject(error.message);
41-
}
42-
});
43-
});
23+
return submitProofsResponse.transactionHash;
4424
},
4525
{
4626
onSuccess: (data) => {
47-
onSuccess(data as string);
27+
onSuccess(data);
4828
},
4929
}
5030
);

jest.config.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const { pathsToModuleNameMapper } = require('ts-jest')
2+
// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file
3+
// which contains the path mapping (ie the `compilerOptions.paths` option):
4+
const { compilerOptions } = require('./tsconfig.json')
5+
6+
/** @type {import('ts-jest').JestConfigWithTsJest} */
7+
module.exports = {
8+
preset: "ts-jest",
9+
testEnvironment: "node",
10+
roots: ['<rootDir>'],
11+
modulePaths: [compilerOptions.baseUrl], // <-- This will be set to 'baseUrl' value
12+
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths /*, { prefix: '<rootDir>/' } */),
13+
}

0 commit comments

Comments
 (0)