Skip to content

Commit d88b805

Browse files
committed
feat(sdk-core): bulk sign account based midnight claim messages
TICKET: COIN-5582
1 parent a6fd603 commit d88b805

File tree

6 files changed

+237
-90
lines changed

6 files changed

+237
-90
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Bulk sign account-based midnight claim messages for a wallet
3+
*
4+
* This example demonstrates how to use the BitGo API to sign multiple account-based midnight claim messages
5+
* in bulk for a given wallet. It shows how to initialize the BitGo SDK, retrieve a wallet, and use the
6+
* bulkSignAccountBasedMidnightClaimMessages utility to sign messages for a specified destination address.
7+
*
8+
* Usage:
9+
* - Configure your .env file with the appropriate TESTNET_ACCESS_TOKEN.
10+
* - Set the coin and wallet ID as needed.
11+
* - Optionally set the wallet passphrase if required for signing.
12+
*
13+
* Copyright 2025, BitGo, Inc. All Rights Reserved.
14+
*/
15+
16+
import {BitGoAPI} from '@bitgo/sdk-api';
17+
import {MessageStandardType, walletUtil} from "@bitgo/sdk-core";
18+
import {Tsol} from "@bitgo/sdk-coin-sol";
19+
require('dotenv').config({ path: '../../.env' });
20+
21+
const bitgo = new BitGoAPI({
22+
accessToken: process.env.TESTNET_ACCESS_TOKEN,
23+
env: 'test', // Change this to env: 'production' when you are ready for production
24+
});
25+
26+
// Set the coin name to match the blockchain and network
27+
// doge = dogecoin, tdoge = testnet dogecoin
28+
const coin = 'tsol';
29+
bitgo.register(coin, Tsol.createInstance);
30+
31+
const id = '';
32+
const walletPassphrase = '';
33+
34+
async function main() {
35+
const wallet = await bitgo.coin(coin).wallets().get({ id });
36+
console.log(`Wallet label: ${wallet.label()}`);
37+
38+
const adaTestnetDestinationAddress = '';
39+
40+
const response = await walletUtil.bulkSignAccountBasedMidnightClaimMessages(wallet, MessageStandardType.SIMPLE, adaTestnetDestinationAddress, walletPassphrase);
41+
console.dir(response);
42+
}
43+
44+
main().catch((e) => console.log(e));
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export type MessageInfo = {
2+
message: string;
3+
address: string;
4+
};
5+
6+
export interface IMessageProvider {
7+
/**
8+
* Returns the messages and addresses that we want to sign. We call this function multiple times until there are no more
9+
* messages. If there are no more messages, an empty array is returned. Note that the messages are returned in batches.
10+
*/
11+
getMessagesAndAddressesToSign(): Promise<MessageInfo[]>;
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './utxoMessageProof';
2+
export * from './signAccountBasedMidnightClaimMessages';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
3+
import { IMessageProvider, MessageInfo } from './iMessageProvider';
4+
import { IWallet } from '../wallet';
5+
import { Environments } from '../environments';
6+
7+
const NUM_MESSAGES_PER_QUERY = 1000;
8+
export const MIDNIGHT_TNC_HASH = '31a6bab50a84b8439adcfb786bb2020f6807e6e8fda629b424110fc7bb1c6b8b';
9+
10+
type Claim = {
11+
originWalletId: string;
12+
status: string;
13+
originAddress?: string;
14+
allocationAmount: string;
15+
};
16+
17+
/**
18+
* The Midnight drop service can return up to 1000 messages per request.
19+
* We make this wrapper function that handles the pagination and batching of messages,
20+
* keeping a local cache of the unprocessed messages.
21+
*/
22+
export class MidnightMessageProvider implements IMessageProvider {
23+
protected unprocessedMessagesCache: MessageInfo[];
24+
protected network: utxolib.Network;
25+
protected midnightClaimUrl: string;
26+
protected prevId: string | undefined;
27+
protected ranOnce = false;
28+
29+
constructor(
30+
private wallet: IWallet,
31+
private destinationAddress: string,
32+
private readonly batchSize = NUM_MESSAGES_PER_QUERY
33+
) {
34+
this.unprocessedMessagesCache = [];
35+
this.network = utxolib.networks[wallet.coin()];
36+
this.midnightClaimUrl = `${
37+
Environments[wallet.bitgo.env].uri
38+
}/api/airdrop-claim/v1/midnight/claims/${wallet.coin()}/${wallet.id()}`;
39+
}
40+
41+
async getMessagesAndAddressesToSign(): Promise<MessageInfo[]> {
42+
if (this.unprocessedMessagesCache.length > 0) {
43+
return this.unprocessedMessagesCache.splice(0, this.batchSize);
44+
} else if (this.unprocessedMessagesCache.length === 0 && this.ranOnce && this.prevId === undefined) {
45+
return [];
46+
}
47+
48+
this.ranOnce = true;
49+
const query: Record<string, unknown> = {
50+
statuses: ['UNINITIATED', 'NEEDS_RESUBMITTING'],
51+
limit: NUM_MESSAGES_PER_QUERY,
52+
};
53+
if (this.prevId !== undefined) {
54+
query.prevId = this.prevId;
55+
}
56+
const response = await this.wallet.bitgo.get(this.midnightClaimUrl).query(query).result();
57+
if (response.status !== 'success') {
58+
throw new Error(`Unexpected status code ${response.status} from ${this.midnightClaimUrl}`);
59+
}
60+
if (response?.pagination?.hasNext) {
61+
this.prevId = response?.pagination?.nextPrevId;
62+
} else {
63+
this.prevId = undefined;
64+
}
65+
66+
this.unprocessedMessagesCache = response.claims.map((claim: Claim) => {
67+
if (!claim.originAddress) {
68+
throw new Error(`Claim ${JSON.stringify(claim)} is missing originAddress`);
69+
}
70+
return {
71+
// Midnight claim message format
72+
message: `STAR ${claim.allocationAmount} to ${this.destinationAddress} ${MIDNIGHT_TNC_HASH}`,
73+
address: claim.originAddress,
74+
};
75+
});
76+
return this.unprocessedMessagesCache.splice(0, this.batchSize);
77+
}
78+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { IWallet } from '../wallet';
2+
import { MessageStandardType } from '../utils';
3+
import { MidnightMessageProvider } from './midnightMessageProvider';
4+
import { IMessageProvider, MessageInfo } from './iMessageProvider';
5+
6+
type BulkAccountBasedMessageResponse = {
7+
txRequests: Record<string, unknown>[];
8+
failedAddresses: string[];
9+
};
10+
11+
export async function bulkSignAccountBasedMidnightClaimMessages(
12+
wallet: IWallet,
13+
messageStandardType: MessageStandardType,
14+
destinationAddress: string,
15+
walletPassphrase?: string
16+
): Promise<BulkAccountBasedMessageResponse> {
17+
const provider = new MidnightMessageProvider(wallet, destinationAddress);
18+
return bulkSignAccountBasedMessagesWithProvider(provider, wallet, messageStandardType, walletPassphrase);
19+
}
20+
21+
async function bulkSignAccountBasedMessagesWithProvider(
22+
provider: IMessageProvider,
23+
wallet: IWallet,
24+
messageStandardType: MessageStandardType,
25+
walletPassphrase?: string
26+
): Promise<BulkAccountBasedMessageResponse> {
27+
const failedAddresses: string[] = [];
28+
const txRequests: Record<string, unknown>[] = [];
29+
30+
let messages: MessageInfo[] = await provider.getMessagesAndAddressesToSign();
31+
while (messages.length > 0) {
32+
// Sign/build all messages in parallel
33+
const results = await Promise.all(
34+
messages.map((messageInfo) => signOrBuildMessage(wallet, messageInfo, messageStandardType, walletPassphrase))
35+
);
36+
// Process results and update counters
37+
processResults(results, txRequests, failedAddresses);
38+
// Get next batch of messages
39+
messages = await provider.getMessagesAndAddressesToSign();
40+
}
41+
return { failedAddresses, txRequests };
42+
}
43+
44+
async function signOrBuildMessage(
45+
wallet: IWallet,
46+
messageInfo: MessageInfo,
47+
messageStandardType: MessageStandardType,
48+
walletPassphrase?: string
49+
): Promise<{ success: boolean; address: string; txRequestId?: string }> {
50+
try {
51+
let txRequestId: string;
52+
if (walletPassphrase !== undefined) {
53+
// Sign the messages with the wallet
54+
const signedMessage = await wallet.signMessage({
55+
message: {
56+
messageRaw: messageInfo.message,
57+
messageStandardType,
58+
signerAddress: messageInfo.address,
59+
},
60+
walletPassphrase,
61+
});
62+
txRequestId = signedMessage.txRequestId;
63+
} else {
64+
// Build the sign message request
65+
const txRequest = await wallet.buildSignMessageRequest({
66+
message: {
67+
messageRaw: messageInfo.message,
68+
messageStandardType,
69+
signerAddress: messageInfo.address,
70+
},
71+
});
72+
txRequestId = txRequest.txRequestId;
73+
}
74+
return { success: true, address: messageInfo.address, txRequestId };
75+
} catch (e) {
76+
console.error(`Error building/signing message for address ${messageInfo.address}: ${e}`);
77+
return { success: false, address: messageInfo.address };
78+
}
79+
}
80+
81+
function processResults(
82+
results: { success: boolean; address: string; txRequestId?: string }[],
83+
txRequests: Record<string, unknown>[],
84+
failedAddresses: string[]
85+
): void {
86+
for (const result of results) {
87+
if (result.success) {
88+
txRequests.push({
89+
address: result.address,
90+
txRequestId: result.txRequestId,
91+
});
92+
} else {
93+
failedAddresses.push(result.address);
94+
}
95+
}
96+
}

modules/sdk-core/src/bitgo/walletUtil/utxoMessageProof.ts

Lines changed: 6 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,13 @@
1-
import * as utxolib from '@bitgo/utxo-lib';
2-
3-
import { IWallet } from '../wallet/iWallet';
4-
import { Environments } from '../environments';
5-
6-
const NUM_MESSAGES_PER_QUERY = 1000;
7-
export const MIDNIGHT_TNC_HASH = '31a6bab50a84b8439adcfb786bb2020f6807e6e8fda629b424110fc7bb1c6b8b';
8-
9-
type MessageInfo = {
10-
message: string;
11-
address: string;
12-
};
13-
14-
type Claim = {
15-
originWalletId: string;
16-
status: string;
17-
originAddress?: string;
18-
allocationAmount: string;
19-
};
1+
import { IWallet } from '../wallet';
2+
import { IMessageProvider, MessageInfo } from './iMessageProvider';
3+
import { MidnightMessageProvider } from './midnightMessageProvider';
204

215
type BulkMessageResponse = {
226
success: boolean;
237
numMessages: number;
248
transactions: Record<string, unknown>[];
259
};
2610

27-
export interface IMessageProvider {
28-
/**
29-
* Returns the messages and addresses that we want to sign. We call this function multiple times until there are no more
30-
* messages. If there are no more messages, an empty array is returned. Note that we only return messages in sets of 200.
31-
*/
32-
getMessagesAndAddressesToSign(): Promise<MessageInfo[]>;
33-
}
34-
35-
/**
36-
* The Midnight drop service can return up to 1000 messages per request. However, UTXO coins
37-
* can only have a maximum of 200 messages per transaction. We make this wrapper function that
38-
* handles the pagination and batching of messages, keeping a local cache of the unprocessed messages.
39-
*/
40-
export class MidnightMessageProvider implements IMessageProvider {
41-
protected unprocessedMessagesCache: MessageInfo[];
42-
protected network: utxolib.Network;
43-
protected midnightClaimUrl: string;
44-
protected prevId: string | undefined;
45-
protected ranOnce = false;
46-
private readonly numMessagesPerTransaction: number;
47-
48-
constructor(private wallet: IWallet, private destinationAddress: string) {
49-
this.unprocessedMessagesCache = [];
50-
this.network = utxolib.networks[wallet.coin()];
51-
this.midnightClaimUrl = `${
52-
Environments[wallet.bitgo.env].uri
53-
}/api/airdrop-claim/v1/midnight/claims/${wallet.coin()}/${wallet.id()}`;
54-
this.numMessagesPerTransaction = wallet.bitgo.env === 'prod' ? 200 : 4;
55-
}
56-
57-
async getMessagesAndAddressesToSign(): Promise<MessageInfo[]> {
58-
if (this.unprocessedMessagesCache.length > 0) {
59-
return this.unprocessedMessagesCache.splice(0, this.numMessagesPerTransaction);
60-
} else if (this.unprocessedMessagesCache.length === 0 && this.ranOnce && this.prevId === undefined) {
61-
return [];
62-
}
63-
64-
this.ranOnce = true;
65-
const query: Record<string, unknown> = {
66-
status: 'UNINITIATED',
67-
limit: NUM_MESSAGES_PER_QUERY,
68-
};
69-
if (this.prevId !== undefined) {
70-
query.prevId = this.prevId;
71-
}
72-
const response = await this.wallet.bitgo.get(this.midnightClaimUrl).query(query).result();
73-
if (response.status !== 'success') {
74-
throw new Error(`Unexpected status code ${response.status} from ${this.midnightClaimUrl}`);
75-
}
76-
if (response?.pagination?.hasNext) {
77-
this.prevId = response?.pagination?.nextPrevId;
78-
} else {
79-
this.prevId = undefined;
80-
}
81-
82-
this.unprocessedMessagesCache = response.claims.map((claim: Claim) => {
83-
if (!claim.originAddress) {
84-
throw new Error(`Claim ${JSON.stringify(claim)} is missing originAddress`);
85-
}
86-
return {
87-
// Midnight claim message format
88-
message: `STAR ${claim.allocationAmount} to ${this.destinationAddress} ${MIDNIGHT_TNC_HASH}`,
89-
address: claim.originAddress,
90-
};
91-
});
92-
const toReturn = this.unprocessedMessagesCache.splice(0, this.numMessagesPerTransaction);
93-
return toReturn;
94-
}
95-
}
96-
9711
/**
9812
* Bulk signs BIP322 messages for the Midnight airdrop.
9913
* @param wallet The wallet to sign the messages with.
@@ -105,7 +19,9 @@ export async function bulkSignBip322MidnightMessages(
10519
destinationAddress: string,
10620
walletPassphrase?: string
10721
): Promise<BulkMessageResponse> {
108-
const provider = new MidnightMessageProvider(wallet, destinationAddress);
22+
// UTXO coins can only have a maximum of 200 messages per transaction
23+
const numMessagesPerTransaction = wallet.bitgo.env === 'prod' ? 200 : 4;
24+
const provider = new MidnightMessageProvider(wallet, destinationAddress, numMessagesPerTransaction);
10925
return bulkSignBip322MessagesWithProvider(provider, wallet, walletPassphrase);
11026
}
11127

0 commit comments

Comments
 (0)