Skip to content

Commit 4dd6464

Browse files
committed
feat: add support for new event kind 30935 and enhance copy-trade functionality
- Updated relayer configuration to include new allowed kind 30935. - Modified trader configuration to replace platform_shared_key with relayer_nostr_pubkey. - Enhanced README and documentation to reflect changes in configuration. - Implemented agent registration event handling in event router. - Updated signal service to use EncryptedDirectMessage for payload encryption. - Added agent registration payload and event classes. - Improved copy-trade listener to handle encrypted messages. - Refactored trading bot to support follower mode in copy-trading. - Introduced new test parameters for strategies to allow configuration overrides.
1 parent 2f92c75 commit 4dd6464

File tree

15 files changed

+506
-112
lines changed

15 files changed

+506
-112
lines changed

docker/relayer.config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ lru_size = 100_000
1111
rocksdb_path = "./data/rocksdb"
1212

1313
[filters]
14-
allowed_kinds = [30931, 30932, 30933, 30934]
14+
allowed_kinds = [30931, 30932, 30933, 30934, 30935]
1515

1616
[output]
1717
websocket_enabled = true

docker/trader.config.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
},
5555
"nostr": {
5656
"nsec": "nsec1yourprivatekey",
57-
"platform_shared_key": "64_hex_shared_key_from_platform",
57+
"relayer_nostr_pubkey": "npub1relayerpublickeyplaceholderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
5858
"relays": [
5959
"wss://nostr.parallel.hetu.org:8443"
6060
],
@@ -63,12 +63,11 @@
6363
},
6464
"copytrade": {
6565
"enabled": false,
66+
"role": "leader",
6667
"follow_pubkeys": [
67-
"npub_of_leader"
68-
],
69-
"symbols": [
70-
"HYPE"
68+
"0xleader_eth_address"
7169
],
70+
"symbols": [],
7271
"size_pct": 0.05,
7372
"min_order_value": 10.0
7473
}

relayer/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Config snippet:
111111

