@openzeppelin/relayer-plugin-channels 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +100 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +89 -0
- package/dist/constants.d.ts +46 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +56 -0
- package/dist/fee.d.ts +11 -0
- package/dist/fee.d.ts.map +1 -0
- package/dist/fee.js +41 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +115 -0
- package/dist/management.d.ts +11 -0
- package/dist/management.d.ts.map +1 -0
- package/dist/management.js +166 -0
- package/dist/pool.d.ts +31 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +129 -0
- package/dist/simulation.d.ts +14 -0
- package/dist/simulation.d.ts.map +1 -0
- package/dist/simulation.js +52 -0
- package/dist/submit.d.ts +20 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/submit.js +102 -0
- package/dist/test/config.test.d.ts +2 -0
- package/dist/test/config.test.d.ts.map +1 -0
- package/dist/test/config.test.js +43 -0
- package/dist/test/fee.test.d.ts +2 -0
- package/dist/test/fee.test.d.ts.map +1 -0
- package/dist/test/fee.test.js +34 -0
- package/dist/test/helpers/fakeKV.d.ts +17 -0
- package/dist/test/helpers/fakeKV.d.ts.map +1 -0
- package/dist/test/helpers/fakeKV.js +60 -0
- package/dist/test/management.test.d.ts +2 -0
- package/dist/test/management.test.d.ts.map +1 -0
- package/dist/test/management.test.js +66 -0
- package/dist/test/pool.busy.test.d.ts +2 -0
- package/dist/test/pool.busy.test.d.ts.map +1 -0
- package/dist/test/pool.busy.test.js +23 -0
- package/dist/test/pool.test.d.ts +2 -0
- package/dist/test/pool.test.d.ts.map +1 -0
- package/dist/test/pool.test.js +29 -0
- package/dist/test/tx.test.d.ts +2 -0
- package/dist/test/tx.test.d.ts.map +1 -0
- package/dist/test/tx.test.js +29 -0
- package/dist/test/validation.test.d.ts +2 -0
- package/dist/test/validation.test.d.ts.map +1 -0
- package/dist/test/validation.test.js +31 -0
- package/dist/tx.d.ts +8 -0
- package/dist/tx.d.ts.map +1 -0
- package/dist/tx.js +45 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/validation.d.ts +9 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +84 -0
- package/package.json +63 -0
package/dist/pool.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* pool.ts
|
|
4
|
+
*
|
|
5
|
+
* KV-backed, stateless channel pool.
|
|
6
|
+
* - Membership comes from KV: <network>:channel:relayer-ids
|
|
7
|
+
* - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
|
|
8
|
+
* - Uses a short global mutex to make acquire atomic across workers.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ChannelPool = void 0;
|
|
15
|
+
const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const config_1 = require("./config");
|
|
18
|
+
const constants_1 = require("./constants");
|
|
19
|
+
class ChannelPool {
|
|
20
|
+
constructor(network, kv) {
|
|
21
|
+
this.network = network;
|
|
22
|
+
this.kv = kv;
|
|
23
|
+
this.globalLockKey = `${this.network}:channel-pool-lock`;
|
|
24
|
+
this.channelLockTtlSec = (0, config_1.getLockTtlSeconds)();
|
|
25
|
+
this.mutexTtlSec = constants_1.POOL.MUTEX_TTL_SECONDS;
|
|
26
|
+
}
|
|
27
|
+
/** Acquire a relayerId with a token lock */
|
|
28
|
+
async acquire() {
|
|
29
|
+
const maxSpins = constants_1.POOL.MUTEX_MAX_SPINS;
|
|
30
|
+
for (let i = 0; i < maxSpins; i++) {
|
|
31
|
+
const r = await this.withGlobalMutex(() => this.tryLockAnyRelayer());
|
|
32
|
+
if (r === null) {
|
|
33
|
+
const jitter = constants_1.POOL.MUTEX_RETRY_MIN_MS +
|
|
34
|
+
Math.floor(Math.random() *
|
|
35
|
+
(constants_1.POOL.MUTEX_RETRY_MAX_MS - constants_1.POOL.MUTEX_RETRY_MIN_MS + 1));
|
|
36
|
+
await sleep(jitter);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
throw (0, relayer_sdk_1.pluginError)("Too many transactions queued. Please try again later", {
|
|
42
|
+
code: "POOL_CAPACITY",
|
|
43
|
+
status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Run a function under the short-lived global mutex; returns null if busy
|
|
47
|
+
async withGlobalMutex(fn) {
|
|
48
|
+
const r = (await this.kv.withLock(this.globalLockKey, fn, {
|
|
49
|
+
ttlSec: this.mutexTtlSec,
|
|
50
|
+
onBusy: "skip",
|
|
51
|
+
}));
|
|
52
|
+
return r;
|
|
53
|
+
}
|
|
54
|
+
// Inside the mutex: pick an available relayer and set its channel lock
|
|
55
|
+
async tryLockAnyRelayer() {
|
|
56
|
+
const ids = await this.getRelayerIdsFromKV();
|
|
57
|
+
if (ids.length === 0) {
|
|
58
|
+
throw (0, relayer_sdk_1.pluginError)("No channel accounts configured. Use the management API to set channel accounts.", {
|
|
59
|
+
code: "NO_CHANNELS_CONFIGURED",
|
|
60
|
+
status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
shuffle(ids);
|
|
64
|
+
for (const relayerId of ids) {
|
|
65
|
+
const key = this.lockKey(relayerId);
|
|
66
|
+
const exists = await this.kv.exists(key);
|
|
67
|
+
if (exists)
|
|
68
|
+
continue;
|
|
69
|
+
const token = randomToken();
|
|
70
|
+
const entry = { token, lockedAt: new Date().toISOString() };
|
|
71
|
+
await this.kv.set(key, entry, { ttlSec: this.channelLockTtlSec });
|
|
72
|
+
return { relayerId, token };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Release the lock if we own it */
|
|
77
|
+
async release(lock) {
|
|
78
|
+
try {
|
|
79
|
+
const key = this.lockKey(lock.relayerId);
|
|
80
|
+
const current = await this.kv.get(key);
|
|
81
|
+
if (current?.token === lock.token) {
|
|
82
|
+
await this.kv.del(key);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore release errors
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
membershipKey() {
|
|
90
|
+
return `${this.network}:channel:relayer-ids`;
|
|
91
|
+
}
|
|
92
|
+
lockKey(relayerId) {
|
|
93
|
+
return `${this.network}:channel:in-use:${relayerId}`;
|
|
94
|
+
}
|
|
95
|
+
async getRelayerIdsFromKV() {
|
|
96
|
+
try {
|
|
97
|
+
const doc = await this.kv.get(this.membershipKey());
|
|
98
|
+
if (!doc || !Array.isArray(doc.relayerIds))
|
|
99
|
+
return [];
|
|
100
|
+
// Normalize and unique
|
|
101
|
+
const set = new Set(doc.relayerIds.map(normalizeId));
|
|
102
|
+
return Array.from(set.values());
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.ChannelPool = ChannelPool;
|
|
110
|
+
function shuffle(arr) {
|
|
111
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
112
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
113
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function randomToken() {
|
|
117
|
+
try {
|
|
118
|
+
return crypto_1.default.randomBytes(16).toString("hex");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return (Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function normalizeId(id) {
|
|
125
|
+
return String(id).trim().toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
function sleep(ms) {
|
|
128
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
129
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* simulation.ts
|
|
3
|
+
*
|
|
4
|
+
* Build and simulate a Soroban transaction using a channel account as the source
|
|
5
|
+
* and the fund account as the operation source. Returns an unsigned inner
|
|
6
|
+
* transaction with sorobanData and correct fee set to the resource fee.
|
|
7
|
+
*/
|
|
8
|
+
import { SorobanRpc, Transaction, xdr } from "@stellar/stellar-sdk";
|
|
9
|
+
export interface ChannelAccount {
|
|
10
|
+
address: string;
|
|
11
|
+
sequence: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function simulateAndBuildWithChannel(func: xdr.HostFunction, auth: xdr.SorobanAuthorizationEntry[] | undefined, channel: ChannelAccount, _fundAddress: string, rpc: SorobanRpc.Server, networkPassphrase: string): Promise<Transaction>;
|
|
14
|
+
//# sourceMappingURL=simulation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"simulation.d.ts","sourceRoot":"","sources":["../src/simulation.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAGL,UAAU,EACV,WAAW,EAEX,GAAG,EACJ,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,GAAG,CAAC,YAAY,EACtB,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,GAAG,SAAS,EACjD,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,MAAM,EACpB,GAAG,EAAE,UAAU,CAAC,MAAM,EACtB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,WAAW,CAAC,CA8CtB"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* simulation.ts
|
|
4
|
+
*
|
|
5
|
+
* Build and simulate a Soroban transaction using a channel account as the source
|
|
6
|
+
* and the fund account as the operation source. Returns an unsigned inner
|
|
7
|
+
* transaction with sorobanData and correct fee set to the resource fee.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.simulateAndBuildWithChannel = simulateAndBuildWithChannel;
|
|
11
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
12
|
+
const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
|
|
13
|
+
const constants_1 = require("./constants");
|
|
14
|
+
async function simulateAndBuildWithChannel(func, auth, channel, _fundAddress, rpc, networkPassphrase) {
|
|
15
|
+
const now = Math.floor(Date.now() / 1000);
|
|
16
|
+
console.debug(`[channels] Building tx: channel=${channel.address}, seq=${channel.sequence}, auth_count=${auth?.length ?? 0}`);
|
|
17
|
+
// Build inner transaction (source = channel, op source = fund)
|
|
18
|
+
const transaction = new stellar_sdk_1.TransactionBuilder(new stellar_sdk_1.Account(channel.address, channel.sequence), {
|
|
19
|
+
fee: constants_1.SIMULATION.DEFAULT_FEE,
|
|
20
|
+
networkPassphrase,
|
|
21
|
+
timebounds: {
|
|
22
|
+
minTime: constants_1.SIMULATION.MIN_TIME_BOUND,
|
|
23
|
+
maxTime: now + constants_1.SIMULATION.MAX_TIME_BOUND_OFFSET_SECONDS,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
.addOperation(stellar_sdk_1.Operation.invokeHostFunction({
|
|
27
|
+
func,
|
|
28
|
+
auth,
|
|
29
|
+
// No explicit source: default to transaction source (channel account)
|
|
30
|
+
}))
|
|
31
|
+
.build();
|
|
32
|
+
// Prepare transaction (attaches sorobanData/resources, preserves provided auth)
|
|
33
|
+
try {
|
|
34
|
+
const prepared = await rpc.prepareTransaction(transaction);
|
|
35
|
+
const resourceFee = prepared
|
|
36
|
+
.toEnvelope()
|
|
37
|
+
.v1()
|
|
38
|
+
.tx()
|
|
39
|
+
.ext()
|
|
40
|
+
.sorobanData()
|
|
41
|
+
?.resourceFee();
|
|
42
|
+
console.debug(`[channels] Simulation complete: resourceFee=${resourceFee}`);
|
|
43
|
+
return prepared;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw (0, relayer_sdk_1.pluginError)("Simulation failed", {
|
|
47
|
+
code: "SIMULATION_FAILED",
|
|
48
|
+
status: constants_1.HTTP_STATUS.BAD_REQUEST,
|
|
49
|
+
details: { error: e instanceof Error ? e.message : String(e) },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/submit.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* submit.ts
|
|
3
|
+
*
|
|
4
|
+
* Signing and submission logic for channel account transactions.
|
|
5
|
+
*/
|
|
6
|
+
import { Transaction } from "@stellar/stellar-sdk";
|
|
7
|
+
import { Relayer, PluginAPI } from "@openzeppelin/relayer-sdk";
|
|
8
|
+
import { ChannelAccountsResponse } from "./types";
|
|
9
|
+
/**
|
|
10
|
+
* Sign transaction with both channel and fund relayers
|
|
11
|
+
* - First sign with channel account
|
|
12
|
+
* - Then sign with fund account
|
|
13
|
+
* - Both signatures are added to the transaction
|
|
14
|
+
*/
|
|
15
|
+
export declare function signWithChannelAndFund(transaction: Transaction, channelRelayer: Relayer, _fundRelayer: Relayer, channelAddress: string, _fundAddress: string, networkPassphrase: string): Promise<Transaction>;
|
|
16
|
+
/**
|
|
17
|
+
* Submit transaction with fee bump and wait for confirmation
|
|
18
|
+
*/
|
|
19
|
+
export declare function submitWithFeeBumpAndWait(fundRelayer: Relayer, signedXdr: string, network: "testnet" | "mainnet", maxFee: number, api: PluginAPI): Promise<ChannelAccountsResponse>;
|
|
20
|
+
//# sourceMappingURL=submit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../src/submit.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAEL,OAAO,EAGP,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,OAAO,EACrB,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,WAAW,CAAC,CA2BtB;AAED;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,SAAS,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,uBAAuB,CAAC,CAwDlC"}
|
package/dist/submit.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* submit.ts
|
|
4
|
+
*
|
|
5
|
+
* Signing and submission logic for channel account transactions.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.signWithChannelAndFund = signWithChannelAndFund;
|
|
9
|
+
exports.submitWithFeeBumpAndWait = submitWithFeeBumpAndWait;
|
|
10
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
11
|
+
const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
|
|
12
|
+
const constants_1 = require("./constants");
|
|
13
|
+
/**
|
|
14
|
+
* Sign transaction with both channel and fund relayers
|
|
15
|
+
* - First sign with channel account
|
|
16
|
+
* - Then sign with fund account
|
|
17
|
+
* - Both signatures are added to the transaction
|
|
18
|
+
*/
|
|
19
|
+
async function signWithChannelAndFund(transaction, channelRelayer, _fundRelayer, channelAddress, _fundAddress, networkPassphrase) {
|
|
20
|
+
const txXdr = transaction.toXDR();
|
|
21
|
+
console.debug(`[channels] Signing transaction with channel (${channelAddress})`);
|
|
22
|
+
// Get signatures from both accounts sequentially
|
|
23
|
+
// Channel signs first
|
|
24
|
+
const channelSignResult = await channelRelayer.signTransaction({
|
|
25
|
+
unsigned_xdr: txXdr,
|
|
26
|
+
});
|
|
27
|
+
if (!isSignTransactionResponseStellar(channelSignResult)) {
|
|
28
|
+
throw (0, relayer_sdk_1.pluginError)("Invalid channel signature response", {
|
|
29
|
+
code: "INVALID_SIGNATURE",
|
|
30
|
+
status: constants_1.HTTP_STATUS.INTERNAL_SERVER_ERROR,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Add both signatures to the transaction
|
|
34
|
+
const signedTx = new stellar_sdk_1.Transaction(txXdr, networkPassphrase);
|
|
35
|
+
signedTx.addSignature(channelAddress, channelSignResult.signature);
|
|
36
|
+
console.debug(`[channels] Transaction signed: ${signedTx.signatures.length} signature(s) added`);
|
|
37
|
+
return signedTx;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Submit transaction with fee bump and wait for confirmation
|
|
41
|
+
*/
|
|
42
|
+
async function submitWithFeeBumpAndWait(fundRelayer, signedXdr, network, maxFee, api) {
|
|
43
|
+
// Submit with fee bump
|
|
44
|
+
console.debug(`[channels] Sending fee bump tx: network=${network}, maxFee=${maxFee}, xdr_len=${signedXdr.length}`);
|
|
45
|
+
const payload = {
|
|
46
|
+
network,
|
|
47
|
+
transaction_xdr: signedXdr,
|
|
48
|
+
fee_bump: true,
|
|
49
|
+
max_fee: maxFee,
|
|
50
|
+
};
|
|
51
|
+
console.debug(`[channels] Relayer payload: ${JSON.stringify(payload)}`);
|
|
52
|
+
const submission = await fundRelayer.sendTransaction(payload);
|
|
53
|
+
// Wait for confirmation
|
|
54
|
+
try {
|
|
55
|
+
const final = (await api.transactionWait(submission, {
|
|
56
|
+
interval: 500,
|
|
57
|
+
timeout: 25000,
|
|
58
|
+
}));
|
|
59
|
+
// Check if transaction actually succeeded
|
|
60
|
+
if (final.status === "failed") {
|
|
61
|
+
throw (0, relayer_sdk_1.pluginError)(final.status_reason || "Transaction failed", {
|
|
62
|
+
code: "ONCHAIN_FAILED",
|
|
63
|
+
status: constants_1.HTTP_STATUS.BAD_REQUEST,
|
|
64
|
+
details: {
|
|
65
|
+
status: String(final.status),
|
|
66
|
+
reason: final.status_reason ?? null,
|
|
67
|
+
id: final.id,
|
|
68
|
+
hash: final.hash ?? null,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
transactionId: final.id,
|
|
74
|
+
status: final.status,
|
|
75
|
+
hash: final.hash ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// If it's already a pluginError with ONCHAIN_FAILED, rethrow it
|
|
80
|
+
if (error.code === "ONCHAIN_FAILED") {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
// Otherwise, it's a timeout
|
|
84
|
+
throw (0, relayer_sdk_1.pluginError)("Transaction wait timeout. It may still submit.", {
|
|
85
|
+
code: "WAIT_TIMEOUT",
|
|
86
|
+
status: constants_1.HTTP_STATUS.GATEWAY_TIMEOUT,
|
|
87
|
+
details: {
|
|
88
|
+
id: submission.id,
|
|
89
|
+
hash: submission.hash ?? null,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Type guard for SignTransactionResponseStellar
|
|
96
|
+
*/
|
|
97
|
+
function isSignTransactionResponseStellar(data) {
|
|
98
|
+
return (data !== null &&
|
|
99
|
+
typeof data === "object" &&
|
|
100
|
+
"signature" in data &&
|
|
101
|
+
"signedXdr" in data);
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/test/config.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
5
|
+
const config_1 = require("../config");
|
|
6
|
+
const env = process.env;
|
|
7
|
+
(0, vitest_1.describe)("config", () => {
|
|
8
|
+
(0, vitest_1.beforeEach)(() => {
|
|
9
|
+
process.env = { ...env };
|
|
10
|
+
});
|
|
11
|
+
(0, vitest_1.afterEach)(() => {
|
|
12
|
+
process.env = env;
|
|
13
|
+
});
|
|
14
|
+
(0, vitest_1.test)("loadConfig reads required env", () => {
|
|
15
|
+
process.env.STELLAR_NETWORK = "testnet";
|
|
16
|
+
process.env.SOROBAN_RPC_URL = "http://localhost:9999";
|
|
17
|
+
process.env.FUND_RELAYER_ID = "fund-relayer";
|
|
18
|
+
const cfg = (0, config_1.loadConfig)();
|
|
19
|
+
(0, vitest_1.expect)(cfg.network).toBe("testnet");
|
|
20
|
+
(0, vitest_1.expect)(cfg.rpcUrl).toBe("http://localhost:9999");
|
|
21
|
+
(0, vitest_1.expect)(cfg.fundRelayerId).toBe("fund-relayer");
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.test)("network passphrase", () => {
|
|
24
|
+
(0, vitest_1.expect)((0, config_1.getNetworkPassphrase)("testnet")).toBe(stellar_sdk_1.Networks.TESTNET);
|
|
25
|
+
(0, vitest_1.expect)((0, config_1.getNetworkPassphrase)("mainnet")).toBe(stellar_sdk_1.Networks.PUBLIC);
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.test)("lock ttl bounds", () => {
|
|
28
|
+
delete process.env.LOCK_TTL_SECONDS;
|
|
29
|
+
(0, vitest_1.expect)((0, config_1.getLockTtlSeconds)()).toBe(30);
|
|
30
|
+
process.env.LOCK_TTL_SECONDS = "5";
|
|
31
|
+
(0, vitest_1.expect)((0, config_1.getLockTtlSeconds)()).toBe(30);
|
|
32
|
+
process.env.LOCK_TTL_SECONDS = "10";
|
|
33
|
+
(0, vitest_1.expect)((0, config_1.getLockTtlSeconds)()).toBe(10);
|
|
34
|
+
process.env.LOCK_TTL_SECONDS = "29";
|
|
35
|
+
(0, vitest_1.expect)((0, config_1.getLockTtlSeconds)()).toBe(29);
|
|
36
|
+
});
|
|
37
|
+
(0, vitest_1.test)("max fee env", () => {
|
|
38
|
+
delete process.env.MAX_FEE;
|
|
39
|
+
(0, vitest_1.expect)(typeof (0, config_1.getMaxFee)()).toBe("number");
|
|
40
|
+
process.env.MAX_FEE = "12345";
|
|
41
|
+
(0, vitest_1.expect)((0, config_1.getMaxFee)()).toBe(12345);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fee.test.d.ts","sourceRoot":"","sources":["../../src/test/fee.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const fee_1 = require("../fee");
|
|
5
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
6
|
+
(0, vitest_1.describe)("fee", () => {
|
|
7
|
+
const passphrase = stellar_sdk_1.Networks.TESTNET;
|
|
8
|
+
function buildSimpleTx() {
|
|
9
|
+
const acc = new stellar_sdk_1.Account("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", "1");
|
|
10
|
+
return new stellar_sdk_1.TransactionBuilder(acc, {
|
|
11
|
+
fee: "100",
|
|
12
|
+
networkPassphrase: passphrase,
|
|
13
|
+
})
|
|
14
|
+
.setTimeout(30)
|
|
15
|
+
.build();
|
|
16
|
+
}
|
|
17
|
+
(0, vitest_1.test)("non-soroban uses offset + base", () => {
|
|
18
|
+
const orig = Math.random;
|
|
19
|
+
Math.random = () => 0; // pick min base fee
|
|
20
|
+
const tx = buildSimpleTx();
|
|
21
|
+
const fee = (0, fee_1.calculateMaxFee)(tx);
|
|
22
|
+
(0, vitest_1.expect)(typeof fee).toBe("number");
|
|
23
|
+
(0, vitest_1.expect)(fee).toBeGreaterThan(0);
|
|
24
|
+
Math.random = orig;
|
|
25
|
+
});
|
|
26
|
+
(0, vitest_1.test)("clamps to MAX_FEE when set", () => {
|
|
27
|
+
const tx = buildSimpleTx();
|
|
28
|
+
const old = process.env.MAX_FEE;
|
|
29
|
+
process.env.MAX_FEE = "1000";
|
|
30
|
+
const fee = (0, fee_1.calculateMaxFee)(tx);
|
|
31
|
+
(0, vitest_1.expect)(fee).toBeLessThanOrEqual(1000);
|
|
32
|
+
process.env.MAX_FEE = old;
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PluginKVStore } from "@openzeppelin/relayer-sdk";
|
|
2
|
+
export declare class FakeKV implements PluginKVStore {
|
|
3
|
+
private store;
|
|
4
|
+
get<T = any>(key: string): Promise<T | null>;
|
|
5
|
+
set(key: string, value: any, opts?: {
|
|
6
|
+
ttlSec?: number;
|
|
7
|
+
}): Promise<boolean>;
|
|
8
|
+
del(key: string): Promise<boolean>;
|
|
9
|
+
exists(key: string): Promise<boolean>;
|
|
10
|
+
withLock<T>(key: string, fn: () => Promise<T>, opts?: {
|
|
11
|
+
ttlSec?: number;
|
|
12
|
+
onBusy?: "throw" | "skip";
|
|
13
|
+
}): Promise<T | null>;
|
|
14
|
+
listKeys(_pattern?: string): Promise<string[]>;
|
|
15
|
+
clear(): Promise<number>;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=fakeKV.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeKV.d.ts","sourceRoot":"","sources":["../../../src/test/helpers/fakeKV.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAI/D,qBAAa,MAAO,YAAW,aAAa;IAC1C,OAAO,CAAC,KAAK,CAA4B;IAEnC,GAAG,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAW5C,GAAG,CACP,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,GAAG,EACV,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,OAAO,CAAC;IASb,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKrC,QAAQ,CAAC,CAAC,EACd,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAgBd,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAI9C,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;CAK/B"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FakeKV = void 0;
|
|
4
|
+
class FakeKV {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.store = new Map();
|
|
7
|
+
}
|
|
8
|
+
async get(key) {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
const e = this.store.get(key);
|
|
11
|
+
if (!e)
|
|
12
|
+
return null;
|
|
13
|
+
if (e.expiresAt && e.expiresAt <= now) {
|
|
14
|
+
this.store.delete(key);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return e.value;
|
|
18
|
+
}
|
|
19
|
+
async set(key, value, opts) {
|
|
20
|
+
const entry = { value };
|
|
21
|
+
if (opts?.ttlSec && opts.ttlSec > 0) {
|
|
22
|
+
entry.expiresAt = Date.now() + opts.ttlSec * 1000;
|
|
23
|
+
}
|
|
24
|
+
this.store.set(key, entry);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
async del(key) {
|
|
28
|
+
return this.store.delete(key);
|
|
29
|
+
}
|
|
30
|
+
async exists(key) {
|
|
31
|
+
const v = await this.get(key);
|
|
32
|
+
return v !== null && v !== undefined;
|
|
33
|
+
}
|
|
34
|
+
async withLock(key, fn, opts) {
|
|
35
|
+
// Simple, non-reentrant lock for testing; not robust
|
|
36
|
+
if (await this.exists(key)) {
|
|
37
|
+
if (opts?.onBusy === "skip")
|
|
38
|
+
return null;
|
|
39
|
+
throw new Error("lock busy");
|
|
40
|
+
}
|
|
41
|
+
// Respect requested TTL to better emulate production behavior
|
|
42
|
+
const ttl = opts?.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : 1;
|
|
43
|
+
await this.set(key, { token: "lock" }, { ttlSec: ttl });
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await this.del(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async listKeys(_pattern) {
|
|
52
|
+
return Array.from(this.store.keys());
|
|
53
|
+
}
|
|
54
|
+
async clear() {
|
|
55
|
+
const n = this.store.size;
|
|
56
|
+
this.store.clear();
|
|
57
|
+
return n;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.FakeKV = FakeKV;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"management.test.d.ts","sourceRoot":"","sources":["../../src/test/management.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const management_1 = require("../management");
|
|
5
|
+
const fakeKV_1 = require("./helpers/fakeKV");
|
|
6
|
+
(0, vitest_1.describe)("management", () => {
|
|
7
|
+
const OLD_ENV = process.env;
|
|
8
|
+
(0, vitest_1.beforeEach)(() => {
|
|
9
|
+
process.env = { ...OLD_ENV };
|
|
10
|
+
process.env.PLUGIN_ADMIN_SECRET = "test";
|
|
11
|
+
process.env.STELLAR_NETWORK = "testnet";
|
|
12
|
+
process.env.SOROBAN_RPC_URL = "http://localhost:9999";
|
|
13
|
+
process.env.FUND_RELAYER_ID = "fund";
|
|
14
|
+
});
|
|
15
|
+
(0, vitest_1.afterEach)(() => {
|
|
16
|
+
process.env = OLD_ENV;
|
|
17
|
+
});
|
|
18
|
+
(0, vitest_1.test)("isManagementRequest detects shape", () => {
|
|
19
|
+
(0, vitest_1.expect)((0, management_1.isManagementRequest)({ management: {} })).toBe(true);
|
|
20
|
+
(0, vitest_1.expect)((0, management_1.isManagementRequest)({})).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
(0, vitest_1.test)("list and set channel accounts", async () => {
|
|
23
|
+
const kv = new fakeKV_1.FakeKV();
|
|
24
|
+
const ctx = {
|
|
25
|
+
kv,
|
|
26
|
+
params: {
|
|
27
|
+
management: { adminSecret: "test", action: "listChannelAccounts" },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const list1 = await (0, management_1.handleManagement)(ctx);
|
|
31
|
+
(0, vitest_1.expect)(Array.isArray(list1.relayerIds)).toBe(true);
|
|
32
|
+
const ctxSet = {
|
|
33
|
+
kv,
|
|
34
|
+
params: {
|
|
35
|
+
management: {
|
|
36
|
+
adminSecret: "test",
|
|
37
|
+
action: "setChannelAccounts",
|
|
38
|
+
relayerIds: ["A", "B"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const set = await (0, management_1.handleManagement)(ctxSet);
|
|
43
|
+
(0, vitest_1.expect)(set.ok).toBe(true);
|
|
44
|
+
(0, vitest_1.expect)(set.appliedRelayerIds).toEqual(["a", "b"]);
|
|
45
|
+
const list2 = await (0, management_1.handleManagement)(ctx);
|
|
46
|
+
(0, vitest_1.expect)(list2.relayerIds).toEqual(["a", "b"]);
|
|
47
|
+
});
|
|
48
|
+
(0, vitest_1.test)("locked conflict on removal", async () => {
|
|
49
|
+
const kv = new fakeKV_1.FakeKV();
|
|
50
|
+
// Seed list
|
|
51
|
+
await kv.set("testnet:channel:relayer-ids", { relayerIds: ["a", "b"] });
|
|
52
|
+
// Simulate lock on 'b'
|
|
53
|
+
await kv.set("testnet:channel:in-use:b", { token: "t" });
|
|
54
|
+
const ctx = {
|
|
55
|
+
kv,
|
|
56
|
+
params: {
|
|
57
|
+
management: {
|
|
58
|
+
adminSecret: "test",
|
|
59
|
+
action: "setChannelAccounts",
|
|
60
|
+
relayerIds: ["a"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
await (0, vitest_1.expect)((0, management_1.handleManagement)(ctx)).rejects.toThrow("Locked relayer IDs cannot be removed");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pool.busy.test.d.ts","sourceRoot":"","sources":["../../src/test/pool.busy.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const pool_1 = require("../pool");
|
|
5
|
+
const fakeKV_1 = require("./helpers/fakeKV");
|
|
6
|
+
(0, vitest_1.describe)("ChannelPool busy mutex", () => {
|
|
7
|
+
(0, vitest_1.test)("acquire retries when global mutex is busy, then succeeds", async () => {
|
|
8
|
+
const kv = new fakeKV_1.FakeKV();
|
|
9
|
+
const pool = new pool_1.ChannelPool("testnet", kv);
|
|
10
|
+
// Configure a single relayer
|
|
11
|
+
await kv.set("testnet:channel:relayer-ids", { relayerIds: ["p1"] });
|
|
12
|
+
// Simulate busy global mutex briefly, then release
|
|
13
|
+
const globalKey = "testnet:channel-pool-lock";
|
|
14
|
+
await kv.set(globalKey, { token: "busy" });
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
void kv.del(globalKey);
|
|
17
|
+
}, 50);
|
|
18
|
+
const lock = await pool.acquire();
|
|
19
|
+
(0, vitest_1.expect)(lock.relayerId).toBe("p1");
|
|
20
|
+
// Release for cleanliness
|
|
21
|
+
await pool.release(lock);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pool.test.d.ts","sourceRoot":"","sources":["../../src/test/pool.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const pool_1 = require("../pool");
|
|
5
|
+
const fakeKV_1 = require("./helpers/fakeKV");
|
|
6
|
+
(0, vitest_1.describe)("ChannelPool", () => {
|
|
7
|
+
(0, vitest_1.test)("acquire distributes and release removes lock", async () => {
|
|
8
|
+
const kv = new fakeKV_1.FakeKV();
|
|
9
|
+
const pool = new pool_1.ChannelPool("testnet", kv);
|
|
10
|
+
await kv.set("testnet:channel:relayer-ids", { relayerIds: ["p1", "p2"] });
|
|
11
|
+
const l1 = await pool.acquire();
|
|
12
|
+
const l2 = await pool.acquire();
|
|
13
|
+
(0, vitest_1.expect)(["p1", "p2"]).toContain(l1.relayerId);
|
|
14
|
+
(0, vitest_1.expect)(["p1", "p2"]).toContain(l2.relayerId);
|
|
15
|
+
(0, vitest_1.expect)(l1.relayerId).not.toEqual(l2.relayerId);
|
|
16
|
+
// Next acquire should fail (both locked)
|
|
17
|
+
await (0, vitest_1.expect)(pool.acquire()).rejects.toThrow("Too many transactions queued");
|
|
18
|
+
// Release one and ensure lock key gone
|
|
19
|
+
await pool.release(l1);
|
|
20
|
+
const stillLocked = await kv.exists(`testnet:channel:in-use:${l1.relayerId}`);
|
|
21
|
+
(0, vitest_1.expect)(stillLocked).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.test)("acquire fails on empty membership", async () => {
|
|
24
|
+
const kv = new fakeKV_1.FakeKV();
|
|
25
|
+
const pool = new pool_1.ChannelPool("testnet", kv);
|
|
26
|
+
await kv.set("testnet:channel:relayer-ids", { relayerIds: [] });
|
|
27
|
+
await (0, vitest_1.expect)(pool.acquire()).rejects.toThrow("No channel accounts configured");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tx.test.d.ts","sourceRoot":"","sources":["../../src/test/tx.test.ts"],"names":[],"mappings":""}
|