Skip to content

Commit f9d98ab

Browse files
committed
feat(sdk-core): add GoStakingWallet
- initial support for staking out of BitGo go accounts - stake and unstake requests - sign - get requests SC-1389 TICKET: SC-1389
1 parent b2a5fea commit f9d98ab

File tree

7 files changed

+378
-2
lines changed

7 files changed

+378
-2
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { GoStakingRequest, UnsignedGoStakingRequest } from '@bitgo/sdk-core';
2+
3+
export default {
4+
previewGoStakingRequest: function (coin: string): UnsignedGoStakingRequest {
5+
return {
6+
payload:
7+
'{"coin":"ofctsol","recipients":[{"address":"ANTqf3wcfUqdPWcn1YsYF5X4BBsC1E4gVKKJW7QaRYGh","amount":"1000000"}],"fromAccount":"6733daae98a5c3f5a565a719e328c2a7","nonce":"2cc231b3-693c-497d-a2fa-8d43f3c9f219","timestamp":"2025-03-04T14:41:46.671Z","feeString":"0","shortCircuitBlockchainTransfer":false,"isIntraJXTransfer":false}',
8+
feeInfo: {
9+
feeString: '0',
10+
},
11+
coin: 'ofc',
12+
token: coin,
13+
};
14+
},
15+
finalizeGoStakingRequest: function (coin: string, type: 'STAKE' | 'UNSTAKE'): GoStakingRequest {
16+
return {
17+
id: 'string',
18+
amount: '1',
19+
type: type,
20+
coin: coin,
21+
status: 'NEW',
22+
goSpecificStatus: 'NEW',
23+
statusModifiedDate: '2025-01-03T22:04:29.264Z',
24+
createdDate: '2025-01-03T22:04:29.264Z',
25+
};
26+
},
27+
};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import * as nock from 'nock';
2+
import * as should from 'should';
3+
import fixtures from '../../fixtures/staking/goStakingWallet';
4+
5+
import { Enterprise, Environments, GoStakingWallet, Wallet } from '@bitgo/sdk-core';
6+
import { TestBitGo } from '@bitgo/sdk-test';
7+
import { BitGo } from '../../../../src';
8+
import * as sinon from 'sinon';
9+
import { OfcToken } from '../../../../src/v2/coins';
10+
import { tokens } from '@bitgo/statics';
11+
12+
describe('Go Staking Wallet Common', function () {
13+
const microservicesUri = Environments['mock'].uri;
14+
let bitgo;
15+
let baseCoin;
16+
let enterprise;
17+
let stakingWallet: GoStakingWallet;
18+
const coin = 'tsol';
19+
const ofcCoin = `ofc${coin}`;
20+
21+
before(async function () {
22+
bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri } as any);
23+
bitgo.initializeTestVars();
24+
baseCoin = bitgo.coin(ofcCoin);
25+
baseCoin.keychains();
26+
const ofcToken = tokens.testnet.ofc.tokens.filter((token) => token.type === `ofc${coin}`)[0];
27+
const tokenConstructor = OfcToken.createTokenConstructor(ofcToken);
28+
bitgo.register(ofcToken.type, tokenConstructor);
29+
30+
enterprise = new Enterprise(bitgo, baseCoin, { id: '5cf940949449412d00f53b3d92dbcaa3', name: 'Test Enterprise' });
31+
const walletData = {
32+
id: 'walletId',
33+
coin: ofcCoin,
34+
enterprise: enterprise.id,
35+
keys: ['5b3424f91bf349930e340175', '5b3424f91bf349930e340174', '5b3424f91bf349930e340173'],
36+
};
37+
const wallet = new Wallet(bitgo, baseCoin, walletData);
38+
stakingWallet = wallet.toGoStakingWallet();
39+
nock(microservicesUri)
40+
.get(`/api/v2/${ofcCoin}/key/${stakingWallet.wallet.keyIds()[0]}`)
41+
.reply(200, {
42+
id: stakingWallet.wallet.keyIds()[0],
43+
pub: 'xpub661MyMwAqRbcFq65dvGMeEVb81KKDRRkWkawSVesWcyevGc5gr8V27LjNfkktaMuKtM362jhgKy2eu35RdArcmmEAoULzAvgKkJpWQPvLXM',
44+
source: 'user',
45+
encryptedPrv: bitgo.encrypt({
46+
input:
47+
'xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76',
48+
password: 'passphrase',
49+
}),
50+
coinSpecific: {},
51+
});
52+
53+
nock(microservicesUri)
54+
.get(`/api/v2/${ofcCoin}/key/${stakingWallet.wallet.keyIds()[1]}`)
55+
.reply(200, {
56+
id: stakingWallet.wallet.keyIds()[1],
57+
pub: 'xpub661MyMwAqRbcFq65dvGMeEVb81KKDRRkWkawSVesWcyevGc5gr8V27LjNfkktaMuKtM362jhgKy2eu35RdArcmmEAoULzAvgKkJpWQPvLXM',
58+
source: 'user',
59+
encryptedPrv: bitgo.encrypt({
60+
input:
61+
'xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76',
62+
password: 'passphrase',
63+
}),
64+
coinSpecific: {},
65+
});
66+
67+
nock(microservicesUri)
68+
.get(`/api/v2/${ofcCoin}/key/${stakingWallet.wallet.keyIds()[2]}`)
69+
.reply(200, {
70+
id: stakingWallet.wallet.keyIds()[2],
71+
pub: 'xpub661MyMwAqRbcFq65dvGMeEVb81KKDRRkWkawSVesWcyevGc5gr8V27LjNfkktaMuKtM362jhgKy2eu35RdArcmmEAoULzAvgKkJpWQPvLXM',
72+
source: 'user',
73+
encryptedPrv: bitgo.encrypt({
74+
input:
75+
'xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76',
76+
password: 'passphrase',
77+
}),
78+
coinSpecific: {},
79+
});
80+
});
81+
82+
const sandbox = sinon.createSandbox();
83+
84+
afterEach(function () {
85+
sandbox.verifyAndRestore();
86+
});
87+
88+
describe('stake', function () {
89+
it('should call go-staking-service to stake', async function () {
90+
const preview = fixtures.previewGoStakingRequest(coin);
91+
const msScope1 = nock(microservicesUri)
92+
.post(`/api/go-staking/v1/${ofcCoin}/accounts/${stakingWallet.accountId}/requests/preview`, {
93+
amount: '1',
94+
clientId: 'clientId',
95+
type: 'STAKE',
96+
})
97+
.reply(201, preview);
98+
99+
const expected = fixtures.finalizeGoStakingRequest(coin, 'STAKE');
100+
const msScope2 = nock(microservicesUri)
101+
.post(`/api/go-staking/v1/${ofcCoin}/accounts/${stakingWallet.accountId}/requests/finalize`, {
102+
amount: '1',
103+
clientId: 'clientId',
104+
frontTransferSendRequest: {
105+
halfSigned: {
106+
payload: preview.payload,
107+
},
108+
},
109+
type: 'STAKE',
110+
})
111+
.reply(201, expected);
112+
113+
const stakingRequest = await stakingWallet.stake({
114+
amount: '1',
115+
clientId: 'clientId',
116+
walletPassphrase: 'passphrase',
117+
});
118+
119+
should.exist(stakingRequest);
120+
121+
stakingRequest.should.deepEqual(expected);
122+
msScope1.isDone().should.be.True();
123+
msScope2.isDone().should.be.True();
124+
});
125+
});
126+
127+
describe('unstake', function () {
128+
it('should call go-staking-service to unstake', async function () {
129+
const expected = fixtures.finalizeGoStakingRequest(coin, 'UNSTAKE');
130+
const msScope = nock(microservicesUri)
131+
.post(`/api/go-staking/v1/${ofcCoin}/accounts/${stakingWallet.accountId}/requests/finalize`, {
132+
amount: '1',
133+
clientId: 'clientId',
134+
type: 'UNSTAKE',
135+
})
136+
.reply(201, expected);
137+
138+
const stakingRequest = await stakingWallet.unstake({
139+
amount: '1',
140+
clientId: 'clientId',
141+
});
142+
143+
should.exist(stakingRequest);
144+
145+
stakingRequest.should.deepEqual(expected);
146+
msScope.isDone().should.be.True();
147+
});
148+
});
149+
150+
describe('getGoStakingRequest', function () {
151+
it('should call gostaking-service to get go staking request', async function () {
152+
const stakingRequestId = '8638284a-dab2-46b9-b07f-21109a6e7220';
153+
const expected = fixtures.finalizeGoStakingRequest(coin, 'STAKE');
154+
const msScope = nock(microservicesUri)
155+
.get(`/api/go-staking/v1/${ofcCoin}/accounts/${stakingWallet.accountId}/requests/${stakingRequestId}`)
156+
.reply(200, expected);
157+
158+
const stakingRequest = await stakingWallet.getGoStakingRequest(stakingRequestId);
159+
160+
should.exist(stakingRequest);
161+
162+
stakingRequest.should.deepEqual(expected);
163+
msScope.isDone().should.be.True();
164+
});
165+
});
166+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @prettier
3+
*/
4+
import { BitGoBase } from '../bitgoBase';
5+
import { IWallet, PrebuildTransactionResult } from '../wallet';
6+
import {
7+
BaseGoStakeOptions,
8+
FrontTransferSendRequest,
9+
GoStakeFinalizeOptions,
10+
GoStakeOptions,
11+
GoStakingRequest,
12+
IGoStakingWallet,
13+
UnsignedGoStakingRequest,
14+
} from './iGoStakingWallet';
15+
import assert from 'assert';
16+
17+
export class GoStakingWallet implements IGoStakingWallet {
18+
private readonly bitgo: BitGoBase;
19+
20+
public wallet: IWallet;
21+
22+
constructor(wallet: IWallet) {
23+
this.wallet = wallet;
24+
this.bitgo = wallet.bitgo;
25+
}
26+
27+
get accountId(): string {
28+
return this.wallet.id();
29+
}
30+
31+
async stake(options: GoStakeOptions): Promise<GoStakingRequest> {
32+
// call preview
33+
const preview = await this.previewStake({
34+
amount: options.amount,
35+
clientId: options.clientId,
36+
} as BaseGoStakeOptions);
37+
38+
// sign the transaction
39+
const halfSignedTransaction = (await this.wallet.prebuildAndSignTransaction({
40+
walletPassphrase: options.walletPassphrase,
41+
prebuildTx: {
42+
payload: preview.payload,
43+
} as PrebuildTransactionResult,
44+
})) as FrontTransferSendRequest;
45+
46+
// call finalize to submit the go staking request to go staking service
47+
assert(halfSignedTransaction.halfSigned?.payload, 'missing payload in half signed transaction');
48+
const finalOptions: GoStakeFinalizeOptions = {
49+
amount: options.amount,
50+
clientId: options.clientId,
51+
frontTransferSendRequest: {
52+
halfSigned: {
53+
payload: halfSignedTransaction.halfSigned.payload,
54+
},
55+
},
56+
};
57+
return (await this.finalizeStake(finalOptions)) as GoStakingRequest;
58+
}
59+
60+
/**
61+
* Unstake request
62+
* @param options
63+
*/
64+
async unstake(options: BaseGoStakeOptions): Promise<GoStakingRequest> {
65+
return (await this.createGoStakingRequest(options, 'finalize', 'UNSTAKE')) as GoStakingRequest;
66+
}
67+
68+
/**
69+
* Preview staking request
70+
* @param options
71+
*/
72+
private async previewStake(options: BaseGoStakeOptions): Promise<UnsignedGoStakingRequest> {
73+
return (await this.createGoStakingRequest(options, 'preview', 'STAKE')) as UnsignedGoStakingRequest;
74+
}
75+
76+
/**
77+
* Finalize staking request
78+
* will prepare the payload and sign the transaction
79+
* and submit it to the go-staking-service
80+
* @param options
81+
*/
82+
private async finalizeStake(options: GoStakeFinalizeOptions): Promise<GoStakingRequest> {
83+
return (await this.createGoStakingRequest(options, 'finalize', 'STAKE')) as GoStakingRequest;
84+
}
85+
86+
/**
87+
* Get go staking request
88+
* @param goStakingRequestId
89+
*/
90+
async getGoStakingRequest(goStakingRequestId: string): Promise<GoStakingRequest> {
91+
return await this.bitgo.get(this.bitgo.microservicesUrl(this.getGoStakingRequestURL(goStakingRequestId))).result();
92+
}
93+
94+
private async createGoStakingRequest(
95+
options: BaseGoStakeOptions | GoStakeFinalizeOptions,
96+
path: 'preview' | 'finalize',
97+
type: string
98+
): Promise<GoStakingRequest | UnsignedGoStakingRequest> {
99+
return await this.bitgo
100+
.post(this.bitgo.microservicesUrl(`${this.goStakingRequestBaseURL()}/${path}`))
101+
.send({
102+
...options,
103+
type: type,
104+
})
105+
.result();
106+
}
107+
108+
private goStakingBaseURL() {
109+
return `/api/go-staking/v1`;
110+
}
111+
112+
private goStakingRequestBaseURL() {
113+
return `${this.goStakingBaseURL()}/${this.wallet.baseCoin.getChain()}/accounts/${this.accountId}/requests`;
114+
}
115+
116+
private getGoStakingRequestURL(stakingRequestId: string): string {
117+
return `${this.goStakingRequestBaseURL()}/${stakingRequestId}`;
118+
}
119+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { HalfSignedAccountTransaction } from '../baseCoin';
2+
3+
export interface UnsignedGoStakingRequest {
4+
payload: string;
5+
coin: string;
6+
token: string;
7+
feeInfo: FeeInfo;
8+
}
9+
10+
interface FeeInfo {
11+
feeString: string;
12+
}
13+
14+
export interface GoStakingRequest {
15+
id: string;
16+
amount: string;
17+
clientId?: string;
18+
type: 'STAKE' | 'UNSTAKE';
19+
coin: string;
20+
status: string;
21+
goSpecificStatus: string;
22+
error?: string;
23+
rawError?: string;
24+
statusModifiedDate: string;
25+
createdDate: string;
26+
}
27+
28+
export interface GoStakeOptions {
29+
amount: string;
30+
clientId?: string;
31+
walletPassphrase: string;
32+
}
33+
34+
export interface BaseGoStakeOptions {
35+
amount: string;
36+
clientId?: string;
37+
}
38+
39+
export interface GoStakeFinalizeOptions extends BaseGoStakeOptions {
40+
frontTransferSendRequest: FrontTransferSendRequest;
41+
}
42+
43+
export type FrontTransferSendRequest = HalfSignedAccountTransaction;
44+
45+
export interface IGoStakingWallet {
46+
readonly accountId: string;
47+
stake(options: GoStakeOptions): Promise<GoStakingRequest>;
48+
unstake(options: BaseGoStakeOptions): Promise<GoStakingRequest>;
49+
getGoStakingRequest(stakingRequestId: string): Promise<GoStakingRequest>;
50+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './iStakingWallet';
22
export * from './stakingWallet';
3+
export * from './iGoStakingWallet';
4+
export * from './goStakingWallet';

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { BitGoBase } from '../bitgoBase';
1313
import { Keychain, KeychainWithEncryptedPrv } from '../keychain';
1414
import { IPendingApproval, PendingApprovalData } from '../pendingApproval';
15-
import { IStakingWallet } from '../staking';
15+
import { IGoStakingWallet, IStakingWallet } from '../staking';
1616
import { ITradingAccount } from '../trading';
1717
import {
1818
CustomCommitmentGeneratingFunction,
@@ -185,6 +185,7 @@ export interface PrebuildTransactionResult extends TransactionPrebuild {
185185
};
186186
pendingApprovalId?: string;
187187
reqId?: IRequestTracer;
188+
payload?: string;
188189
}
189190

190191
export interface CustomSigningFunction {
@@ -881,6 +882,7 @@ export interface IWallet {
881882
toJSON(): WalletData;
882883
toTradingAccount(): ITradingAccount;
883884
toStakingWallet(): IStakingWallet;
885+
toGoStakingWallet(): IGoStakingWallet;
884886
toAddressBook(): IAddressBook;
885887
downloadKeycard(params?: DownloadKeycardOptions): void;
886888
buildAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise<PrebuildTransactionResult[]>;

0 commit comments

Comments
 (0)