112112
```toml
113113
[filters]
114-
allowed_kinds = [30931, 30932, 30933, 30934]
114+
allowed_kinds = [30931, 30932, 30933, 30934, 30935]
115115

116116
[postgres]
117117
dsn = "postgres://postgres:postgres@localhost:5432/moltrade"

relayer/config.template.toml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@ lru_size = 100000
55
rocksdb_path = "./data/rocksdb"
66

77
[filters]
8-
allowed_kinds = [
9-
30931,
10-
30932,
11-
30933,
12-
30934,
13-
]
8+
allowed_kinds = [30931, 30932, 30933, 30934, 30935]
149

1510
[monitoring]
1611
log_level = "debug"
@@ -31,7 +26,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/moltrade"
3126
max_connections = 5
3227

3328
[relay]
34-
bootstrap_relays = ["ws://62.72.41.239:8000"]
29+
bootstrap_relays = ["wss://nostr.parallel.hetu.org:8443"]
3530
health_check_interval = 30
3631
max_connections = 10000
3732

relayer/src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub struct FilterConfig {
3939
}
4040

4141
fn default_allowed_kinds() -> Vec<u16> {
42-
vec![30931, 30932, 30933, 30934]
42+
vec![30931, 30932, 30933, 30934, 30935]
4343
}
4444

4545
#[derive(Debug, Clone, Deserialize)]

relayer/src/core/event_router.rs

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ use crate::core::dedupe_engine::DeduplicationEngine;
1212
use crate::core::subscription::{FanoutMessage, SubscriptionService};
1313
use nostr_sdk::Kind;
1414
use nostr_sdk::nips::nip04;
15-
use nostr_sdk::prelude::{Client, EventBuilder, Keys, PublicKey, Tag};
15+
use nostr_sdk::prelude::{Client, EventBuilder, Keys, PublicKey, Tag, Timestamp};
1616
use serde_json::Value;
1717
use std::str::FromStr;
1818

19+
const KIND_TRADE_SIGNAL: u16 = 30931;
20+
const KIND_COPYTRADE_INTENT: u16 = 30932;
21+
const KIND_HEARTBEAT: u16 = 30933;
22+
const KIND_EXECUTION_REPORT: u16 = 30934;
23+
const KIND_AGENT_REGISTER: u16 = 30935;
24+
const STALE_AFTER: Duration = Duration::from_secs(10 * 60);
25+
1926
/// Wrapper for Event to enable sorting by timestamp
2027
#[derive(Clone)]
2128
struct EventWrapper {
@@ -190,6 +197,17 @@ impl EventRouter {
190197

191198
// Send events to downstream in timestamp order
192199
for event in batch {
200+
if self.is_stale(&event) {
201+
debug!(
202+
"Skip stale event id={} kind={} age_secs={}",
203+
event.id.to_hex(),
204+
event.kind.as_u16(),
205+
Timestamp::now()
206+
.as_secs()
207+
.saturating_sub(event.created_at.as_secs())
208+
);
209+
continue;
210+
}
193211
self.maybe_update_last_seen(&event).await;
194212
if let Err(e) = self.handle_copytrade_fanout(&event).await {
195213
error!("Fanout processing failed: {}", e);
@@ -237,6 +255,72 @@ impl EventRouter {
237255
}
238256

239257
async fn handle_copytrade_fanout(&self, event: &Event) -> Result<()> {
258+
// Short-circuit heartbeat-like events: no decrypt/fanout required
259+
if matches!(event.kind.as_u16(), KIND_HEARTBEAT | KIND_EXECUTION_REPORT) {
260+
return Ok(());
261+
}
262+
263+
// Agent registration is plaintext and upserts the bot record
264+
if event.kind.as_u16() == KIND_AGENT_REGISTER {
265+
let subs = match &self.subscription_service {
266+
Some(s) => s,
267+
None => return Ok(()),
268+
};
269+
270+
let parsed: Value = match serde_json::from_str(&event.content) {
271+
Ok(v) => v,
272+
Err(e) => {
273+
error!(
274+
"Agent register decode failed for {}: {}",
275+
event.id.to_hex(),
276+
e
277+
);
278+
return Ok(());
279+
}
280+
};
281+
282+
let nostr_pubkey = parsed
283+
.get("nostr_pubkey")
284+
.and_then(|v| v.as_str())
285+
.map(|s| s.to_string())
286+
.unwrap_or_else(|| event.pubkey.to_hex());
287+
let bot_pubkey = parsed
288+
.get("bot_pubkey")
289+
.and_then(|v| v.as_str())
290+
.map(|s| s.to_string())
291+
.unwrap_or_else(|| event.pubkey.to_hex());
292+
let eth_address = parsed
293+
.get("eth_address")
294+
.or_else(|| parsed.get("account"))
295+
.and_then(|v| v.as_str())
296+
.unwrap_or("")
297+
.to_string();
298+
let name = parsed
299+
.get("name")
300+
.and_then(|v| v.as_str())
301+
.unwrap_or("agent")
302+
.to_string();
303+
304+
if eth_address.is_empty() {
305+
error!("Agent register missing eth_address for {}", bot_pubkey);
306+
return Ok(());
307+
}
308+
309+
if let Err(e) = subs
310+
.register_bot(&bot_pubkey, &nostr_pubkey, &eth_address, &name)
311+
.await
312+
{
313+
error!("Agent register upsert failed for {}: {}", bot_pubkey, e);
314+
} else {
315+
info!(
316+
"Registered bot via nostr: bot_pubkey={} eth={}",
317+
bot_pubkey, eth_address
318+
);
319+
}
320+
321+
return Ok(());
322+
}
323+
240324
// Preconditions: need subscription service and platform nostr keys
241325
let subs = match &self.subscription_service {
242326
Some(s) => s,
@@ -247,6 +331,12 @@ impl EventRouter {
247331
None => return Ok(()),
248332
};
249333

334+
// Skip decrypting events we just published (self-sent fanout echoes)
335+
if event.pubkey == nostr_keys.public_key() {
336+
debug!("Skip self-published fanout event {}", event.id.to_hex());
337+
return Ok(());
338+
}
339+
250340
// Decrypt content using platform key and sender pubkey
251341
let plaintext = match nip04::decrypt(nostr_keys.secret_key(), &event.pubkey, &event.content)
252342
{
@@ -257,6 +347,19 @@ impl EventRouter {
257347
}
258348
};
259349

350+
let preview = if plaintext.len() > 256 {
351+
format!("{}...", &plaintext[..256])
352+
} else {
353+
plaintext.clone()
354+
};
355+
debug!(
356+
"Decrypted nostr event id={} kind={} from={} preview={}",
357+
event.id.to_hex(),
358+
event.kind.as_u16(),
359+
event.pubkey.to_hex(),
360+
preview,
361+
);
362+
260363
// Extract agent eth address from JSON payload
261364
let agent_eth = extract_agent_eth(&plaintext)
262365
.ok_or_else(|| anyhow::anyhow!("agent eth address missing"))?;
@@ -299,12 +402,13 @@ impl EventRouter {
299402
// Publish encrypted nostr events to followers if client exists
300403
if let Some(client) = &self.nostr_client {
301404
for follower in followers {
302-
let follower_pk = match PublicKey::from_str(&follower.follower_pubkey) {
405+
let follower_pk_str = follower.shared_secret.as_str();
406+
let follower_pk = match PublicKey::from_str(follower_pk_str) {
303407
Ok(pk) => pk,
304408
Err(e) => {
305409
error!(
306-
"Invalid follower pubkey {}: {}",
307-
follower.follower_pubkey, e
410+
"Invalid follower shared_secret pubkey {}: {}",
411+
follower_pk_str, e
308412
);
309413
continue;
310414
}
@@ -314,10 +418,7 @@ impl EventRouter {
314418
match nip04::encrypt(nostr_keys.secret_key(), &follower_pk, &plaintext) {
315419
Ok(ct) => ct,
316420
Err(e) => {
317-
error!(
318-
"Encrypt for follower {} failed: {}",
319-
follower.follower_pubkey, e
320-
);
421+
error!("Encrypt for follower {} failed: {}", follower_pk_str, e);
321422
continue;
322423
}
323424
};
@@ -326,10 +427,7 @@ impl EventRouter {
326427
builder = builder.tag(Tag::public_key(follower_pk));
327428

328429
if let Err(e) = client.send_event_builder(builder).await {
329-
error!(
330-
"Publish to follower {} failed: {}",
331-
follower.follower_pubkey, e
332-
);
430+
error!("Publish to follower {} failed: {}", follower_pk_str, e);
333431
}
334432
}
335433
}
@@ -339,8 +437,14 @@ impl EventRouter {
339437
}
340438

341439
impl EventRouter {
440+
fn is_stale(&self, event: &Event) -> bool {
441+
let now = Timestamp::now().as_secs();
442+
let created = event.created_at.as_secs();
443+
now.saturating_sub(created) > STALE_AFTER.as_secs()
444+
}
445+
342446
async fn maybe_update_last_seen(&self, event: &Event) {
343-
const HEARTBEAT_KIND: u16 = 30934;
447+
const HEARTBEAT_KIND: u16 = KIND_HEARTBEAT;
344448
const MIN_INTERVAL: Duration = Duration::from_secs(15 * 60);
345449

346450
if event.kind.as_u16() != HEARTBEAT_KIND {
@@ -384,6 +488,7 @@ fn extract_agent_eth(plaintext: &str) -> Option<String> {
384488
parsed
385489
.get("agent_eth_address")
386490
.or_else(|| parsed.get("agent"))
491+
.or_else(|| parsed.get("account"))
387492
.or_else(|| parsed.get("eth_address"))
388493
.and_then(|v| v.as_str())
389494
.map(|s| s.to_string())

skills/moltrade/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Paths are repo-root relative. Keep actions deterministic and redact secrets.
1717

1818
- Read `trader/config.example.json` or `config.json` if present.
1919
- Summarize `trading` (exchange/default_strategy/sizing/stops), `risk_management`, `nostr`, `telegram`.
20-
- Do not log keys; redact `wallet_address`, `private_key`, `nostr.nsec`, `platform_shared_key`.
20+
- Do not log keys; redact `wallet_address`, `private_key`, `nostr.nsec`, `nostr.relayer_nostr_pubkey`.
2121

2222
## Update Config Safely
2323

@@ -39,7 +39,7 @@ Paths are repo-root relative. Keep actions deterministic and redact secrets.
3939

4040
## Broadcast Signals to Nostr
4141

42-
- Check `nostr` block: `nsec`, `platform_shared_key`, `relays`, `sid`.
42+
- Check `nostr` block: `nsec`, `relayer_nostr_pubkey`, `relays`, `sid`.
4343
- `SignalBroadcaster` is wired in `main.py`. In test mode, verify `send_trade_signal` / `send_execution_report` run without errors.
4444

4545
## Add Exchange Adapter

tests/trader/trader_smoke.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def main() -> None:
1616
cfg = {
1717
"nostr": {
1818
"nsec": "", # keep disabled to avoid network work
19-
"platform_shared_key": "",
19+
"relayer_nostr_pubkey": "",
2020
"sid": "smoke",
2121
"role": "bot",
2222
"relays": [],
@@ -46,6 +46,7 @@ def main() -> None:
4646
pnl=None,
4747
pnl_percent=None,
4848
test_mode=True,
49+
account=None,
4950
note=None,
5051
) is False
5152

trader/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ cp config.example.json config.json
3131
- `wallet_address`, `private_key`: required for Hyperliquid (keep safe)
3232
- `trading.default_strategy`, `position_size`, stops
3333
- Optional: `telegram` block for alerts; `nostr` block for encrypted signal broadcast
34+
- Copy-trade follower: set `copytrade.enabled=true`, `copytrade.role="follower"`, and `copytrade.follow_pubkeys=["<leader_eth_address>"]`. You can copy the leader ETH address from the leaderboard on https://moltrade.ai/.
3435

3536
### Telegram (optional)
3637

@@ -71,7 +72,7 @@ To add your own, see `strategies/INTEGRATION.md` and register it in `get_strateg
7172

