Skip to content

Commit 9b51d86

Browse files
committed
toolbox ARC and ARC.test of postRawTx w/doubleSpend
1 parent 71db2c7 commit 9b51d86

File tree

7 files changed

+319
-491
lines changed

7 files changed

+319
-491
lines changed

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"axios": "^0.29.0",
3737
"express": "^4.21.2",
3838
"knex": "^3.1.0",
39+
"sqlite3": "^5.1.7",
3940
"mysql2": "^3.12.0"
4041
},
4142
"devDependencies": {
@@ -52,8 +53,6 @@
5253
"jest-diff": "^29.7.0",
5354
"jest-simple-summary": "^1.0.2",
5455
"prettier": "^3.4.2",
55-
"sqlite3": "^5.1.7",
56-
"standard": "^17.1.0",
5756
"ts-jest": "^29.0.5",
5857
"ts-node": "^10.9.1",
5958
"ts2md": "^0.2.8",

src/sdk/WalletServices.interfaces.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,25 @@ export interface PostTxResultForTxid {
230230
*/
231231
alreadyKnown?: boolean
232232

233+
/**
234+
* service indicated this broadcast double spends at least one input
235+
* `competingTxs` may be an array of txids that were first seen spends of at least one input.
236+
*/
237+
doubleSpend?: boolean
238+
233239
blockHash?: string
234240
blockHeight?: number
235241
merklePath?: MerklePath
236242

237-
data?: object
243+
competingTxs?: string[]
244+
245+
data?: object | string | PostTxResultForTxidError
246+
}
247+
248+
export interface PostTxResultForTxidError {
249+
status?: string
250+
detail?: string
251+
more?: object
238252
}
239253

240254
export interface PostBeefResult extends PostTxsResult {}

src/services/__tests/ARC.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { toBase58 } from "@bsv/sdk/dist/types/src/primitives/utils"
2+
import { _tu } from "../../../test/utils/TestUtilsWalletStorage"
3+
import { sdk, wait } from "../../index.client"
4+
import ARC from "../providers/ARC"
5+
6+
describe('ARC tests', () => {
7+
jest.setTimeout(99999999)
8+
9+
const envTest = _tu.getEnv('test')
10+
const arcTest = new ARC(arcUrl(envTest.chain), { apiKey: envTest.taalApiKey })
11+
12+
const envMain = _tu.getEnv('main')
13+
const arcMain = new ARC(arcUrl(envMain.chain), { apiKey: envMain.taalApiKey })
14+
15+
test('7 postRawTx testnet', async () => {
16+
await postRawTxTest('test', arcTest)
17+
})
18+
19+
test('8 postRawTx mainnet', async () => {
20+
await postRawTxTest('main', arcMain)
21+
})
22+
23+
})
24+
25+
function arcUrl(chain: sdk.Chain): string {
26+
const url = chain === 'main'
27+
? 'https://api.taal.com/arc'
28+
: 'https://arc-test.taal.com'
29+
return url
30+
}
31+
32+
async function postRawTxTest(chain: sdk.Chain, arc: ARC) {
33+
if (_tu.noEnv(chain)) return
34+
const c = await _tu.createNoSendTxPair(chain)
35+
36+
const rawTxDo = c.beef.findTxid(c.txidDo)!.tx!.toHex()
37+
const rawTxUndo = c.beef.findTxid(c.txidUndo)!.tx!.toHex()
38+
39+
const rDo = await arc.postRawTx(rawTxDo)
40+
expect(rDo.status).toBe('success')
41+
expect(rDo.txid).toBe(c.txidDo)
42+
43+
await wait(1000)
44+
45+
const rUndo = await arc.postRawTx(rawTxUndo)
46+
expect(rUndo.status).toBe('success')
47+
expect(rUndo.txid).toBe(c.txidUndo)
48+
expect(rUndo.doubleSpend).not.toBe(true)
49+
50+
await wait(1000)
51+
52+
{
53+
// Send same transaction again...
54+
const rUndo = await arc.postRawTx(rawTxUndo)
55+
expect(rUndo.status).toBe('success')
56+
expect(rUndo.txid).toBe(c.txidUndo)
57+
expect(rUndo.doubleSpend).not.toBe(true)
58+
}
59+
60+
await wait(1000)
61+
62+
// Confirm double spend detection.
63+
const rDouble = await arc.postRawTx(c.doubleSpendTx.toHex())
64+
expect(rDouble.status).toBe('error')
65+
expect(rDouble.doubleSpend).toBe(true)
66+
expect(rDouble.competingTxs![0]).toBe(c.txidUndo)
67+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Beef } from '@bsv/sdk'
2+
import { Services } from '../../index.all'
3+
4+
describe.skip('arcServices tests', () => {
5+
jest.setTimeout(99999999)
6+
7+
test('0 ', async () => {
8+
})
9+
})

src/services/__tests/postBeefToArcTaal.test.ts

Lines changed: 0 additions & 487 deletions
This file was deleted.

src/services/providers/ARC.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { Broadcaster, BroadcastFailure, BroadcastResponse, defaultHttpClient, HexString, HttpClient, HttpClientRequestOptions, Random, Transaction, Utils } from "@bsv/sdk"
2+
import { doubleSha256BE, sdk } from "../../index.client"
3+
import { error } from "console"
4+
5+
/** Configuration options for the ARC broadcaster. */
6+
export interface ArcConfig {
7+
/** Authentication token for the ARC API */
8+
apiKey?: string
9+
/** The HTTP client used to make requests to the ARC API. */
10+
httpClient?: HttpClient
11+
/** Deployment id used annotating api calls in XDeployment-ID header - this value will be randomly generated if not set */
12+
deploymentId?: string
13+
/** notification callback endpoint for proofs and double spend notification */
14+
callbackUrl?: string
15+
/** default access token for notification callback endpoint. It will be used as a Authorization header for the http callback */
16+
callbackToken?: string
17+
/** additional headers to be attached to all tx submissions. */
18+
headers?: Record<string, string>
19+
}
20+
21+
function defaultDeploymentId(): string {
22+
return `ts-sdk-${Utils.toHex(Random(16))}`
23+
}
24+
25+
/**
26+
* Represents an ARC transaction broadcaster.
27+
*/
28+
export default class ARC {
29+
readonly URL: string
30+
readonly apiKey: string | undefined
31+
readonly deploymentId: string
32+
readonly callbackUrl: string | undefined
33+
readonly callbackToken: string | undefined
34+
readonly headers: Record<string, string> | undefined
35+
private readonly httpClient: HttpClient
36+
37+
/**
38+
* Constructs an instance of the ARC broadcaster.
39+
*
40+
* @param {string} URL - The URL endpoint for the ARC API.
41+
* @param {ArcConfig} config - Configuration options for the ARC broadcaster.
42+
*/
43+
constructor(URL: string, config?: ArcConfig)
44+
/**
45+
* Constructs an instance of the ARC broadcaster.
46+
*
47+
* @param {string} URL - The URL endpoint for the ARC API.
48+
* @param {string} apiKey - The API key used for authorization with the ARC API.
49+
*/
50+
constructor(URL: string, apiKey?: string)
51+
52+
constructor(URL: string, config?: string | ArcConfig) {
53+
this.URL = URL
54+
if (typeof config === 'string') {
55+
this.apiKey = config
56+
this.httpClient = defaultHttpClient()
57+
this.deploymentId = defaultDeploymentId()
58+
this.callbackToken = undefined
59+
this.callbackUrl = undefined
60+
} else {
61+
const configObj: ArcConfig = config ?? {}
62+
const {
63+
apiKey,
64+
deploymentId,
65+
httpClient,
66+
callbackToken,
67+
callbackUrl,
68+
headers
69+
} = configObj
70+
this.apiKey = apiKey
71+
this.httpClient = httpClient ?? defaultHttpClient()
72+
this.deploymentId = deploymentId ?? defaultDeploymentId()
73+
this.callbackToken = callbackToken
74+
this.callbackUrl = callbackUrl
75+
this.headers = headers
76+
}
77+
}
78+
79+
/**
80+
* Constructs a dictionary of the default & supplied request headers.
81+
*/
82+
private requestHeaders(): Record<string, string> {
83+
const headers: Record<string, string> = {
84+
'Content-Type': 'application/json',
85+
'XDeployment-ID': this.deploymentId
86+
}
87+
88+
if (this.apiKey != null && this.apiKey !== '') {
89+
headers.Authorization = `Bearer ${this.apiKey}`
90+
}
91+
92+
if (this.callbackUrl != null && this.callbackUrl !== '') {
93+
headers['X-CallbackUrl'] = this.callbackUrl
94+
}
95+
96+
if (this.callbackToken != null && this.callbackToken !== '') {
97+
headers['X-CallbackToken'] = this.callbackToken
98+
}
99+
100+
if (this.headers != null) {
101+
for (const key in this.headers) {
102+
headers[key] = this.headers[key]
103+
}
104+
}
105+
106+
return headers
107+
}
108+
109+
async postRawTx(rawTx: HexString): Promise<sdk.PostTxResultForTxid> {
110+
111+
const txid = Utils.toHex(doubleSha256BE(Utils.toArray(rawTx, 'hex')))
112+
113+
const requestOptions: HttpClientRequestOptions = {
114+
method: 'POST',
115+
headers: this.requestHeaders(),
116+
data: { rawTx }
117+
}
118+
119+
const r: sdk.PostTxResultForTxid = {
120+
txid,
121+
status: "success"
122+
}
123+
124+
try {
125+
126+
const response = await this.httpClient.request<ArcResponse>(
127+
`${this.URL}/v1/tx`,
128+
requestOptions
129+
)
130+
131+
if (response.ok) {
132+
const { txid, extraInfo, txStatus, competingTxs } = response.data
133+
r.data = `${txStatus} ${extraInfo}`
134+
if (r.txid !== txid) r.data += ` txid altered from ${r.txid} to ${txid}`
135+
r.txid = txid
136+
if (txStatus === 'DOUBLE_SPEND_ATTEMPTED') {
137+
r.status = 'error'
138+
r.doubleSpend = true
139+
r.competingTxs = competingTxs
140+
}
141+
} else {
142+
143+
r.status = 'error'
144+
const ed: sdk.PostTxResultForTxidError = {}
145+
r.data = ed
146+
const st = typeof response.status
147+
ed.status = st === 'number' || st === 'string' ? response.status.toString() : 'ERR_UNKNOWN'
148+
149+
let d = response.data
150+
if (d && typeof d === 'string') {
151+
try {
152+
d = JSON.parse(d)
153+
} catch {
154+
// Intentionally left empty
155+
}
156+
}
157+
if (d && typeof d === 'object') {
158+
ed.more = d
159+
ed.detail = d['detail']
160+
if (typeof ed.detail !== 'string') ed.detail = undefined
161+
}
162+
}
163+
164+
} catch (eu: unknown) {
165+
const e = sdk.WalletError.fromUnknown(eu)
166+
r.status = 'error'
167+
r.data = `${e.code} ${e.message}`
168+
}
169+
170+
return r
171+
}
172+
173+
/**
174+
* Broadcasts multiple transactions via ARC.
175+
* Handles mixed responses where some transactions succeed and others fail.
176+
*
177+
* @param {Transaction[]} txs - Array of transactions to be broadcasted.
178+
* @returns {Promise<Array<object>>} A promise that resolves to an array of objects.
179+
*/
180+
async broadcastMany(txs: Transaction[]): Promise<object[]> {
181+
const rawTxs = txs.map((tx) => {
182+
try {
183+
return { rawTx: tx.toHexEF() }
184+
} catch (eu: unknown) {
185+
const e = sdk.WalletError.fromUnknown(eu)
186+
if (
187+
e.message ===
188+
'All inputs must have source transactions when serializing to EF format'
189+
) {
190+
return { rawTx: tx.toHex() }
191+
}
192+
throw eu
193+
}
194+
})
195+
196+
const requestOptions: HttpClientRequestOptions = {
197+
method: 'POST',
198+
headers: this.requestHeaders(),
199+
data: rawTxs
200+
}
201+
202+
try {
203+
const response = await this.httpClient.request<object[]>(
204+
`${this.URL}/v1/txs`,
205+
requestOptions
206+
)
207+
208+
return response.data as object[]
209+
} catch (eu: unknown) {
210+
const e = sdk.WalletError.fromUnknown(eu)
211+
const errorResponse: BroadcastFailure = {
212+
status: 'error',
213+
code: '500',
214+
description: typeof e.message === 'string' ? e.message : 'Internal Server Error'
215+
}
216+
return txs.map(() => errorResponse)
217+
}
218+
}
219+
}
220+
221+
interface ArcResponse {
222+
txid: string
223+
extraInfo: string
224+
txStatus: string
225+
competingTxs?: string[]
226+
}

src/services/providers/__tests/WhatsOnChain.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('whatsonchain tests', () => {
103103
test('6 getTxPropagation mainnet', async () => {})
104104

105105
test('7 postRawTx testnet', async () => {
106-
if (_tu.noEnv('main')) return
106+
if (_tu.noEnv('test')) return
107107
const woc = wocTest
108108
const c = await _tu.createNoSendTxPair('test')
109109

0 commit comments

Comments
 (0)