7273
## Nostr Signals (optional)
7374

74-
- Requires `nostr` config (`nsec`, `platform_shared_key`, `relays`).
75+
- Requires `nostr` config (`nsec`, `relayer_nostr_pubkey`, `relays`).
7576
- Bot broadcasts encrypted trade signals and execution reports via `SignalBroadcaster` (already wired in `main.py`).
7677

7778
## Risk Warning

trader/config.example.json

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"exchange": "hyperliquid",
1818
"default_symbol": "HYPE",
1919
"default_strategy": "test",
20+
"refresh_interval_seconds": 60,
2021
"position_size": 0.08,
2122
"max_position_size": 0.2,
2223
"stop_loss_percent": 0.03,
@@ -46,16 +47,23 @@
4647
"trend_following": {
4748
"min_trade_interval": 14400,
4849
"min_adx": 30
50+
},
51+
"test": {
52+
"rsi_oversold": 35,
53+
"rsi_overbought": 65,
54+
"adx_threshold": 20,
55+
"signal_threshold": 0.3,
56+
"required_conditions": 2
4957
}
5058
},
5159
"risk_management": {
52-
"max_daily_loss": 0.05,
53-
"max_trades_per_day": 8,
54-
"cool_down_seconds": 300
60+
"max_daily_loss": 0.5,
61+
"max_trades_per_day": 500,
62+
"cool_down_seconds": 60
5563
},
5664
"nostr": {
57-
"nsec": "nsec1yourprivatekey",
58-
"platform_shared_key": "64_hex_shared_key_from_platform",
65+
"nsec": "nsec19rs65lkqpqqhmft5s97cw94u627mfrhunt02jsftn9fycxv3dhhs0p45dh",
66+
"relayer_nostr_pubkey": "npub1relayerpublickeyplaceholderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
5967
"relays": [
6068
"wss://nostr.parallel.hetu.org:8443"
6169
],
@@ -64,12 +72,11 @@
6472
},
6573
"copytrade": {
6674
"enabled": false,
75+
"role": "leader",
6776
"follow_pubkeys": [
68-
"npub_of_leader"
69-
],
70-
"symbols": [
71-
"HYPE"
77+
"0xleader_eth_address"
7278
],
79+
"symbols": [],
7380
"size_pct": 0.05,
7481
"min_order_value": 10.0
7582
}

0 commit comments

Comments
 (0)