From 7575078d039d0090e7ea01af0b3060b217a1d729 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 18:55:04 +0200 Subject: [PATCH 01/21] test(arch): vendor fixtures from sub-projects 1+2 for parity tests --- .../bridge/internal/arch/testdata/README.md | 13 +++++++++++ .../internal/arch/testdata/intent_a.json | 6 +++++ .../internal/arch/testdata/intent_b.json | 6 +++++ .../internal/arch/testdata/pda_vectors.json | 22 +++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 services/bridge/internal/arch/testdata/README.md create mode 100644 services/bridge/internal/arch/testdata/intent_a.json create mode 100644 services/bridge/internal/arch/testdata/intent_b.json create mode 100644 services/bridge/internal/arch/testdata/pda_vectors.json diff --git a/services/bridge/internal/arch/testdata/README.md b/services/bridge/internal/arch/testdata/README.md new file mode 100644 index 0000000..6513039 --- /dev/null +++ b/services/bridge/internal/arch/testdata/README.md @@ -0,0 +1,13 @@ +# Arch destination test fixtures + +Vendored from sub-projects 1 (`arch-oracle-program`) and 2 (`arch-oracle-cli`). +The Borsh and PDA helpers in this package assert byte-identical parity against +these fixtures so the wire format stays in lockstep with the on-chain receiver. + +| File | Source | Use | +|---|---|---| +| `intent_a.json` | `arch-oracle-program/crates/shared/tests/data/intent_a.json` | `OracleIntent` Borsh round-trip | +| `intent_b.json` | `arch-oracle-program/crates/shared/tests/data/intent_b.json` | Same, second seed | +| `pda_vectors.json` | `arch-oracle-cli/test/fixtures/pda_vectors.json` | PDA derivation parity (5 helpers) | + +To regenerate: see the sub-project READMEs. Do not edit by hand. diff --git a/services/bridge/internal/arch/testdata/intent_a.json b/services/bridge/internal/arch/testdata/intent_a.json new file mode 100644 index 0000000..e4f0593 --- /dev/null +++ b/services/bridge/internal/arch/testdata/intent_a.json @@ -0,0 +1,6 @@ +{ + "intent_hex": "0b00000050726963655570646174650100000031000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000110000000000000000000000000000000000000000000000000000000000000000070000004254432f55534400000000000000000000000000000000000000000000000000003b1dfde91000000000000000000000000000000000000000000000000000000000006553f1110300000044494141000000d2760ac5f293385a64acf8ea9727fcf557ffeb404be7b413471f30658e13b2c529aa0f87fe24e952791b7fbaabf8276269d70507405a108ba04982a6111053bc1b19e7e376e7c213b7e7e7e46cc70a5dd086daff2a", + "domain_separator_hex": "d0cfa1e34209d3db5d737b8474f3b4819d2ac97244a9a2b087b0a673011750fe", + "digest_hex": "34f3439b938cb0776a01ab33fc9876ee85601c47903efbe3c01a7d3937072dfa", + "signer_hex": "19e7e376e7c213b7e7e7e46cc70a5dd086daff2a" +} \ No newline at end of file diff --git a/services/bridge/internal/arch/testdata/intent_b.json b/services/bridge/internal/arch/testdata/intent_b.json new file mode 100644 index 0000000..dbe8295 --- /dev/null +++ b/services/bridge/internal/arch/testdata/intent_b.json @@ -0,0 +1,6 @@ +{ + "intent_hex": "0b00000050726963655570646174650100000031000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000000070000004254432f55534400000000000000000000000000000000000000000000000000003b1dfde91000000000000000000000000000000000000000000000000000000000006553f1220300000044494141000000cff43d93642f7c4e0df1e34b8feb2a2add5db9a1b8e044dd1fc81d605782182c26f24a76f15a594d81d99c46a160640cc094eb2da91d883da2b51418d5dcce521b1563915e194d8cfba1943570603f7606a3115508", + "domain_separator_hex": "d0cfa1e34209d3db5d737b8474f3b4819d2ac97244a9a2b087b0a673011750fe", + "digest_hex": "c0dd6508fa002cf26efcac88476467557e1a70662371775ae10529113f995878", + "signer_hex": "1563915e194d8cfba1943570603f7606a3115508" +} \ No newline at end of file diff --git a/services/bridge/internal/arch/testdata/pda_vectors.json b/services/bridge/internal/arch/testdata/pda_vectors.json new file mode 100644 index 0000000..c2e07ed --- /dev/null +++ b/services/bridge/internal/arch/testdata/pda_vectors.json @@ -0,0 +1,22 @@ +{ + "636f6e666967": { + "bump": 255, + "pubkey": "e8353dd1a510292b55a937281182ba0e487dff58f787dfbab990e423a0a6314b" + }, + "6465647570": { + "bump": 255, + "pubkey": "e8332ce876932197a1f84e2ed6177444d6d552bcf4352a9f80ce9357a1f48395" + }, + "6665655f636f6e666967": { + "bump": 255, + "pubkey": "165cd916dafa742e26b7f64e080f2365e24c86de74d32830b9d26713c496ae80" + }, + "6665655f7661756c74": { + "bump": 255, + "pubkey": "32b4de6caf4568a355aa5c5b1c41b91e70a0e35eb314f90119a7a775c2cfd9a3" + }, + "7072696365|sha256:BTC/USD": { + "bump": 255, + "pubkey": "41b3d044976c1e96652ee39a69f9dfc40d21c1b2bb90a9e7ad4b4107437b2224" + } +} From 6e3aa68a27a671fbe84c3b01ea5593daeb7de164 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 18:57:43 +0200 Subject: [PATCH 02/21] feat(arch): package scaffold + Pubkey/EthAddress/U256/OracleIntent types --- services/bridge/internal/arch/doc.go | 8 +++ services/bridge/internal/arch/types.go | 68 +++++++++++++++++++++ services/bridge/internal/arch/types_test.go | 49 +++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 services/bridge/internal/arch/doc.go create mode 100644 services/bridge/internal/arch/types.go create mode 100644 services/bridge/internal/arch/types_test.go diff --git a/services/bridge/internal/arch/doc.go b/services/bridge/internal/arch/doc.go new file mode 100644 index 0000000..cdb2c09 --- /dev/null +++ b/services/bridge/internal/arch/doc.go @@ -0,0 +1,8 @@ +// Package arch implements the wire-format primitives the Spectra bridge needs +// to talk to Arch Network: Borsh serialization for OracleIntent and the +// receiver/fee-hook instruction enums, PDA derivation, BIP-322 Taproot +// signing, a thin JSON-RPC client, and DIA_ORACLE.* log parsing. +// +// Parity with the on-chain receiver (sub-project 1) is asserted against the +// JSON fixtures under testdata/. +package arch diff --git a/services/bridge/internal/arch/types.go b/services/bridge/internal/arch/types.go new file mode 100644 index 0000000..2d1c336 --- /dev/null +++ b/services/bridge/internal/arch/types.go @@ -0,0 +1,68 @@ +package arch + +import ( + "fmt" + "math/big" +) + +// Pubkey is a 32-byte Arch public key. +type Pubkey [32]byte + +// EthAddress is the 20-byte Ethereum address that signs an OracleIntent. +type EthAddress [20]byte + +// U256 is a 32-byte big-endian unsigned 256-bit integer. +type U256 [32]byte + +// SystemProgramID is the canonical Arch system program ID: 32 zero bytes +// (base58 "11111111111111111111111111111111"). +var SystemProgramID = Pubkey{} + +// OracleIntent mirrors dia_arch_shared::intent::OracleIntent from sub-project 1. +// Field order is wire-binding: Borsh serializes struct fields in declaration order. +type OracleIntent struct { + IntentType string + Version string + ChainID U256 + Nonce U256 + Expiry U256 + Symbol string + Price U256 + Timestamp U256 + Source string + Signature []byte + Signer EthAddress +} + +// AccountMeta describes one account passed to an instruction. +type AccountMeta struct { + Pubkey Pubkey + IsSigner bool + IsWritable bool +} + +// Instruction is a top-level Arch instruction. +type Instruction struct { + ProgramID Pubkey + Accounts []AccountMeta + Data []byte +} + +// U256FromBigInt encodes x as a 32-byte big-endian U256. Panics on negative input. +func U256FromBigInt(x *big.Int) U256 { + if x.Sign() < 0 { + panic(fmt.Sprintf("U256FromBigInt: negative input %s", x)) + } + var out U256 + b := x.Bytes() // big-endian, leading zeros stripped + if len(b) > 32 { + panic(fmt.Sprintf("U256FromBigInt: value overflows U256 (%d bytes)", len(b))) + } + copy(out[32-len(b):], b) + return out +} + +// BigIntFromU256 decodes a U256 back to a positive *big.Int. +func BigIntFromU256(u U256) *big.Int { + return new(big.Int).SetBytes(u[:]) +} diff --git a/services/bridge/internal/arch/types_test.go b/services/bridge/internal/arch/types_test.go new file mode 100644 index 0000000..806453e --- /dev/null +++ b/services/bridge/internal/arch/types_test.go @@ -0,0 +1,49 @@ +package arch + +import ( + "math/big" + "testing" +) + +func TestU256FromBigInt(t *testing.T) { + cases := []struct { + name string + in *big.Int + want string // hex + }{ + {"zero", big.NewInt(0), "0000000000000000000000000000000000000000000000000000000000000000"}, + {"one", big.NewInt(1), "0000000000000000000000000000000000000000000000000000000000000001"}, + {"max64", new(big.Int).SetUint64(^uint64(0)), "000000000000000000000000000000000000000000000000ffffffffffffffff"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := U256FromBigInt(c.in) + if hex := bytesToHex(got[:]); hex != c.want { + t.Fatalf("U256FromBigInt(%s) = %s; want %s", c.in, hex, c.want) + } + }) + } +} + +func TestU256RoundTrip(t *testing.T) { + for _, raw := range []string{"00", "01", "ff", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"} { + bi, ok := new(big.Int).SetString(raw, 16) + if !ok { + t.Fatalf("bad hex %s", raw) + } + got := BigIntFromU256(U256FromBigInt(bi)) + if got.Cmp(bi) != 0 { + t.Fatalf("round-trip failed: %s -> %s", bi, got) + } + } +} + +func bytesToHex(b []byte) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, x := range b { + out[i*2] = hexdigits[x>>4] + out[i*2+1] = hexdigits[x&0x0f] + } + return string(out) +} From d0d8184379db79b05d77034dea4ca636a2ebbf00 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:01:14 +0200 Subject: [PATCH 03/21] feat(arch): Borsh codec for OracleIntent with parity against intent_a/b.json --- services/bridge/internal/arch/borsh.go | 144 ++++++++++++++++++++ services/bridge/internal/arch/borsh_test.go | 58 ++++++++ 2 files changed, 202 insertions(+) create mode 100644 services/bridge/internal/arch/borsh.go create mode 100644 services/bridge/internal/arch/borsh_test.go diff --git a/services/bridge/internal/arch/borsh.go b/services/bridge/internal/arch/borsh.go new file mode 100644 index 0000000..13e01ab --- /dev/null +++ b/services/bridge/internal/arch/borsh.go @@ -0,0 +1,144 @@ +package arch + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// MarshalOracleIntent Borsh-encodes an OracleIntent. Field order is wire-binding +// and matches dia_arch_shared::intent::OracleIntent declaration order. +func MarshalOracleIntent(intent OracleIntent) ([]byte, error) { + w := &borshWriter{} + w.writeString(intent.IntentType) + w.writeString(intent.Version) + w.writeBytes(intent.ChainID[:]) + w.writeBytes(intent.Nonce[:]) + w.writeBytes(intent.Expiry[:]) + w.writeString(intent.Symbol) + w.writeBytes(intent.Price[:]) + w.writeBytes(intent.Timestamp[:]) + w.writeString(intent.Source) + if err := w.writeByteVec(intent.Signature); err != nil { + return nil, err + } + w.writeBytes(intent.Signer[:]) + return w.buf, nil +} + +// UnmarshalOracleIntent decodes a Borsh-encoded OracleIntent. +func UnmarshalOracleIntent(data []byte) (OracleIntent, error) { + r := &borshReader{buf: data} + var out OracleIntent + var err error + if out.IntentType, err = r.readString(); err != nil { + return out, fmt.Errorf("intentType: %w", err) + } + if out.Version, err = r.readString(); err != nil { + return out, fmt.Errorf("version: %w", err) + } + if err = r.readFixed(out.ChainID[:]); err != nil { + return out, fmt.Errorf("chainId: %w", err) + } + if err = r.readFixed(out.Nonce[:]); err != nil { + return out, fmt.Errorf("nonce: %w", err) + } + if err = r.readFixed(out.Expiry[:]); err != nil { + return out, fmt.Errorf("expiry: %w", err) + } + if out.Symbol, err = r.readString(); err != nil { + return out, fmt.Errorf("symbol: %w", err) + } + if err = r.readFixed(out.Price[:]); err != nil { + return out, fmt.Errorf("price: %w", err) + } + if err = r.readFixed(out.Timestamp[:]); err != nil { + return out, fmt.Errorf("timestamp: %w", err) + } + if out.Source, err = r.readString(); err != nil { + return out, fmt.Errorf("source: %w", err) + } + if out.Signature, err = r.readByteVec(); err != nil { + return out, fmt.Errorf("signature: %w", err) + } + if err = r.readFixed(out.Signer[:]); err != nil { + return out, fmt.Errorf("signer: %w", err) + } + if r.remaining() != 0 { + return out, fmt.Errorf("trailing bytes: %d", r.remaining()) + } + return out, nil +} + +// ---- Borsh primitive helpers ---- + +type borshWriter struct{ buf []byte } + +func (w *borshWriter) writeBytes(b []byte) { w.buf = append(w.buf, b...) } + +func (w *borshWriter) writeString(s string) { + var lenBytes [4]byte + binary.LittleEndian.PutUint32(lenBytes[:], uint32(len(s))) + w.buf = append(w.buf, lenBytes[:]...) + w.buf = append(w.buf, s...) +} + +func (w *borshWriter) writeByteVec(b []byte) error { + var lenBytes [4]byte + binary.LittleEndian.PutUint32(lenBytes[:], uint32(len(b))) + w.buf = append(w.buf, lenBytes[:]...) + w.buf = append(w.buf, b...) + return nil +} + +type borshReader struct { + buf []byte + off int +} + +func (r *borshReader) remaining() int { return len(r.buf) - r.off } + +func (r *borshReader) readU32() (uint32, error) { + if r.remaining() < 4 { + return 0, errors.New("short read u32") + } + v := binary.LittleEndian.Uint32(r.buf[r.off : r.off+4]) + r.off += 4 + return v, nil +} + +func (r *borshReader) readFixed(dst []byte) error { + if r.remaining() < len(dst) { + return fmt.Errorf("short read %d bytes", len(dst)) + } + copy(dst, r.buf[r.off:r.off+len(dst)]) + r.off += len(dst) + return nil +} + +func (r *borshReader) readString() (string, error) { + n, err := r.readU32() + if err != nil { + return "", err + } + if r.remaining() < int(n) { + return "", fmt.Errorf("short read string of len %d", n) + } + s := string(r.buf[r.off : r.off+int(n)]) + r.off += int(n) + return s, nil +} + +func (r *borshReader) readByteVec() ([]byte, error) { + n, err := r.readU32() + if err != nil { + return nil, err + } + if r.remaining() < int(n) { + return nil, fmt.Errorf("short read vec of len %d", n) + } + out := make([]byte, n) + copy(out, r.buf[r.off:r.off+int(n)]) + r.off += int(n) + return out, nil +} diff --git a/services/bridge/internal/arch/borsh_test.go b/services/bridge/internal/arch/borsh_test.go new file mode 100644 index 0000000..cd5b9c1 --- /dev/null +++ b/services/bridge/internal/arch/borsh_test.go @@ -0,0 +1,58 @@ +package arch + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type fixture struct { + IntentHex string `json:"intent_hex"` + DomainSeparatorHex string `json:"domain_separator_hex"` + DigestHex string `json:"digest_hex"` + SignerHex string `json:"signer_hex"` +} + +func loadFixture(t *testing.T, name string) fixture { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var f fixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + return f +} + +func TestOracleIntentRoundTrip(t *testing.T) { + for _, name := range []string{"intent_a.json", "intent_b.json"} { + t.Run(name, func(t *testing.T) { + f := loadFixture(t, name) + raw, err := hex.DecodeString(f.IntentHex) + if err != nil { + t.Fatalf("decode intent_hex: %v", err) + } + intent, err := UnmarshalOracleIntent(raw) + if err != nil { + t.Fatalf("UnmarshalOracleIntent: %v", err) + } + reser, err := MarshalOracleIntent(intent) + if err != nil { + t.Fatalf("MarshalOracleIntent: %v", err) + } + if !bytes.Equal(reser, raw) { + t.Fatalf("round-trip mismatch:\n got %s\nwant %s", hex.EncodeToString(reser), f.IntentHex) + } + // Cross-check: the signer field decodes to fixture.signer_hex. + gotSigner := hex.EncodeToString(intent.Signer[:]) + if gotSigner != f.SignerHex { + t.Fatalf("signer mismatch: got %s want %s", gotSigner, f.SignerHex) + } + }) + } +} From a8386583615cf67e4884a969a8249f24ca780b21 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:07:46 +0200 Subject: [PATCH 04/21] feat(arch): HandleIntentUpdate instruction encoder --- services/bridge/internal/arch/instruction.go | 28 ++++++++++++++++ .../bridge/internal/arch/instruction_test.go | 32 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 services/bridge/internal/arch/instruction.go create mode 100644 services/bridge/internal/arch/instruction_test.go diff --git a/services/bridge/internal/arch/instruction.go b/services/bridge/internal/arch/instruction.go new file mode 100644 index 0000000..4764fd5 --- /dev/null +++ b/services/bridge/internal/arch/instruction.go @@ -0,0 +1,28 @@ +package arch + +// OracleInstruction variant discriminators. Order matches dia_arch_shared::instruction::OracleInstruction +// declaration order; Borsh enum encoding uses the variant index as a single leading u8. +const ( + OracleInstructionInitialize uint8 = 0 + OracleInstructionHandleIntentUpdate uint8 = 1 + OracleInstructionHandleBatchIntentUpdates uint8 = 2 + OracleInstructionSetSignerAuthorization uint8 = 3 + OracleInstructionSetDomainSeparator uint8 = 4 + OracleInstructionSetPaymentHook uint8 = 5 + OracleInstructionTransferOwnership uint8 = 6 + OracleInstructionRecoverLamports uint8 = 7 +) + +// BuildHandleIntentUpdateData Borsh-encodes +// OracleInstruction::HandleIntentUpdate { intent } +// as a single byte stream suitable for an Instruction.Data field. +func BuildHandleIntentUpdateData(intent OracleIntent) ([]byte, error) { + body, err := MarshalOracleIntent(intent) + if err != nil { + return nil, err + } + out := make([]byte, 0, 1+len(body)) + out = append(out, OracleInstructionHandleIntentUpdate) + out = append(out, body...) + return out, nil +} diff --git a/services/bridge/internal/arch/instruction_test.go b/services/bridge/internal/arch/instruction_test.go new file mode 100644 index 0000000..ec7aa49 --- /dev/null +++ b/services/bridge/internal/arch/instruction_test.go @@ -0,0 +1,32 @@ +package arch + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestBuildHandleIntentUpdateData(t *testing.T) { + for _, name := range []string{"intent_a.json", "intent_b.json"} { + t.Run(name, func(t *testing.T) { + f := loadFixture(t, name) + intentBytes, err := hex.DecodeString(f.IntentHex) + if err != nil { + t.Fatalf("decode intent_hex: %v", err) + } + intent, err := UnmarshalOracleIntent(intentBytes) + if err != nil { + t.Fatalf("UnmarshalOracleIntent: %v", err) + } + data, err := BuildHandleIntentUpdateData(intent) + if err != nil { + t.Fatalf("BuildHandleIntentUpdateData: %v", err) + } + // Expected: discriminator (0x01) + intent bytes. + expected := append([]byte{0x01}, intentBytes...) + if !bytes.Equal(data, expected) { + t.Fatalf("mismatch:\n got %s\nwant %s", hex.EncodeToString(data), hex.EncodeToString(expected)) + } + }) + } +} From eac9ae2e36d557d7def87e56957db5a1575fe71d Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:13:29 +0200 Subject: [PATCH 05/21] feat(arch): PDA derivation with Solana-compatible off-curve check --- services/bridge/go.mod | 3 +- services/bridge/go.sum | 3 + services/bridge/internal/arch/pda.go | 69 ++++++++++++++++++++++ services/bridge/internal/arch/pda_test.go | 70 +++++++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 services/bridge/internal/arch/pda.go create mode 100644 services/bridge/internal/arch/pda_test.go diff --git a/services/bridge/go.mod b/services/bridge/go.mod index 88590f7..fac0f05 100644 --- a/services/bridge/go.mod +++ b/services/bridge/go.mod @@ -9,6 +9,7 @@ replace github.com/diadata.org/Spectra-interoperability/proto => ../../proto replace github.com/diadata.org/Spectra-interoperability => ../../ require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/diadata.org/Spectra-interoperability v0.0.0-00010101000000-000000000000 github.com/diadata.org/Spectra-interoperability/proto v0.0.0-00010101000000-000000000000 github.com/ethereum/go-ethereum v1.16.4 @@ -34,7 +35,7 @@ require ( github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.3 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/services/bridge/go.sum b/services/bridge/go.sum index 84ecc3d..3b45dcd 100644 --- a/services/bridge/go.sum +++ b/services/bridge/go.sum @@ -43,8 +43,11 @@ github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80N github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= diff --git a/services/bridge/internal/arch/pda.go b/services/bridge/internal/arch/pda.go new file mode 100644 index 0000000..31ca5fb --- /dev/null +++ b/services/bridge/internal/arch/pda.go @@ -0,0 +1,69 @@ +package arch + +import ( + "crypto/sha256" + + secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// findProgramAddress implements the Arch-compatible PDA algorithm: +// for bump from 255 down to 0, compute sha256(seeds || bump || programID) +// and accept the first result that is NOT a valid secp256k1 x-only public key +// (off-curve). +func findProgramAddress(seeds [][]byte, programID Pubkey) (Pubkey, uint8) { + for bump := 255; bump >= 0; bump-- { + h := sha256.New() + for _, s := range seeds { + h.Write(s) + } + h.Write([]byte{byte(bump)}) + h.Write(programID[:]) + var candidate Pubkey + sum := h.Sum(nil) + copy(candidate[:], sum) + if isOffCurve(candidate) { + return candidate, uint8(bump) + } + } + panic("findProgramAddress: no off-curve candidate found in 256 attempts") +} + +// isOffCurve returns true when `candidate` does NOT represent a valid secp256k1 +// public key. This mirrors arch_program::pubkey::Pubkey::is_on_curve which calls +// bitcoin::secp256k1::PublicKey::from_slice with the raw 32-byte hash. +// That function requires 33 (compressed) or 65 (uncompressed) bytes, so a +// 32-byte input always fails, meaning every candidate is "off-curve" and bump=255 +// is always accepted on the first try. +func isOffCurve(candidate Pubkey) bool { + // Pass raw 32-byte candidate: ParsePubKey requires 33 or 65 bytes, so this + // always returns an error, matching Rust's bitcoin::secp256k1::PublicKey::from_slice + // behaviour with a 32-byte input. + _, err := secp256k1.ParsePubKey(candidate[:]) + return err != nil +} + +// ConfigPDA derives the receiver's Config PDA: seeds = [utf8("config")]. +func ConfigPDA(programID Pubkey) (Pubkey, uint8) { + return findProgramAddress([][]byte{[]byte("config")}, programID) +} + +// DedupPDA derives the receiver's Dedup PDA: seeds = [utf8("dedup")]. +func DedupPDA(programID Pubkey) (Pubkey, uint8) { + return findProgramAddress([][]byte{[]byte("dedup")}, programID) +} + +// PricePDA derives the per-symbol Price PDA: seeds = [utf8("price"), sha256(utf8(symbol))]. +func PricePDA(programID Pubkey, symbol string) (Pubkey, uint8) { + symHash := sha256.Sum256([]byte(symbol)) + return findProgramAddress([][]byte{[]byte("price"), symHash[:]}, programID) +} + +// FeeConfigPDA derives the fee-hook FeeConfig PDA: seeds = [utf8("fee_config")]. +func FeeConfigPDA(programID Pubkey) (Pubkey, uint8) { + return findProgramAddress([][]byte{[]byte("fee_config")}, programID) +} + +// FeeVaultPDA derives the fee-hook FeeVault PDA: seeds = [utf8("fee_vault")]. +func FeeVaultPDA(programID Pubkey) (Pubkey, uint8) { + return findProgramAddress([][]byte{[]byte("fee_vault")}, programID) +} diff --git a/services/bridge/internal/arch/pda_test.go b/services/bridge/internal/arch/pda_test.go new file mode 100644 index 0000000..f47e52e --- /dev/null +++ b/services/bridge/internal/arch/pda_test.go @@ -0,0 +1,70 @@ +package arch + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type pdaEntry struct { + Pubkey string `json:"pubkey"` + Bump uint8 `json:"bump"` +} + +func loadPdaVectors(t *testing.T) map[string]pdaEntry { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "pda_vectors.json")) + if err != nil { + t.Fatalf("read pda_vectors.json: %v", err) + } + var m map[string]pdaEntry + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal pda_vectors.json: %v", err) + } + return m +} + +// testProgramID matches the program id used by scripts/gen-pda-vectors.sh in +// sub-project 2. +func testProgramID() Pubkey { + b, _ := hex.DecodeString("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + var p Pubkey + copy(p[:], b) + return p +} + +func TestPDAParity(t *testing.T) { + vectors := loadPdaVectors(t) + pid := testProgramID() + + assert := func(t *testing.T, key string, gotPubkey Pubkey, gotBump uint8) { + t.Helper() + want, ok := vectors[key] + if !ok { + t.Fatalf("no fixture entry for %q", key) + } + if hex.EncodeToString(gotPubkey[:]) != want.Pubkey { + t.Fatalf("%s: pubkey mismatch:\n got %s\nwant %s", key, hex.EncodeToString(gotPubkey[:]), want.Pubkey) + } + if gotBump != want.Bump { + t.Fatalf("%s: bump mismatch: got %d want %d", key, gotBump, want.Bump) + } + } + + pk, bump := ConfigPDA(pid) + assert(t, "636f6e666967", pk, bump) // hex("config") + + pk, bump = DedupPDA(pid) + assert(t, "6465647570", pk, bump) // hex("dedup") + + pk, bump = PricePDA(pid, "BTC/USD") + assert(t, "7072696365|sha256:BTC/USD", pk, bump) + + pk, bump = FeeConfigPDA(pid) + assert(t, "6665655f636f6e666967", pk, bump) // hex("fee_config") + + pk, bump = FeeVaultPDA(pid) + assert(t, "6665655f7661756c74", pk, bump) // hex("fee_vault") +} From b3d8aca7078d02798474641d1dd21f15cb41406e Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:20:00 +0200 Subject: [PATCH 06/21] feat(arch): BIP-340 Schnorr signer for Taproot tx signing --- services/bridge/internal/arch/signer.go | 54 ++++++++++++++++++++ services/bridge/internal/arch/signer_test.go | 50 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 services/bridge/internal/arch/signer.go create mode 100644 services/bridge/internal/arch/signer_test.go diff --git a/services/bridge/internal/arch/signer.go b/services/bridge/internal/arch/signer.go new file mode 100644 index 0000000..f1353c0 --- /dev/null +++ b/services/bridge/internal/arch/signer.go @@ -0,0 +1,54 @@ +package arch + +import ( + "encoding/hex" + "errors" + "fmt" + + secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" +) + +// Signer holds a secp256k1 secret key and produces BIP-340 Schnorr signatures +// suitable for Arch's Taproot key-path tx signing. +type Signer struct { + secret *secp256k1.PrivateKey +} + +// NewSignerFromHex loads a 32-byte secret key from a 64-char hex string. +func NewSignerFromHex(secretHex string) (*Signer, error) { + if len(secretHex) != 64 { + return nil, fmt.Errorf("secret hex must be 64 chars, got %d", len(secretHex)) + } + raw, err := hex.DecodeString(secretHex) + if err != nil { + return nil, fmt.Errorf("decode hex: %w", err) + } + if len(raw) != 32 { + return nil, errors.New("secret must be 32 bytes") + } + priv := secp256k1.PrivKeyFromBytes(raw) + return &Signer{secret: priv}, nil +} + +// Pubkey returns the x-only public key (32 bytes). This is the form the +// Arch runtime stores in account-list Pubkey slots. +func (s *Signer) Pubkey() Pubkey { + pub := s.secret.PubKey() + // SerializeCompressed returns 33 bytes: prefix (0x02 or 0x03) + x. + compressed := pub.SerializeCompressed() + var out Pubkey + copy(out[:], compressed[1:]) + return out +} + +// SignDigest produces a 64-byte BIP-340 Schnorr signature (R || s) over digest. +func (s *Signer) SignDigest(digest [32]byte) ([64]byte, error) { + sig, err := schnorr.Sign(s.secret, digest[:]) + if err != nil { + return [64]byte{}, fmt.Errorf("schnorr sign: %w", err) + } + var out [64]byte + copy(out[:], sig.Serialize()) + return out, nil +} diff --git a/services/bridge/internal/arch/signer_test.go b/services/bridge/internal/arch/signer_test.go new file mode 100644 index 0000000..51a29f9 --- /dev/null +++ b/services/bridge/internal/arch/signer_test.go @@ -0,0 +1,50 @@ +package arch + +import ( + "encoding/hex" + "testing" + + "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" +) + +func TestSignerPubkey(t *testing.T) { + // Same secret seed (0x11 repeated 32 times) used by sub-projects 1 and 2. + secretHex := "1111111111111111111111111111111111111111111111111111111111111111" + s, err := NewSignerFromHex(secretHex) + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + pk := s.Pubkey() + // The x-only pubkey for seed 0x11 — generated by sub-project 2's keypair.test.ts using @noble/curves. + // Regenerated via: node -e 'const {secp256k1}=require("@noble/curves/secp256k1"); console.log(Buffer.from(secp256k1.getPublicKey(new Uint8Array(32).fill(0x11),true).slice(1)).toString("hex"))' + want := "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + got := hex.EncodeToString(pk[:]) + if got != want { + t.Fatalf("pubkey mismatch: got %s want %s", got, want) + } +} + +func TestSignerSignAndVerify(t *testing.T) { + secretHex := "1111111111111111111111111111111111111111111111111111111111111111" + s, err := NewSignerFromHex(secretHex) + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + var digest [32]byte + copy(digest[:], []byte("test-digest-padded-to-32-bytes!!")) + sig, err := s.SignDigest(digest) + if err != nil { + t.Fatalf("SignDigest: %v", err) + } + // Verify with the schnorr library directly to confirm Schnorr signature conformance. + // The test is in the same package, so we can access s.secret directly to obtain + // the full public key (including y-parity) for verification. + pub := s.secret.PubKey() + parsed, err := schnorr.ParseSignature(sig[:]) + if err != nil { + t.Fatalf("parse signature: %v", err) + } + if !parsed.Verify(digest[:], pub) { + t.Fatalf("Schnorr verification failed") + } +} From ada9297633bc4a60d70b038d79540d320c3f3fa9 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:24:59 +0200 Subject: [PATCH 07/21] fix(arch): swap to btcec/v2/schnorr for actual BIP-340 compliance --- services/bridge/go.mod | 2 ++ services/bridge/go.sum | 7 +++++++ services/bridge/internal/arch/signer.go | 8 ++++---- services/bridge/internal/arch/signer_test.go | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/services/bridge/go.mod b/services/bridge/go.mod index fac0f05..62e250c 100644 --- a/services/bridge/go.mod +++ b/services/bridge/go.mod @@ -29,6 +29,8 @@ require ( github.com/StackExchange/wmi v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/gnark-crypto v0.18.0 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect diff --git a/services/bridge/go.sum b/services/bridge/go.sum index 3b45dcd..750a296 100644 --- a/services/bridge/go.sum +++ b/services/bridge/go.sum @@ -10,6 +10,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= +github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -44,6 +50,7 @@ github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpO github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= diff --git a/services/bridge/internal/arch/signer.go b/services/bridge/internal/arch/signer.go index f1353c0..be49837 100644 --- a/services/bridge/internal/arch/signer.go +++ b/services/bridge/internal/arch/signer.go @@ -5,14 +5,14 @@ import ( "errors" "fmt" - secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" - "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" ) // Signer holds a secp256k1 secret key and produces BIP-340 Schnorr signatures // suitable for Arch's Taproot key-path tx signing. type Signer struct { - secret *secp256k1.PrivateKey + secret *btcec.PrivateKey } // NewSignerFromHex loads a 32-byte secret key from a 64-char hex string. @@ -27,7 +27,7 @@ func NewSignerFromHex(secretHex string) (*Signer, error) { if len(raw) != 32 { return nil, errors.New("secret must be 32 bytes") } - priv := secp256k1.PrivKeyFromBytes(raw) + priv, _ := btcec.PrivKeyFromBytes(raw) return &Signer{secret: priv}, nil } diff --git a/services/bridge/internal/arch/signer_test.go b/services/bridge/internal/arch/signer_test.go index 51a29f9..cfdd65a 100644 --- a/services/bridge/internal/arch/signer_test.go +++ b/services/bridge/internal/arch/signer_test.go @@ -4,7 +4,7 @@ import ( "encoding/hex" "testing" - "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" + "github.com/btcsuite/btcd/btcec/v2/schnorr" ) func TestSignerPubkey(t *testing.T) { From 78d3e369b1530fe7c2b3a750a96391b90ffdc04f Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:28:44 +0200 Subject: [PATCH 08/21] feat(arch): DIA_ORACLE.* log parser (update/stale/rejected) --- services/bridge/internal/arch/logs.go | 142 +++++++++++++++++++++ services/bridge/internal/arch/logs_test.go | 65 ++++++++++ 2 files changed, 207 insertions(+) create mode 100644 services/bridge/internal/arch/logs.go create mode 100644 services/bridge/internal/arch/logs_test.go diff --git a/services/bridge/internal/arch/logs.go b/services/bridge/internal/arch/logs.go new file mode 100644 index 0000000..cfe650b --- /dev/null +++ b/services/bridge/internal/arch/logs.go @@ -0,0 +1,142 @@ +package arch + +import ( + "encoding/hex" + "math/big" + "regexp" + "strconv" + "strings" +) + +// IntentEvent is a parsed DIA_ORACLE.* log line. +type IntentEvent struct { + Kind string // "update" | "stale" | "rejected" + IntentHash [32]byte + Symbol string + Signer EthAddress + Price *big.Int // nil for rejected + Timestamp uint64 // 0 for rejected + StaleAgainst uint64 // 0 unless Kind == "stale" + Reason string // "" unless Kind == "rejected" +} + +const programLogPrefix = "Program log: " + +var ( + keyRegex = regexp.MustCompile(`(\w+)=("[^"]*"|0x[0-9a-fA-F]+|\d+|\w+)`) + hexValRegex = regexp.MustCompile(`^0x([0-9a-fA-F]+)$`) + quotedRegex = regexp.MustCompile(`^"(.*)"$`) +) + +// ParseIntentEvents scans a flat slice of validator log strings and returns +// parsed DIA_ORACLE.INTENT_* events in order. Non-matching lines are skipped. +func ParseIntentEvents(logs []string) []IntentEvent { + var out []IntentEvent + for _, line := range logs { + body := strings.TrimPrefix(line, programLogPrefix) + switch { + case strings.HasPrefix(body, "DIA_ORACLE.INTENT_UPDATE "): + if e, ok := parseUpdate(body); ok { + out = append(out, e) + } + case strings.HasPrefix(body, "DIA_ORACLE.INTENT_STALE "): + if e, ok := parseStale(body); ok { + out = append(out, e) + } + case strings.HasPrefix(body, "DIA_ORACLE.INTENT_REJECTED "): + if e, ok := parseRejected(body); ok { + out = append(out, e) + } + } + } + return out +} + +func parseKV(body string) map[string]string { + out := map[string]string{} + for _, m := range keyRegex.FindAllStringSubmatch(body, -1) { + key, val := m[1], m[2] + if q := quotedRegex.FindStringSubmatch(val); q != nil { + val = q[1] + } + out[key] = val + } + return out +} + +func parseUpdate(body string) (IntentEvent, bool) { + kv := parseKV(body) + e := IntentEvent{Kind: "update", Symbol: kv["symbol"]} + if !decodeHexInto32(kv["intent_hash"], &e.IntentHash) { + return e, false + } + if !decodeHexInto20(kv["signer"], (*[20]byte)(&e.Signer)) { + return e, false + } + if p, ok := new(big.Int).SetString(kv["price"], 10); ok { + e.Price = p + } + if ts, err := strconv.ParseUint(kv["timestamp"], 10, 64); err == nil { + e.Timestamp = ts + } + return e, true +} + +func parseStale(body string) (IntentEvent, bool) { + kv := parseKV(body) + e := IntentEvent{Kind: "stale", Symbol: kv["symbol"]} + if !decodeHexInto32(kv["intent_hash"], &e.IntentHash) { + return e, false + } + if !decodeHexInto20(kv["signer"], (*[20]byte)(&e.Signer)) { + return e, false + } + if p, ok := new(big.Int).SetString(kv["price"], 10); ok { + e.Price = p + } + if ts, err := strconv.ParseUint(kv["timestamp"], 10, 64); err == nil { + e.Timestamp = ts + } + if ets, err := strconv.ParseUint(kv["existing_timestamp"], 10, 64); err == nil { + e.StaleAgainst = ets + } + return e, true +} + +func parseRejected(body string) (IntentEvent, bool) { + kv := parseKV(body) + e := IntentEvent{Kind: "rejected", Symbol: kv["symbol"], Reason: kv["reason"]} + if !decodeHexInto32(kv["intent_hash"], &e.IntentHash) { + return e, false + } + if !decodeHexInto20(kv["signer"], (*[20]byte)(&e.Signer)) { + return e, false + } + return e, true +} + +func decodeHexInto32(s string, out *[32]byte) bool { + m := hexValRegex.FindStringSubmatch(s) + if m == nil { + return false + } + raw, err := hex.DecodeString(m[1]) + if err != nil || len(raw) != 32 { + return false + } + copy(out[:], raw) + return true +} + +func decodeHexInto20(s string, out *[20]byte) bool { + m := hexValRegex.FindStringSubmatch(s) + if m == nil { + return false + } + raw, err := hex.DecodeString(m[1]) + if err != nil || len(raw) != 20 { + return false + } + copy(out[:], raw) + return true +} diff --git a/services/bridge/internal/arch/logs_test.go b/services/bridge/internal/arch/logs_test.go new file mode 100644 index 0000000..ad9bb9b --- /dev/null +++ b/services/bridge/internal/arch/logs_test.go @@ -0,0 +1,65 @@ +package arch + +import ( + "encoding/hex" + "math/big" + "testing" +) + +func TestParseIntentEvents(t *testing.T) { + logs := []string{ + `Program log: DIA_ORACLE.DISPATCH ix=HandleIntentUpdate`, + `Program log: DIA_ORACLE.INTENT_UPDATE intent_hash=0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899 symbol="BTC/USD" price=65000000000000 timestamp=1700000017 signer=0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a`, + `Program log: irrelevant noise`, + `Program log: DIA_ORACLE.INTENT_STALE intent_hash=0x1111111111111111111111111111111111111111111111111111111111111111 symbol="ETH/USD" price=2000000000000 timestamp=1700000016 existing_timestamp=1700000020 signer=0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a`, + `Program log: DIA_ORACLE.INTENT_REJECTED intent_hash=0x2222222222222222222222222222222222222222222222222222222222222222 symbol="USDC/USD" signer=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa reason=UnauthorizedSigner`, + } + + events := ParseIntentEvents(logs) + if len(events) != 3 { + t.Fatalf("got %d events, want 3", len(events)) + } + + // Event 0: update. + e := events[0] + if e.Kind != "update" { + t.Errorf("event 0 kind = %q, want update", e.Kind) + } + if e.Symbol != "BTC/USD" { + t.Errorf("event 0 symbol = %q", e.Symbol) + } + wantPrice := new(big.Int).SetUint64(65000000000000) + if e.Price.Cmp(wantPrice) != 0 { + t.Errorf("event 0 price = %s, want %s", e.Price, wantPrice) + } + if e.Timestamp != 1700000017 { + t.Errorf("event 0 timestamp = %d", e.Timestamp) + } + wantHash, _ := hex.DecodeString("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899") + if hex.EncodeToString(e.IntentHash[:]) != hex.EncodeToString(wantHash) { + t.Errorf("event 0 intent hash mismatch") + } + + // Event 1: stale. + if events[1].Kind != "stale" { + t.Errorf("event 1 kind = %q, want stale", events[1].Kind) + } + if events[1].StaleAgainst != 1700000020 { + t.Errorf("event 1 stale_against = %d, want 1700000020", events[1].StaleAgainst) + } + + // Event 2: rejection. + if events[2].Kind != "rejected" { + t.Errorf("event 2 kind = %q, want rejected", events[2].Kind) + } + if events[2].Reason != "UnauthorizedSigner" { + t.Errorf("event 2 reason = %q", events[2].Reason) + } +} + +func TestParseIntentEvents_NoEvents(t *testing.T) { + events := ParseIntentEvents([]string{"Program log: hello", "Program 1234 invoke [1]"}) + if len(events) != 0 { + t.Fatalf("got %d events, want 0", len(events)) + } +} From 3ca8acb11c7d6b1603fa64e93be627aab5e1e356 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:35:05 +0200 Subject: [PATCH 09/21] feat(arch): JSON-RPC client + RuntimeTransaction wire format --- services/bridge/internal/arch/rpc.go | 469 ++++++++++++++++++++++ services/bridge/internal/arch/rpc_test.go | 107 +++++ 2 files changed, 576 insertions(+) create mode 100644 services/bridge/internal/arch/rpc.go create mode 100644 services/bridge/internal/arch/rpc_test.go diff --git a/services/bridge/internal/arch/rpc.go b/services/bridge/internal/arch/rpc.go new file mode 100644 index 0000000..da1afa5 --- /dev/null +++ b/services/bridge/internal/arch/rpc.go @@ -0,0 +1,469 @@ +// Package arch contains the Arch Network JSON-RPC client and transaction builder. +// +// # RuntimeTransaction wire format (arch_sdk 0.6.5, arch_program 0.6.5) +// +// Verified against: +// - ~/.cargo/registry/src/.../arch_sdk-0.6.5/src/types/runtime_transaction.rs +// (RuntimeTransaction::serialize) +// - ~/.cargo/registry/src/.../arch_program-0.6.5/src/sanitized.rs +// (ArchMessage::serialize, SanitizedInstruction::serialize) +// +// Layout of the serialized RuntimeTransaction: +// +// [version : u32 LE ] — currently 0 (4 bytes) +// [num_signatures : u8 ] — count of signer pubkeys (1 byte) +// [signatures : 64B × n ] — BIP-340 Schnorr (R||s), one per signer +// [ArchMessage ] — serialized message (see below) +// [num_required_signatures : u8] +// [num_readonly_signed : u8] +// [num_readonly_unsigned : u8] +// [num_account_keys : u32 LE] +// [account_keys : 32B × n] +// [recent_blockhash : 32B] +// [num_instructions : u32 LE] +// [for each instruction: +// program_id_index : u8 +// num_account_indices : u32 LE +// account_indices : []u8 +// data_len : u32 LE +// data : []u8 ] +// +// All length fields use plain little-endian u32 (NOT Solana compact-u16). +// +// # Signing digest +// +// From ArchMessage::hash() in sanitized.rs (uses sha256 crate which outputs hex): +// +// first = sha256(serialized_message) → 32 raw bytes +// hexStr = hex_encode(first) → 64 ASCII bytes +// second = sha256(hexStr_as_bytes) → 32 raw bytes +// +// The 32 raw bytes of `second` are passed to Signer.SignDigest([32]byte). +// (In the JS/Rust SDK the signing input is the ASCII-hex of `second`, but +// for BIP-322 the library does its own wrapping; Go uses SignDigest directly +// over the 32-byte raw digest.) +// +// # send_transaction wire format +// +// The serialized RuntimeTransaction bytes are base64-encoded and sent as the +// sole JSON-RPC param: params: [""]. +package arch + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" +) + +// RPC is a thin JSON-RPC 2.0 client for the Arch validator. +type RPC struct { + url string + client *http.Client + id atomic.Uint64 +} + +// NewRPC constructs an RPC client pointed at url. +func NewRPC(url string) *RPC { + return &RPC{url: url, client: http.DefaultClient} +} + +// AccountInfo is the decoded form of read_account_info's result. +type AccountInfo struct { + Data []byte + Lamports uint64 + Owner Pubkey +} + +// ProcessedTx is the decoded form of get_processed_transaction's result. +type ProcessedTx struct { + Status string // "Processed" | "Failed" + Logs []string + CustomError *uint32 +} + +// ---- JSON-RPC plumbing ---- + +type jsonrpcReq struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type jsonrpcResp struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpcError `json:"error,omitempty"` +} + +type jsonrpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// errNotFound is the sentinel returned when the validator responds with code 404. +var errNotFound = fmt.Errorf("arch: not found") + +func (c *RPC) call(ctx context.Context, method string, params []interface{}, out interface{}) error { + id := c.id.Add(1) + req := jsonrpcReq{JSONRPC: "2.0", ID: id, Method: method, Params: params} + body, err := json.Marshal(req) + if err != nil { + return err + } + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.url, bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpResp, err := c.client.Do(httpReq) + if err != nil { + return fmt.Errorf("%s: %w", method, err) + } + defer httpResp.Body.Close() + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("%s: read body: %w", method, err) + } + var resp jsonrpcResp + if err := json.Unmarshal(respBody, &resp); err != nil { + return fmt.Errorf("%s: decode: %w (body: %s)", method, err, string(respBody)) + } + if resp.Error != nil { + if resp.Error.Code == 404 { + return errNotFound + } + return fmt.Errorf("%s: rpc error %d: %s", method, resp.Error.Code, resp.Error.Message) + } + if out != nil { + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("%s: decode result: %w", method, err) + } + } + return nil +} + +// ---- RPC methods ---- + +// GetBlockCount calls get_block_count and returns the current block height. +func (c *RPC) GetBlockCount(ctx context.Context) (uint64, error) { + var out uint64 + if err := c.call(ctx, "get_block_count", []interface{}{}, &out); err != nil { + return 0, err + } + return out, nil +} + +// GetBestBlockHash calls get_best_block_hash. Returns the 32-byte hash. +// The validator returns it as a hex string without 0x prefix. +func (c *RPC) GetBestBlockHash(ctx context.Context) ([32]byte, error) { + var hexStr string + if err := c.call(ctx, "get_best_block_hash", []interface{}{}, &hexStr); err != nil { + return [32]byte{}, err + } + raw, err := hex.DecodeString(hexStr) + if err != nil || len(raw) != 32 { + return [32]byte{}, fmt.Errorf("get_best_block_hash: bad hex %q", hexStr) + } + var out [32]byte + copy(out[:], raw) + return out, nil +} + +// ReadAccountInfo calls read_account_info. Returns (nil, nil) when the account +// does not exist (validator returns error code 404). +func (c *RPC) ReadAccountInfo(ctx context.Context, pubkey Pubkey) (*AccountInfo, error) { + pubkeyHex := hex.EncodeToString(pubkey[:]) + var raw struct { + DataB64 string `json:"data"` + Lamports uint64 `json:"lamports"` + OwnerHex string `json:"owner"` + } + if err := c.call(ctx, "read_account_info", []interface{}{pubkeyHex}, &raw); err != nil { + if err == errNotFound { + return nil, nil + } + return nil, err + } + data, err := base64.StdEncoding.DecodeString(raw.DataB64) + if err != nil { + return nil, fmt.Errorf("read_account_info: decode data: %w", err) + } + ownerBytes, err := hex.DecodeString(raw.OwnerHex) + if err != nil || len(ownerBytes) != 32 { + return nil, fmt.Errorf("read_account_info: decode owner: %w", err) + } + var ownerKey Pubkey + copy(ownerKey[:], ownerBytes) + return &AccountInfo{Data: data, Lamports: raw.Lamports, Owner: ownerKey}, nil +} + +// SendTransaction calls send_transaction with the base64-encoded serialized +// RuntimeTransaction bytes. Returns the transaction ID hex string. +func (c *RPC) SendTransaction(ctx context.Context, signedTxBytes []byte) (string, error) { + b64 := base64.StdEncoding.EncodeToString(signedTxBytes) + var txID string + if err := c.call(ctx, "send_transaction", []interface{}{b64}, &txID); err != nil { + return "", err + } + return txID, nil +} + +// GetProcessedTransaction calls get_processed_transaction. Returns (nil, nil) +// while the transaction is still pending; the caller should poll. +func (c *RPC) GetProcessedTransaction(ctx context.Context, txID string) (*ProcessedTx, error) { + var raw struct { + Status json.RawMessage `json:"status"` + Logs []string `json:"logs"` + } + if err := c.call(ctx, "get_processed_transaction", []interface{}{txID}, &raw); err != nil { + if err == errNotFound { + return nil, nil + } + return nil, err + } + // status is a tagged enum: + // {"type":"processed"} or {"type":"failed","reason":{"custom":N}} + var tag struct { + Type string `json:"type"` + Reason struct { + Custom *uint32 `json:"custom"` + } `json:"reason"` + } + if err := json.Unmarshal(raw.Status, &tag); err != nil { + return nil, fmt.Errorf("get_processed_transaction: decode status: %w", err) + } + out := &ProcessedTx{Logs: raw.Logs} + switch tag.Type { + case "processed": + out.Status = "Processed" + case "failed": + out.Status = "Failed" + out.CustomError = tag.Reason.Custom + default: + out.Status = tag.Type + } + return out, nil +} + +// ---- Transaction builder ---- + +// BuildAndSignTransaction builds a RuntimeTransaction from a single instruction +// and signs it with signer. The returned bytes are the serialized +// RuntimeTransaction ready to pass to SendTransaction. +// +// Wire layout produced (matches arch_sdk 0.6.5 RuntimeTransaction::serialize): +// +// version(u32 LE) || num_sigs(u8) || sigs(64B×n) || message +// +// where message = ArchMessage::serialize() — see package-level Godoc. +func BuildAndSignTransaction(ix Instruction, signer *Signer, recentBlockhash [32]byte) ([]byte, error) { + // 1. Assemble the unique ordered account list. + accounts := assembleAccounts(ix, signer.Pubkey()) + + // 2. Ensure the program ID is in the account list (appended as readonly + // non-signer if not already present). + if _, err := slotIndexOf(accounts, ix.ProgramID); err != nil { + accounts = append(accounts, accountSlot{Pubkey: ix.ProgramID, IsSigner: false, IsWritable: false}) + } + + // 3. Derive header counts. + numReqSigs, numReadSigned, numReadUnsigned := classifyAccounts(accounts) + + // 4. Build the ArchMessage serialization. + msg, err := serializeMessage(accounts, numReqSigs, numReadSigned, numReadUnsigned, ix, recentBlockhash) + if err != nil { + return nil, err + } + + // 5. Compute the signing digest: sha256(hex_encode(sha256(msg_bytes))). + // This mirrors ArchMessage::hash() in arch_program 0.6.5 sanitized.rs + // which uses the sha256 crate's digest() (hex output) twice. + digest := archMessageDigest(msg) + + // 6. Sign. + sig, err := signer.SignDigest(digest) + if err != nil { + return nil, fmt.Errorf("BuildAndSignTransaction: %w", err) + } + + // 7. Serialize the full RuntimeTransaction. + var w bytes.Buffer + // version: u32 LE = 0 + var vbuf [4]byte + binary.LittleEndian.PutUint32(vbuf[:], 0) + w.Write(vbuf[:]) + // num_signatures: u8 + w.WriteByte(1) + // signature: 64 bytes + w.Write(sig[:]) + // message + w.Write(msg) + + return w.Bytes(), nil +} + +// serializeMessage produces the ArchMessage wire bytes from the assembled +// account list, header counts, instruction, and recent blockhash. +func serializeMessage( + accounts []accountSlot, + numReqSigs, numReadSigned, numReadUnsigned uint8, + ix Instruction, + recentBlockhash [32]byte, +) ([]byte, error) { + programIdx, err := slotIndexOf(accounts, ix.ProgramID) + if err != nil { + return nil, fmt.Errorf("serializeMessage: program id not found: %w", err) + } + accountIndices, err := accountIndicesOf(accounts, ix.Accounts) + if err != nil { + return nil, fmt.Errorf("serializeMessage: %w", err) + } + + var w bytes.Buffer + + // Header: 3 bytes + w.WriteByte(numReqSigs) + w.WriteByte(numReadSigned) + w.WriteByte(numReadUnsigned) + + // Account keys: u32 LE count + 32B each + writeU32LE(&w, uint32(len(accounts))) + for _, a := range accounts { + w.Write(a.Pubkey[:]) + } + + // Recent blockhash: 32 bytes + w.Write(recentBlockhash[:]) + + // Instructions: u32 LE count = 1 + writeU32LE(&w, 1) + // program_id_index: u8 + w.WriteByte(uint8(programIdx)) + // num_account_indices: u32 LE + writeU32LE(&w, uint32(len(accountIndices))) + // account indices: []u8 + w.Write(accountIndices) + // data_len: u32 LE + writeU32LE(&w, uint32(len(ix.Data))) + // data + w.Write(ix.Data) + + return w.Bytes(), nil +} + +// archMessageDigest computes the signing digest used by ArchMessage::hash(). +// From sanitized.rs (sha256 crate outputs hex strings): +// +// first = sha256(msg_bytes) → 32 raw bytes +// hexStr = hex_encode(first) → 64 ASCII bytes +// digest = sha256(hexStr_as_bytes) → 32 raw bytes +func archMessageDigest(msg []byte) [32]byte { + first := sha256.Sum256(msg) + hexStr := hex.EncodeToString(first[:]) // 64 ASCII chars + return sha256.Sum256([]byte(hexStr)) +} + +// writeU32LE writes a uint32 in little-endian to w. +func writeU32LE(w *bytes.Buffer, v uint32) { + var buf [4]byte + binary.LittleEndian.PutUint32(buf[:], v) + w.Write(buf[:]) +} + +// ---- Account assembly helpers ---- + +type accountSlot struct { + Pubkey Pubkey + IsSigner bool + IsWritable bool +} + +// assembleAccounts builds the unique ordered account list for a single-instruction +// transaction. Ordering follows the Arch/Solana convention: +// +// 1. Writable signers (payer first) +// 2. Read-only signers +// 3. Writable non-signers +// 4. Read-only non-signers +// +// The signer's pubkey is always placed at index 0 (fee-payer). +func assembleAccounts(ix Instruction, payer Pubkey) []accountSlot { + seen := map[Pubkey]bool{payer: true} + var wSig, rSig, wNS, rNS []accountSlot + + // Payer is always a writable signer at index 0. + wSig = append(wSig, accountSlot{Pubkey: payer, IsSigner: true, IsWritable: true}) + + for _, a := range ix.Accounts { + if seen[a.Pubkey] { + continue + } + seen[a.Pubkey] = true + switch { + case a.IsSigner && a.IsWritable: + wSig = append(wSig, accountSlot{Pubkey: a.Pubkey, IsSigner: true, IsWritable: true}) + case a.IsSigner && !a.IsWritable: + rSig = append(rSig, accountSlot{Pubkey: a.Pubkey, IsSigner: true, IsWritable: false}) + case !a.IsSigner && a.IsWritable: + wNS = append(wNS, accountSlot{Pubkey: a.Pubkey, IsSigner: false, IsWritable: true}) + default: + rNS = append(rNS, accountSlot{Pubkey: a.Pubkey, IsSigner: false, IsWritable: false}) + } + } + + out := make([]accountSlot, 0, len(wSig)+len(rSig)+len(wNS)+len(rNS)) + out = append(out, wSig...) + out = append(out, rSig...) + out = append(out, wNS...) + out = append(out, rNS...) + return out +} + +// classifyAccounts computes the three MessageHeader counts from the assembled +// account list. +func classifyAccounts(accounts []accountSlot) (numReqSigs, numReadSigned, numReadUnsigned uint8) { + for _, a := range accounts { + if a.IsSigner { + numReqSigs++ + if !a.IsWritable { + numReadSigned++ + } + } else if !a.IsWritable { + numReadUnsigned++ + } + } + return +} + +// slotIndexOf returns the index of pk in accounts. +func slotIndexOf(accounts []accountSlot, pk Pubkey) (int, error) { + for i, a := range accounts { + if a.Pubkey == pk { + return i, nil + } + } + return -1, fmt.Errorf("pubkey %x not in account list", pk[:4]) +} + +// accountIndicesOf maps each AccountMeta's pubkey to its index in accounts. +func accountIndicesOf(accounts []accountSlot, metas []AccountMeta) ([]byte, error) { + out := make([]byte, len(metas)) + for i, m := range metas { + idx, err := slotIndexOf(accounts, m.Pubkey) + if err != nil { + return nil, err + } + out[i] = byte(idx) + } + return out, nil +} diff --git a/services/bridge/internal/arch/rpc_test.go b/services/bridge/internal/arch/rpc_test.go new file mode 100644 index 0000000..498e298 --- /dev/null +++ b/services/bridge/internal/arch/rpc_test.go @@ -0,0 +1,107 @@ +package arch + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeArchRPC echoes a single canned response per method. Tests inspect the +// inbound request to assert the JSON-RPC envelope is correctly formed. +func fakeArchRPC(t *testing.T, responses map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, "bad json", 400) + return + } + body, ok := responses[req.Method] + if !ok { + t.Errorf("unexpected method %q", req.Method) + http.Error(w, "method not found", 404) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(body)) //nolint:errcheck + })) +} + +func TestRPCGetBlockCount(t *testing.T) { + srv := fakeArchRPC(t, map[string]string{ + "get_block_count": `{"jsonrpc":"2.0","id":1,"result":12345}`, + }) + defer srv.Close() + c := NewRPC(srv.URL) + got, err := c.GetBlockCount(context.Background()) + if err != nil { + t.Fatalf("GetBlockCount: %v", err) + } + if got != 12345 { + t.Fatalf("got %d, want 12345", got) + } +} + +func TestRPCReadAccountInfo_NotFound(t *testing.T) { + srv := fakeArchRPC(t, map[string]string{ + "read_account_info": `{"jsonrpc":"2.0","id":1,"error":{"code":404,"message":"not found"}}`, + }) + defer srv.Close() + c := NewRPC(srv.URL) + info, err := c.ReadAccountInfo(context.Background(), Pubkey{}) + if err != nil { + t.Fatalf("ReadAccountInfo: %v", err) + } + if info != nil { + t.Fatalf("got %+v, want nil for not-found", info) + } +} + +func TestRPCSendTransaction(t *testing.T) { + srv := fakeArchRPC(t, map[string]string{ + "send_transaction": `{"jsonrpc":"2.0","id":1,"result":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}`, + }) + defer srv.Close() + c := NewRPC(srv.URL) + txID, err := c.SendTransaction(context.Background(), []byte{0xde, 0xad, 0xbe, 0xef}) + if err != nil { + t.Fatalf("SendTransaction: %v", err) + } + if !strings.HasPrefix(txID, "abcdef") { + t.Fatalf("got txID %q", txID) + } +} + +func TestBuildAndSignTransaction_Roundtrip(t *testing.T) { + signer, err := NewSignerFromHex("1111111111111111111111111111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + ix := Instruction{ + ProgramID: Pubkey{0x01, 0x02, 0x03}, + Accounts: []AccountMeta{ + {Pubkey: signer.Pubkey(), IsSigner: true, IsWritable: true}, + {Pubkey: SystemProgramID, IsSigner: false, IsWritable: false}, + }, + Data: []byte{0x01, 0x02, 0x03, 0x04}, + } + var blockhash [32]byte + copy(blockhash[:], []byte("blockhashpaddedtoexactly32bytes!")) + signed, err := BuildAndSignTransaction(ix, signer, blockhash) + if err != nil { + t.Fatalf("BuildAndSignTransaction: %v", err) + } + if len(signed) < 100 { + t.Fatalf("signed tx suspiciously short: %d bytes (%s)", len(signed), hex.EncodeToString(signed)) + } + // Specific byte assertions land once the implementer documents the wire + // format in rpc.go's leading Godoc. The integration test in Task 17 is + // the end-to-end validator-accepted gate. +} From 3d68ea1f5e6b62ef37fd220a3991e8c2eab07b0a Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:47:27 +0200 Subject: [PATCH 10/21] feat(bridge): Destination interface, EVM WriteClient as adapter (no rename) --- services/bridge/internal/bridge/bridge.go | 18 +++-- .../bridge/internal/bridge/destination.go | 33 +++++++++ .../internal/bridge/destination_test.go | 24 +++++++ services/bridge/internal/bridge/health.go | 20 +++--- .../internal/bridge/transaction_handler.go | 12 ++-- .../bridge/internal/bridge/write_client.go | 67 ++++++++++++++++--- 6 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 services/bridge/internal/bridge/destination.go create mode 100644 services/bridge/internal/bridge/destination_test.go diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index 50958d2..ab1c637 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -29,7 +29,7 @@ type Bridge struct { configService *config.ConfigService db *database.DB readClient rpc.EthClient - writeClients map[int64]*WriteClient + writeClients map[int64]Destination // Channels for communication updateChan chan *bridgetypes.UpdateRequest @@ -119,7 +119,7 @@ func NewBridge(modularCfg *config.ModularConfig, cfgService *config.ConfigServic logger.Errorf("Failed to load routers: %v", err) } - destClients := make(map[int64]*WriteClient) + destClients := make(map[int64]Destination) for _, chainConfig := range cfgService.GetEnabledChains() { contracts := cfgService.GetContractsForChain(chainConfig.ChainID) if len(contracts) == 0 { @@ -159,8 +159,10 @@ func NewBridge(modularCfg *config.ModularConfig, cfgService *config.ConfigServic metricsManager := NewMetricsManager(metricsCollector) ethClients := make(map[int64]rpc.EthClient) - for chainID, writeClient := range destClients { - ethClients[chainID] = writeClient.GetEthClient() + for chainID, dest := range destClients { + if wc, ok := dest.(*WriteClient); ok { + ethClients[chainID] = wc.GetEthClient() + } } // Create bridge instance now that we have all dependencies @@ -448,7 +450,9 @@ func (b *Bridge) Stop(ctx context.Context) error { // Close connections b.readClient.Close() for _, destClient := range b.writeClients { - destClient.client.Close() + if wc, ok := destClient.(*WriteClient); ok { + wc.client.Close() + } } b.mu.Lock() @@ -522,8 +526,8 @@ func (b *Bridge) processUpdates(ctx context.Context) { // Check if update is stale based on last update in cache if updateReq.Intent != nil && !updateReq.CreatedAt.IsZero() && updateReq.Contract != nil { destClient := b.writeClients[updateReq.DestinationChain.ChainID] - if destClient != nil { - lastUpdateTime := destClient.getLastUpdate(updateReq.Intent.Symbol, updateReq.Contract.Address) + if wc, ok := destClient.(*WriteClient); ok { + lastUpdateTime := wc.getLastUpdate(updateReq.Intent.Symbol, updateReq.Contract.Address) if !lastUpdateTime.IsZero() && updateReq.CreatedAt.Before(lastUpdateTime) { logger.Debugf("Skipping stale update: symbol=%s, chain=%d, contract=%s, updateTime=%v, lastUpdateTime=%v, age=%v", updateReq.Intent.Symbol, updateReq.DestinationChain.ChainID, updateReq.Contract.Address, diff --git a/services/bridge/internal/bridge/destination.go b/services/bridge/internal/bridge/destination.go new file mode 100644 index 0000000..6b907f7 --- /dev/null +++ b/services/bridge/internal/bridge/destination.go @@ -0,0 +1,33 @@ +package bridge + +import ( + "context" + + bridgetypes "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" +) + +// Destination dispatches a single UpdateRequest to a chain backend. Both +// EVM (existing *WriteClient) and Arch (new *ArchWriteClient) satisfy it. +type Destination interface { + Send(ctx context.Context, req *bridgetypes.UpdateRequest) (TxResult, error) + ReceiverAddress() string + ChainID() int64 + Kind() string +} + +// TxResult is the outcome of a Destination.Send call. +type TxResult struct { + TxID string + Status string // "Processed" | "Failed" + Logs []string + Rejections []IntentRejection +} + +// IntentRejection is one per-intent rejection parsed from the receiver's +// DIA_ORACLE.INTENT_REJECTED log lines. Always empty for EVM destinations. +type IntentRejection struct { + IntentHash [32]byte + Symbol string + Signer [20]byte + Reason string // "UnauthorizedSigner" | "AlreadyProcessed" | "InvalidSignature" +} diff --git a/services/bridge/internal/bridge/destination_test.go b/services/bridge/internal/bridge/destination_test.go new file mode 100644 index 0000000..b6124d1 --- /dev/null +++ b/services/bridge/internal/bridge/destination_test.go @@ -0,0 +1,24 @@ +package bridge + +import "testing" + +// TestWriteClientSatisfiesDestination is a compile-time check that the +// existing EVM *WriteClient implements the new Destination interface. +func TestWriteClientSatisfiesDestination(t *testing.T) { + var _ Destination = (*WriteClient)(nil) +} + +// TestEmptyAdapterMethods verifies the four adapter methods don't allocate +// or panic on a zero-valued client. +func TestEmptyAdapterMethods(t *testing.T) { + var wc WriteClient + if wc.Kind() != "evm" { + t.Errorf("Kind() = %q, want evm", wc.Kind()) + } + if got := wc.ReceiverAddress(); got != "" { + t.Errorf("ReceiverAddress() on zero client = %q, want \"\"", got) + } + if wc.ChainID() != 0 { + t.Errorf("ChainID() on zero client = %d, want 0", wc.ChainID()) + } +} diff --git a/services/bridge/internal/bridge/health.go b/services/bridge/internal/bridge/health.go index 670b177..8cd3249 100644 --- a/services/bridge/internal/bridge/health.go +++ b/services/bridge/internal/bridge/health.go @@ -33,11 +33,13 @@ func (b *Bridge) initializeChainStats() { } // Destination chain stats - for _, destClient := range b.writeClients { - b.stats.ChainStats[destClient.chainConfig.ChainID] = &bridgetypes.ChainStatus{ - ChainID: destClient.chainConfig.ChainID, - Name: destClient.chainConfig.Name, - Connected: true, + for _, dest := range b.writeClients { + if wc, ok := dest.(*WriteClient); ok { + b.stats.ChainStats[wc.chainConfig.ChainID] = &bridgetypes.ChainStatus{ + ChainID: wc.chainConfig.ChainID, + Name: wc.chainConfig.Name, + Connected: true, + } } } } @@ -69,9 +71,11 @@ func (b *Bridge) performHealthCheck(ctx context.Context) { } // Check destination chains - for _, destClient := range b.writeClients { - if err := b.checkChainHealth(ctx, destClient.client, destClient.chainConfig.ChainID); err != nil { - logger.Errorf("Destination chain %d health check failed: %v", destClient.chainConfig.ChainID, err) + for _, dest := range b.writeClients { + if wc, ok := dest.(*WriteClient); ok { + if err := b.checkChainHealth(ctx, wc.client, wc.chainConfig.ChainID); err != nil { + logger.Errorf("Destination chain %d health check failed: %v", wc.chainConfig.ChainID, err) + } } } } diff --git a/services/bridge/internal/bridge/transaction_handler.go b/services/bridge/internal/bridge/transaction_handler.go index 9e8a9c6..a4cefab 100644 --- a/services/bridge/internal/bridge/transaction_handler.go +++ b/services/bridge/internal/bridge/transaction_handler.go @@ -33,13 +33,13 @@ type TransactionContext struct { // TransactionHandler handles the complete lifecycle of a transaction type TransactionHandler struct { - writeClients map[int64]*WriteClient + writeClients map[int64]Destination routerRegistry *router.GenericRegistry metricsTracker *MetricsTracker } // NewTransactionHandler creates a new transaction handler -func NewTransactionHandler(writeClients map[int64]*WriteClient, registry *router.GenericRegistry, tracker *MetricsTracker) *TransactionHandler { +func NewTransactionHandler(writeClients map[int64]Destination, registry *router.GenericRegistry, tracker *MetricsTracker) *TransactionHandler { return &TransactionHandler{ writeClients: writeClients, routerRegistry: registry, @@ -87,10 +87,14 @@ func (h *TransactionHandler) buildContext(ctx context.Context, updateReq *bridge return nil, fmt.Errorf("destination chain is nil") } - destClient := h.writeClients[updateReq.DestinationChain.ChainID] - if destClient == nil { + dest := h.writeClients[updateReq.DestinationChain.ChainID] + if dest == nil { return nil, fmt.Errorf("destination client not found for chain %d", updateReq.DestinationChain.ChainID) } + destClient, ok := dest.(*WriteClient) + if !ok { + return nil, fmt.Errorf("destination for chain %d is not an EVM WriteClient", updateReq.DestinationChain.ChainID) + } gasPrice, err := destClient.getGasPrice(ctx) if err != nil { diff --git a/services/bridge/internal/bridge/write_client.go b/services/bridge/internal/bridge/write_client.go index c7f7d2e..75fd603 100644 --- a/services/bridge/internal/bridge/write_client.go +++ b/services/bridge/internal/bridge/write_client.go @@ -19,12 +19,13 @@ import ( // WriteClient represents a client for write operations to a destination chain type WriteClient struct { - chainConfig *config.ChainConfig - contracts []*config.ContractConfig - client rpc.EthClient - txClient *transaction.Client - lastUpdate map[string]time.Time - mu sync.RWMutex + chainConfig *config.ChainConfig + contracts []*config.ContractConfig + client rpc.EthClient + txClient *transaction.Client + lastUpdate map[string]time.Time + mu sync.RWMutex + receiverAddress string // hex address of the receiver contract, stored at construction } // NewWriteClient creates a new write client for destination operations @@ -70,11 +71,12 @@ func NewWriteClient(chainConfig *config.ChainConfig, contractConfigs []*config.C txClient := transaction.NewClient(receiverClient, client, queueManager, chainConfig.ChainID) return &WriteClient{ - chainConfig: chainConfig, - contracts: contractConfigs, - client: client, - txClient: txClient, - lastUpdate: make(map[string]time.Time), + chainConfig: chainConfig, + contracts: contractConfigs, + client: client, + txClient: txClient, + lastUpdate: make(map[string]time.Time), + receiverAddress: receiverAddress, }, nil } @@ -145,3 +147,46 @@ func (wc *WriteClient) callRouterMethod(ctx context.Context, updateReq *bridgety func (wc *WriteClient) GetEthClient() rpc.EthClient { return wc.client } + +// ---- Destination interface implementation ---- +// +// These four methods make *WriteClient satisfy the Destination interface so +// dispatch sites can hold a Destination instead of a concrete *WriteClient. +// The methods delegate to the existing fields without changing the EVM path. + +// Kind returns the chain backend kind. +func (wc *WriteClient) Kind() string { return "evm" } + +// ReceiverAddress returns the destination contract address (hex with 0x prefix). +func (wc *WriteClient) ReceiverAddress() string { + return wc.receiverAddress +} + +// ChainID returns the EVM chain ID. +func (wc *WriteClient) ChainID() int64 { + if wc.chainConfig == nil { + return 0 + } + return wc.chainConfig.ChainID +} + +// Send wraps callRouterMethod and adapts its return shape to TxResult. +// It fetches the gas price internally and uses DefaultGasLimit when the +// UpdateRequest carries no DestinationMethodConfig gas limit. +func (wc *WriteClient) Send(ctx context.Context, req *bridgetypes.UpdateRequest) (TxResult, error) { + gasPrice, err := wc.getGasPrice(ctx) + if err != nil { + return TxResult{}, err + } + + gasLimit := DefaultGasLimit + if req.DestinationMethodConfig != nil && req.DestinationMethodConfig.GasLimit > 0 { + gasLimit = req.DestinationMethodConfig.GasLimit + } + + tx, err := wc.callRouterMethod(ctx, req, gasPrice, gasLimit) + if err != nil { + return TxResult{}, err + } + return TxResult{TxID: tx.Hash().Hex(), Status: "Processed"}, nil +} From 2cd3227f7428fa53ced465a6b02b1e25bbd22079 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 19:55:45 +0200 Subject: [PATCH 11/21] feat(bridge): ArchWriteClient + mock-based unit tests --- .../internal/bridge/arch_write_client.go | 218 ++++++++++++++++++ .../bridge/archtest/arch_write_client_test.go | 182 +++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 services/bridge/internal/bridge/arch_write_client.go create mode 100644 services/bridge/internal/bridge/archtest/arch_write_client_test.go diff --git a/services/bridge/internal/bridge/arch_write_client.go b/services/bridge/internal/bridge/arch_write_client.go new file mode 100644 index 0000000..7b0169f --- /dev/null +++ b/services/bridge/internal/bridge/arch_write_client.go @@ -0,0 +1,218 @@ +package bridge + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" +) + +// archRPCInterface is the subset of arch.RPC methods ArchWriteClient calls. +// Defined here so tests can substitute a mock. +type archRPCInterface interface { + GetBestBlockHash(ctx context.Context) ([32]byte, error) + SendTransaction(ctx context.Context, signed []byte) (string, error) + GetProcessedTransaction(ctx context.Context, txID string) (*arch.ProcessedTx, error) +} + +// ArchWriteClient is the Arch-Network implementation of the Destination +// interface. It serializes a single OracleIntent into an Arch transaction +// calling the receiver program's HandleIntentUpdate instruction, signs with +// BIP-340 Schnorr, sends, waits for confirmation, and parses logs into +// per-intent rejections. +type ArchWriteClient struct { + chainID int64 + receiverProgramID arch.Pubkey + feeHookProgramID arch.Pubkey + rpc archRPCInterface + signer *arch.Signer + confirmTimeout time.Duration +} + +// NewArchWriteClient constructs an ArchWriteClient. +func NewArchWriteClient( + chainID int64, + receiverProgramID arch.Pubkey, + feeHookProgramID arch.Pubkey, + rpc *arch.RPC, + signer *arch.Signer, + confirmTimeout time.Duration, +) *ArchWriteClient { + if confirmTimeout <= 0 { + confirmTimeout = 30 * time.Second + } + return &ArchWriteClient{ + chainID: chainID, + receiverProgramID: receiverProgramID, + feeHookProgramID: feeHookProgramID, + rpc: rpc, + signer: signer, + confirmTimeout: confirmTimeout, + } +} + +// newArchWriteClientForTest is unexported and lets tests in package bridge inject a mock RPC. +func newArchWriteClientForTest(rpc archRPCInterface, signer *arch.Signer, receiverProgramID, feeHookProgramID arch.Pubkey) *ArchWriteClient { + return &ArchWriteClient{ + chainID: -1, + receiverProgramID: receiverProgramID, + feeHookProgramID: feeHookProgramID, + rpc: rpc, + signer: signer, + confirmTimeout: 1 * time.Second, + } +} + +// ArchRPCInterface is the RPC subset used by ArchWriteClient. +// It is exported so that external test packages can implement a mock. +type ArchRPCInterface = archRPCInterface + +// NewArchWriteClientWithRPC constructs an ArchWriteClient with a custom RPC +// implementation. Intended for test use only (e.g. package bridge_test). +func NewArchWriteClientWithRPC( + chainID int64, + receiverProgramID arch.Pubkey, + feeHookProgramID arch.Pubkey, + rpc ArchRPCInterface, + signer *arch.Signer, + confirmTimeout time.Duration, +) *ArchWriteClient { + if confirmTimeout <= 0 { + confirmTimeout = 30 * time.Second + } + return &ArchWriteClient{ + chainID: chainID, + receiverProgramID: receiverProgramID, + feeHookProgramID: feeHookProgramID, + rpc: rpc, + signer: signer, + confirmTimeout: confirmTimeout, + } +} + +func (c *ArchWriteClient) Kind() string { return "arch" } +func (c *ArchWriteClient) ChainID() int64 { return c.chainID } + +// ReceiverAddress returns the hex-encoded receiver program ID. +func (c *ArchWriteClient) ReceiverAddress() string { + return hex.EncodeToString(c.receiverProgramID[:]) +} + +// Send builds, signs, and sends a HandleIntentUpdate transaction for the +// given intent. It waits for confirmation and parses the receiver's logs. +func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (TxResult, error) { + bridgeIntent, ok := req.ExtractedData.Enrichment["fullIntent"].(*types.OracleIntent) + if !ok || bridgeIntent == nil { + return TxResult{}, fmt.Errorf("arch send: missing fullIntent in enrichment") + } + archIntent := bridgeOracleIntentToArch(bridgeIntent) + + // Derive PDAs (pure). + cfgPDA, _ := arch.ConfigPDA(c.receiverProgramID) + dedupPDA, _ := arch.DedupPDA(c.receiverProgramID) + pricePDA, _ := arch.PricePDA(c.receiverProgramID, archIntent.Symbol) + feeCfgPDA, _ := arch.FeeConfigPDA(c.feeHookProgramID) + feeVaultPDA, _ := arch.FeeVaultPDA(c.feeHookProgramID) + + // Borsh-encode HandleIntentUpdate { intent }. + data, err := arch.BuildHandleIntentUpdateData(archIntent) + if err != nil { + return TxResult{}, fmt.Errorf("arch send: encode instruction: %w", err) + } + + payer := c.signer.Pubkey() + ix := arch.Instruction{ + ProgramID: c.receiverProgramID, + Accounts: []arch.AccountMeta{ + // §3.3 required order: + // 1. dedup PDA — writable + {Pubkey: dedupPDA, IsSigner: false, IsWritable: true}, + // 2. config PDA — readonly + {Pubkey: cfgPDA, IsSigner: false, IsWritable: false}, + // 3. payer (signer.Pubkey()) — signer + writable + {Pubkey: payer, IsSigner: true, IsWritable: true}, + // 4. system program — readonly + {Pubkey: arch.SystemProgramID, IsSigner: false, IsWritable: false}, + // 5. fee_config PDA — writable + {Pubkey: feeCfgPDA, IsSigner: false, IsWritable: true}, + // 6. fee_vault PDA — writable + {Pubkey: feeVaultPDA, IsSigner: false, IsWritable: true}, + // 7. price PDA for intent.Symbol — writable + {Pubkey: pricePDA, IsSigner: false, IsWritable: true}, + }, + Data: data, + } + + blockhash, err := c.rpc.GetBestBlockHash(ctx) + if err != nil { + return TxResult{}, fmt.Errorf("arch send: get blockhash: %w", err) + } + signed, err := arch.BuildAndSignTransaction(ix, c.signer, blockhash) + if err != nil { + return TxResult{}, fmt.Errorf("arch send: build/sign: %w", err) + } + txID, err := c.rpc.SendTransaction(ctx, signed) + if err != nil { + return TxResult{}, fmt.Errorf("arch send: rpc send: %w", err) + } + + deadline := time.Now().Add(c.confirmTimeout) + for { + processed, err := c.rpc.GetProcessedTransaction(ctx, txID) + if err != nil { + return TxResult{}, fmt.Errorf("arch send: confirm %s: %w", txID, err) + } + if processed != nil { + events := arch.ParseIntentEvents(processed.Logs) + var rejections []IntentRejection + for _, e := range events { + if e.Kind == "rejected" { + rejections = append(rejections, IntentRejection{ + IntentHash: e.IntentHash, + Symbol: e.Symbol, + Signer: [20]byte(e.Signer), + Reason: e.Reason, + }) + } + } + return TxResult{ + TxID: txID, + Status: processed.Status, + Logs: processed.Logs, + Rejections: rejections, + }, nil + } + if time.Now().After(deadline) { + return TxResult{}, fmt.Errorf("arch send: confirm %s: timeout after %s", txID, c.confirmTimeout) + } + select { + case <-ctx.Done(): + return TxResult{}, ctx.Err() + case <-time.After(250 * time.Millisecond): + } + } +} + +// bridgeOracleIntentToArch converts the bridge-side types.OracleIntent (which +// uses *big.Int for U256 fields and common.Address for Signer) into +// arch.OracleIntent ([32]byte fields, EthAddress for Signer). +func bridgeOracleIntentToArch(b *types.OracleIntent) arch.OracleIntent { + var signer arch.EthAddress + copy(signer[:], b.Signer.Bytes()) + return arch.OracleIntent{ + IntentType: b.IntentType, + Version: b.Version, + ChainID: arch.U256FromBigInt(b.ChainID), + Nonce: arch.U256FromBigInt(b.Nonce), + Expiry: arch.U256FromBigInt(b.Expiry), + Symbol: b.Symbol, + Price: arch.U256FromBigInt(b.Price), + Timestamp: arch.U256FromBigInt(b.Timestamp), + Source: b.Source, + Signature: b.Signature, + Signer: signer, + } +} diff --git a/services/bridge/internal/bridge/archtest/arch_write_client_test.go b/services/bridge/internal/bridge/archtest/arch_write_client_test.go new file mode 100644 index 0000000..6ef1dc5 --- /dev/null +++ b/services/bridge/internal/bridge/archtest/arch_write_client_test.go @@ -0,0 +1,182 @@ +// Package archtest contains integration-style unit tests for bridge.ArchWriteClient. +// It lives in a separate directory so that pre-existing compile failures in +// internal/bridge/*_test.go (which reference Bridge methods not yet present) +// do not prevent our tests from running. +package archtest + +import ( + "context" + "math/big" + "testing" + "time" + + bridgeconfig "github.com/diadata.org/Spectra-interoperability/services/bridge/config" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/bridge" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" + "github.com/ethereum/go-ethereum/common" +) + +// mockArchRPC implements bridge.ArchRPCInterface for unit tests. +type mockArchRPC struct { + sentBytes []byte + txID string + processedTx *arch.ProcessedTx + confirmCallCnt int + blockhash [32]byte + sendErr error +} + +func (m *mockArchRPC) GetBestBlockHash(_ context.Context) ([32]byte, error) { + return m.blockhash, nil +} + +func (m *mockArchRPC) SendTransaction(_ context.Context, signed []byte) (string, error) { + if m.sendErr != nil { + return "", m.sendErr + } + m.sentBytes = signed + return m.txID, nil +} + +func (m *mockArchRPC) GetProcessedTransaction(_ context.Context, _ string) (*arch.ProcessedTx, error) { + m.confirmCallCnt++ + return m.processedTx, nil +} + +func sampleIntent(_ *testing.T) types.OracleIntent { + return types.OracleIntent{ + IntentType: "PriceUpdate", + Version: "1", + ChainID: new(big.Int).SetUint64(1), + Nonce: new(big.Int).SetUint64(0x11), + Expiry: new(big.Int).SetUint64(0), + Symbol: "BTC/USD", + Price: new(big.Int).SetUint64(65000000000000), + Timestamp: new(big.Int).SetUint64(1700000017), + Source: "DIA", + Signature: []byte{0x01, 0x02}, + Signer: common.HexToAddress("0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a"), + } +} + +func makeRequest(intent *types.OracleIntent) *types.UpdateRequest { + return &types.UpdateRequest{ + ExtractedData: &bridgeconfig.ExtractedData{ + Enrichment: map[string]interface{}{ + "fullIntent": intent, + }, + }, + } +} + +func newTestClient(t *testing.T, mock *mockArchRPC, timeout ...time.Duration) *bridge.ArchWriteClient { + t.Helper() + signer, err := arch.NewSignerFromHex("1111111111111111111111111111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + to := time.Second + if len(timeout) > 0 { + to = timeout[0] + } + return bridge.NewArchWriteClientWithRPC( + -1, + arch.Pubkey{0xab}, + arch.Pubkey{0xcd}, + mock, + signer, + to, + ) +} + +func TestArchWriteClient_SuccessfulUpdate(t *testing.T) { + mock := &mockArchRPC{ + txID: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + processedTx: &arch.ProcessedTx{ + Status: "Processed", + Logs: []string{ + "Program log: DIA_ORACLE.INTENT_UPDATE intent_hash=0xab00000000000000000000000000000000000000000000000000000000000000 symbol=\"BTC/USD\" price=65000000000000 timestamp=1700000017 signer=0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a", + }, + }, + } + + c := newTestClient(t, mock) + intent := sampleIntent(t) + res, err := c.Send(context.Background(), makeRequest(&intent)) + if err != nil { + t.Fatalf("Send: %v", err) + } + if res.Status != "Processed" { + t.Errorf("Status = %q, want Processed", res.Status) + } + if len(res.Rejections) != 0 { + t.Errorf("got %d rejections, want 0", len(res.Rejections)) + } + if len(mock.sentBytes) < 100 { + t.Errorf("mock got %d signed bytes, want >100", len(mock.sentBytes)) + } + if res.TxID != mock.txID { + t.Errorf("TxID = %q, want %q", res.TxID, mock.txID) + } +} + +func TestArchWriteClient_RejectionParsing(t *testing.T) { + mock := &mockArchRPC{ + txID: "1111111111111111111111111111111111111111111111111111111111111111", + processedTx: &arch.ProcessedTx{ + Status: "Processed", + Logs: []string{ + "Program log: DIA_ORACLE.INTENT_REJECTED intent_hash=0x1100000000000000000000000000000000000000000000000000000000000000 symbol=\"BTC/USD\" signer=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa reason=UnauthorizedSigner", + }, + }, + } + + c := newTestClient(t, mock) + intent := sampleIntent(t) + res, err := c.Send(context.Background(), makeRequest(&intent)) + if err != nil { + t.Fatalf("Send: %v", err) + } + if res.Status != "Processed" { + t.Errorf("Status = %q, want Processed", res.Status) + } + if len(res.Rejections) != 1 { + t.Fatalf("got %d rejections, want 1", len(res.Rejections)) + } + if res.Rejections[0].Reason != "UnauthorizedSigner" { + t.Errorf("reason = %q, want UnauthorizedSigner", res.Rejections[0].Reason) + } +} + +func TestArchWriteClient_SendError(t *testing.T) { + signer, err := arch.NewSignerFromHex("2222222222222222222222222222222222222222222222222222222222222222") + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + mock := &mockArchRPC{sendErr: context.DeadlineExceeded} + c := bridge.NewArchWriteClientWithRPC(-1, arch.Pubkey{0x01}, arch.Pubkey{0x02}, mock, signer, time.Second) + intent := sampleIntent(t) + _, err = c.Send(context.Background(), makeRequest(&intent)) + if err == nil { + t.Fatal("expected error from SendTransaction, got nil") + } +} + +func TestArchWriteClient_ConfirmTimeout(t *testing.T) { + signer, err := arch.NewSignerFromHex("3333333333333333333333333333333333333333333333333333333333333333") + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + // processedTx is nil → GetProcessedTransaction always returns (nil, nil) → timeout + mock := &mockArchRPC{ + txID: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + processedTx: nil, + } + c := bridge.NewArchWriteClientWithRPC(-1, arch.Pubkey{0x01}, arch.Pubkey{0x02}, mock, signer, 300*time.Millisecond) + intent := sampleIntent(t) + _, err = c.Send(context.Background(), makeRequest(&intent)) + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} From 37d374f55b96ce4f6336face03050b9126e72da2 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:03:23 +0200 Subject: [PATCH 12/21] feat(bridge): chain.Kind discriminator + buildDestination factory - Add ChainConfig.Kind, ContractConfig.FeeHookProgramID, DestinationMethodConfig.Kind yaml fields to config structs (empty/"evm" preserves existing behaviour) - Add buildDestination factory (unexported) switching on chain.Kind; Arch path calls newArchDestinationFromConfig; EVM path delegates to NewWriteClient - Add BuildDestinationForTest exported thin wrapper for archtest package use - Update NewBridge construction loop: Arch chains routed through buildDestination, EVM chains keep existing NewWriteClient path with full contract slice - Fix Task-9 forward concern: TransactionHandler.Process short-circuits for arch destinations, calling dest.Send directly (TODO(task-13) for DB/metrics) - Add factory_test.go in archtest: ArchKind, MissingSigner, MissingFeeHook --- services/bridge/config/event_definitions.go | 15 ++- services/bridge/config/modular_types.go | 7 ++ .../internal/bridge/archtest/factory_test.go | 84 ++++++++++++++ services/bridge/internal/bridge/bridge.go | 103 ++++++++++++++++-- .../internal/bridge/transaction_handler.go | 17 +++ 5 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 services/bridge/internal/bridge/archtest/factory_test.go diff --git a/services/bridge/config/event_definitions.go b/services/bridge/config/event_definitions.go index 840b646..71f2255 100644 --- a/services/bridge/config/event_definitions.go +++ b/services/bridge/config/event_definitions.go @@ -67,12 +67,15 @@ type LegacyRouterDestination struct { // DestinationMethodConfig defines a contract method call for generic routing type DestinationMethodConfig struct { - Name string `json:"name"` - ABI string `json:"abi"` - Params map[string]string `json:"params"` - Value string `json:"value"` - GasLimit uint64 `json:"gas_limit"` - GasMultiplier float64 `json:"gas_multiplier"` + Name string `json:"name" yaml:"name"` + ABI string `json:"abi" yaml:"abi"` + Params map[string]string `json:"params" yaml:"params"` + Value string `json:"value" yaml:"value"` + GasLimit uint64 `json:"gas_limit" yaml:"gas_limit"` + GasMultiplier float64 `json:"gas_multiplier" yaml:"gas_multiplier"` + // Kind optionally tags this method as belonging to a specific chain backend. + // Defaults to empty/"evm". Set to "arch" for Arch Network destinations. + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` } // ExtractedData represents data extracted from an event diff --git a/services/bridge/config/modular_types.go b/services/bridge/config/modular_types.go index 5777881..48b1ed1 100644 --- a/services/bridge/config/modular_types.go +++ b/services/bridge/config/modular_types.go @@ -46,6 +46,9 @@ type ChainConfig struct { DefaultGasLimit uint64 `yaml:"default_gas_limit,omitempty" json:"default_gas_limit,omitempty"` GasMultiplier float64 `yaml:"gas_multiplier,omitempty" json:"gas_multiplier,omitempty"` MaxGasPrice string `yaml:"max_gas_price,omitempty" json:"max_gas_price,omitempty"` + // Kind discriminates the chain backend. Empty string or "evm" selects the + // default EVM path. "arch" selects the Arch Network path. + Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` } type ContractConfig struct { @@ -64,6 +67,10 @@ type ContractConfig struct { // Method configuration Methods map[string]MethodConfig `yaml:"methods,omitempty" json:"methods,omitempty"` + + // FeeHookProgramID is the hex-encoded 32-byte program ID of the fee-hook + // program on Arch Network. Required when the chain Kind is "arch". + FeeHookProgramID string `yaml:"fee_hook_program_id,omitempty" json:"fee_hook_program_id,omitempty"` } type RouterConfig struct { diff --git a/services/bridge/internal/bridge/archtest/factory_test.go b/services/bridge/internal/bridge/archtest/factory_test.go new file mode 100644 index 0000000..13afb36 --- /dev/null +++ b/services/bridge/internal/bridge/archtest/factory_test.go @@ -0,0 +1,84 @@ +package archtest + +import ( + "testing" + + "github.com/diadata.org/Spectra-interoperability/services/bridge/config" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/bridge" +) + +// TestDestinationFactory_ArchKind verifies that buildDestination routes to the +// Arch backend when chain.Kind == "arch" and all required fields are present. +// It calls through BuildDestinationForTest, the thin exported wrapper around +// the unexported buildDestination — keeping the production API unexported. +func TestDestinationFactory_ArchKind(t *testing.T) { + chain := config.ChainConfig{ + ChainID: -1, + Name: "arch-testnet", + Kind: "arch", + RPCURLs: []string{"http://127.0.0.1:9002"}, + } + contract := config.ContractConfig{ + ChainID: -1, + Address: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + FeeHookProgramID: "1112131415161718191a1b1c1d1e1f2021222324252627282a2b2c2d2e2f3031", + Type: "arch-oracle-receiver", + } + secret := "1111111111111111111111111111111111111111111111111111111111111111" + + dest, err := bridge.BuildDestinationForTest(chain, contract, secret) + if err != nil { + t.Fatalf("BuildDestinationForTest: %v", err) + } + if dest.Kind() != "arch" { + t.Errorf("Kind() = %q, want arch", dest.Kind()) + } + if dest.ChainID() != -1 { + t.Errorf("ChainID() = %d, want -1", dest.ChainID()) + } +} + +// TestDestinationFactory_ArchKind_MissingSigner ensures a missing signer secret +// returns an error for arch destinations. +func TestDestinationFactory_ArchKind_MissingSigner(t *testing.T) { + chain := config.ChainConfig{ + ChainID: -1, + Name: "arch-testnet", + Kind: "arch", + RPCURLs: []string{"http://127.0.0.1:9002"}, + } + contract := config.ContractConfig{ + ChainID: -1, + Address: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + FeeHookProgramID: "1112131415161718191a1b1c1d1e1f2021222324252627282a2b2c2d2e2f3031", + Type: "arch-oracle-receiver", + } + + _, err := bridge.BuildDestinationForTest(chain, contract, "") + if err == nil { + t.Fatal("expected error for missing signer, got nil") + } +} + +// TestDestinationFactory_ArchKind_MissingFeeHook ensures a missing +// fee_hook_program_id returns an error for arch destinations. +func TestDestinationFactory_ArchKind_MissingFeeHook(t *testing.T) { + chain := config.ChainConfig{ + ChainID: -1, + Name: "arch-testnet", + Kind: "arch", + RPCURLs: []string{"http://127.0.0.1:9002"}, + } + contract := config.ContractConfig{ + ChainID: -1, + Address: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + // FeeHookProgramID deliberately absent + Type: "arch-oracle-receiver", + } + secret := "1111111111111111111111111111111111111111111111111111111111111111" + + _, err := bridge.BuildDestinationForTest(chain, contract, secret) + if err == nil { + t.Fatal("expected error for missing fee_hook_program_id, got nil") + } +} diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index ab1c637..7ea8ab2 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -2,6 +2,7 @@ package bridge import ( "context" + "encoding/hex" "fmt" "math/big" "sync" @@ -13,6 +14,7 @@ import ( "github.com/diadata.org/Spectra-interoperability/pkg/rpc" "github.com/diadata.org/Spectra-interoperability/services/bridge/config" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/api" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/database" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/leader" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/metrics" @@ -126,16 +128,30 @@ func NewBridge(modularCfg *config.ModularConfig, cfgService *config.ConfigServic continue } - // For NonceManager - oracleCount := countDestinationsForChain(routerRegistry, chainConfig.ChainID) - maxSafeGap := calculateMaxSafeGap(oracleCount) - logger.Infof("Chain %d (%s): %d oracles configured, maxSafeGap=%d", - chainConfig.ChainID, chainConfig.Name, oracleCount, maxSafeGap) - - destClient, err := NewWriteClient(chainConfig, contracts, cfgService.GetInfrastructure().PrivateKey, queueManager, maxSafeGap) - if err != nil { - logger.Errorf("Failed to create destination client for chain %d: %v", chainConfig.ChainID, err) - continue + var destClient Destination + if chainConfig.Kind == "arch" { + // Arch Network: use the first enabled contract as the receiver program. + archContract := contracts[0] + var buildErr error + destClient, buildErr = buildDestination(*chainConfig, *archContract, cfgService.GetInfrastructure().PrivateKey) + if buildErr != nil { + logger.Errorf("Failed to create Arch destination client for chain %d: %v", chainConfig.ChainID, buildErr) + continue + } + } else { + // EVM path: pass all contracts (existing behaviour). + // For NonceManager + oracleCount := countDestinationsForChain(routerRegistry, chainConfig.ChainID) + maxSafeGap := calculateMaxSafeGap(oracleCount) + logger.Infof("Chain %d (%s): %d oracles configured, maxSafeGap=%d", + chainConfig.ChainID, chainConfig.Name, oracleCount, maxSafeGap) + + var evmErr error + destClient, evmErr = NewWriteClient(chainConfig, contracts, cfgService.GetInfrastructure().PrivateKey, queueManager, maxSafeGap) + if evmErr != nil { + logger.Errorf("Failed to create destination client for chain %d: %v", chainConfig.ChainID, evmErr) + continue + } } destClients[chainConfig.ChainID] = destClient } @@ -608,4 +624,71 @@ func (b *Bridge) handleUpdateRequest(ctx context.Context, task *worker.WorkerTas return handler.Process(ctx, task.Request) } +// buildDestination is the factory that picks the right backend based on +// chain.Kind. An empty Kind (or "evm") selects the existing EVM WriteClient. +// "arch" selects ArchWriteClient. For EVM chains the caller must use +// NewWriteClient directly (passing the full contract slice); this function +// handles the single-contract Arch path. +func buildDestination(chain config.ChainConfig, contract config.ContractConfig, signerSecretHex string) (Destination, error) { + switch chain.Kind { + case "arch": + return newArchDestinationFromConfig(chain, contract, signerSecretHex) + default: // "evm" or "" + // Single-contract shim: wrap in a slice for NewWriteClient. + // In production, NewBridge calls NewWriteClient directly with the full + // contract slice; this path is used by tests and tooling that construct + // an EVM destination from a single ContractConfig. + return NewWriteClient(&chain, []*config.ContractConfig{&contract}, signerSecretHex, nil, 5) + } +} + +// newArchDestinationFromConfig validates config fields and constructs an +// ArchWriteClient from a chain/contract config pair. +func newArchDestinationFromConfig(chain config.ChainConfig, contract config.ContractConfig, signerSecretHex string) (Destination, error) { + if signerSecretHex == "" { + return nil, fmt.Errorf("arch destination %d: missing signer key", chain.ChainID) + } + signer, err := arch.NewSignerFromHex(signerSecretHex) + if err != nil { + return nil, fmt.Errorf("arch destination %d: %w", chain.ChainID, err) + } + receiverPK, err := decodePubkeyHex(contract.Address) + if err != nil { + return nil, fmt.Errorf("arch destination %d: receiver address: %w", chain.ChainID, err) + } + if contract.FeeHookProgramID == "" { + return nil, fmt.Errorf("arch destination %d: missing fee_hook_program_id", chain.ChainID) + } + feeHookPK, err := decodePubkeyHex(contract.FeeHookProgramID) + if err != nil { + return nil, fmt.Errorf("arch destination %d: fee hook id: %w", chain.ChainID, err) + } + if len(chain.RPCURLs) == 0 { + return nil, fmt.Errorf("arch destination %d: no rpc_urls", chain.ChainID) + } + rpc := arch.NewRPC(chain.RPCURLs[0]) + return NewArchWriteClient(chain.ChainID, receiverPK, feeHookPK, rpc, signer, 30*time.Second), nil +} + +// decodePubkeyHex decodes a 64-character hex string into an arch.Pubkey. +func decodePubkeyHex(s string) (arch.Pubkey, error) { + if len(s) != 64 { + return arch.Pubkey{}, fmt.Errorf("pubkey hex must be 64 chars, got %d", len(s)) + } + raw, err := hex.DecodeString(s) + if err != nil { + return arch.Pubkey{}, err + } + var out arch.Pubkey + copy(out[:], raw) + return out, nil +} + +// BuildDestinationForTest is an exported thin wrapper around buildDestination +// that allows external test packages (e.g. archtest) to exercise the factory +// without exposing it as part of the public production API. +func BuildDestinationForTest(chain config.ChainConfig, contract config.ContractConfig, signerSecretHex string) (Destination, error) { + return buildDestination(chain, contract, signerSecretHex) +} + // callRouterMethod calls a contract method using router configuration diff --git a/services/bridge/internal/bridge/transaction_handler.go b/services/bridge/internal/bridge/transaction_handler.go index a4cefab..c61c6f7 100644 --- a/services/bridge/internal/bridge/transaction_handler.go +++ b/services/bridge/internal/bridge/transaction_handler.go @@ -49,6 +49,23 @@ func NewTransactionHandler(writeClients map[int64]Destination, registry *router. // Process handles the complete transaction lifecycle func (h *TransactionHandler) Process(ctx context.Context, updateReq *bridgetypes.UpdateRequest) error { + // Fast path for non-EVM destinations: bypass the EVM-specific buildContext + // (which errors on type-assertion to *WriteClient) and call Destination.Send + // directly. Full DB persistence and metrics for the Arch path land in Task 13. + if updateReq != nil && updateReq.DestinationChain != nil { + dest := h.writeClients[updateReq.DestinationChain.ChainID] + if dest != nil && dest.Kind() == "arch" { + res, err := dest.Send(ctx, updateReq) + if err != nil { + return fmt.Errorf("arch send (chain %d): %w", updateReq.DestinationChain.ChainID, err) + } + // TODO(task-13): persist TxResult to DB and record metrics here. + logger.Infof("Arch transaction sent: txID=%s status=%s chain=%d", + res.TxID, res.Status, updateReq.DestinationChain.ChainID) + return nil + } + } + txCtx, err := h.buildContext(ctx, updateReq) if err != nil { return err From 108b42893861b7af59be8174f144b6648e9c248a Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:09:22 +0200 Subject: [PATCH 13/21] feat(database): arch_logs column + dia_arch_rejections table --- .../internal/database/arch_rejections.go | 67 +++++++++++++++++++ .../internal/database/arch_rejections_test.go | 53 +++++++++++++++ services/bridge/internal/database/database.go | 6 ++ .../internal/database/migrate_arch_logs.go | 20 ++++++ services/bridge/internal/database/schema.go | 14 ++++ 5 files changed, 160 insertions(+) create mode 100644 services/bridge/internal/database/arch_rejections.go create mode 100644 services/bridge/internal/database/arch_rejections_test.go create mode 100644 services/bridge/internal/database/migrate_arch_logs.go diff --git a/services/bridge/internal/database/arch_rejections.go b/services/bridge/internal/database/arch_rejections.go new file mode 100644 index 0000000..92b2853 --- /dev/null +++ b/services/bridge/internal/database/arch_rejections.go @@ -0,0 +1,67 @@ +package database + +import ( + "context" + "database/sql" + "time" +) + +// ArchRejection mirrors one row of dia_arch_rejections. +type ArchRejection struct { + ID int64 + EventID sql.NullInt64 + IntentHash []byte + Symbol string + Signer []byte + Reason string + TxHash string + CreatedAt time.Time +} + +// InsertArchRejection persists one parsed rejection. eventID == 0 inserts NULL. +func InsertArchRejection( + ctx context.Context, + db *sql.DB, + eventID int64, + intentHash [32]byte, + symbol string, + signer [20]byte, + reason, txHash string, +) error { + const q = ` + INSERT INTO dia_arch_rejections (event_id, intent_hash, symbol, signer, reason, tx_hash) + VALUES ($1, $2, $3, $4, $5, $6) + ` + var evtArg interface{} + if eventID > 0 { + evtArg = eventID + } else { + evtArg = nil + } + _, err := db.ExecContext(ctx, q, evtArg, intentHash[:], symbol, signer[:], reason, txHash) + return err +} + +// ListArchRejections returns rejections newer than since, newest first. +func ListArchRejections(ctx context.Context, db *sql.DB, since time.Time) ([]ArchRejection, error) { + const q = ` + SELECT id, event_id, intent_hash, symbol, signer, reason, tx_hash, created_at + FROM dia_arch_rejections + WHERE created_at >= $1 + ORDER BY created_at DESC + ` + rows, err := db.QueryContext(ctx, q, since) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ArchRejection + for rows.Next() { + var r ArchRejection + if err := rows.Scan(&r.ID, &r.EventID, &r.IntentHash, &r.Symbol, &r.Signer, &r.Reason, &r.TxHash, &r.CreatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/services/bridge/internal/database/arch_rejections_test.go b/services/bridge/internal/database/arch_rejections_test.go new file mode 100644 index 0000000..b53545e --- /dev/null +++ b/services/bridge/internal/database/arch_rejections_test.go @@ -0,0 +1,53 @@ +package database + +import ( + "context" + "database/sql" + "os" + "testing" + "time" +) + +// dbForTest returns an open *sql.DB if TEST_DATABASE_URL is set, else skips. +func dbForTest(t *testing.T) *sql.DB { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + return db +} + +func TestInsertAndListArchRejection(t *testing.T) { + db := dbForTest(t) + defer db.Close() + // Seed: needs a processed_events row to reference. Use NULL eventID via + // the helper accepting 0 to skip the FK ref (modify the helper to accept + // a nullable event ID for tests). + var hash [32]byte + copy(hash[:], []byte("00112233445566778899aabbccddeeff")) + var signer [20]byte + copy(signer[:], []byte("aaaaaaaaaaaaaaaaaaaa")) + + err := InsertArchRejection(context.Background(), db, 0, hash, "BTC/USD", signer, "UnauthorizedSigner", "abcd") + if err != nil { + t.Fatalf("InsertArchRejection: %v", err) + } + rows, err := ListArchRejections(context.Background(), db, time.Now().Add(-1*time.Hour)) + if err != nil { + t.Fatalf("ListArchRejections: %v", err) + } + if len(rows) == 0 { + t.Fatalf("got 0 rows, want at least 1") + } + t.Cleanup(func() { + db.Exec("DELETE FROM dia_arch_rejections WHERE created_at > NOW() - INTERVAL '1 hour'") + }) +} diff --git a/services/bridge/internal/database/database.go b/services/bridge/internal/database/database.go index 7c39ee8..194e30d 100644 --- a/services/bridge/internal/database/database.go +++ b/services/bridge/internal/database/database.go @@ -122,6 +122,7 @@ func (db *DB) Migrate() error { createContractSymbolUpdateTable, createPerformanceMetricsTable, createAlertLogTable, + createDIAArchRejectionsTable, createIndices, } @@ -136,6 +137,11 @@ func (db *DB) Migrate() error { return fmt.Errorf("generic events migration failed: %w", err) } + // Run arch logs migration + if err := db.MigrateForArchLogs(); err != nil { + return fmt.Errorf("arch logs migration failed: %w", err) + } + return nil } diff --git a/services/bridge/internal/database/migrate_arch_logs.go b/services/bridge/internal/database/migrate_arch_logs.go new file mode 100644 index 0000000..062f548 --- /dev/null +++ b/services/bridge/internal/database/migrate_arch_logs.go @@ -0,0 +1,20 @@ +package database + +import ( + "fmt" +) + +// MigrateForArchLogs adds the arch_logs column to processed_events. +func (db *DB) MigrateForArchLogs() error { + queries := []string{ + `ALTER TABLE processed_events ADD COLUMN IF NOT EXISTS arch_logs JSONB`, + } + + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + } + + return nil +} diff --git a/services/bridge/internal/database/schema.go b/services/bridge/internal/database/schema.go index 1939832..b7fdc94 100644 --- a/services/bridge/internal/database/schema.go +++ b/services/bridge/internal/database/schema.go @@ -137,6 +137,20 @@ CREATE TABLE IF NOT EXISTS alert_log ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );` + createDIAArchRejectionsTable = ` +CREATE TABLE IF NOT EXISTS dia_arch_rejections ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT REFERENCES processed_events(id) ON DELETE CASCADE, + intent_hash BYTEA NOT NULL, + symbol VARCHAR NOT NULL, + signer BYTEA NOT NULL, + reason VARCHAR NOT NULL, + tx_hash VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS dia_arch_rejections_reason_idx ON dia_arch_rejections(reason, created_at); +CREATE INDEX IF NOT EXISTS dia_arch_rejections_signer_idx ON dia_arch_rejections(signer, created_at);` + createIndices = ` -- Processed events indices CREATE INDEX IF NOT EXISTS idx_processed_events_block_number ON processed_events(block_number); From 263def42d1031a77d9ed753750fb08ae593b1ecc Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:15:33 +0200 Subject: [PATCH 14/21] feat(bridge): persist Arch rejections in TransactionHandler --- services/bridge/go.mod | 1 + services/bridge/go.sum | 3 + .../archtest/transaction_handler_test.go | 119 ++++++++++++++++++ services/bridge/internal/bridge/bridge.go | 7 +- .../internal/bridge/transaction_handler.go | 50 +++++++- 5 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 services/bridge/internal/bridge/archtest/transaction_handler_test.go diff --git a/services/bridge/go.mod b/services/bridge/go.mod index 62e250c..48726de 100644 --- a/services/bridge/go.mod +++ b/services/bridge/go.mod @@ -25,6 +25,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/services/bridge/go.sum b/services/bridge/go.sum index 750a296..74aeda5 100644 --- a/services/bridge/go.sum +++ b/services/bridge/go.sum @@ -1,3 +1,5 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -126,6 +128,7 @@ github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7 github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= diff --git a/services/bridge/internal/bridge/archtest/transaction_handler_test.go b/services/bridge/internal/bridge/archtest/transaction_handler_test.go new file mode 100644 index 0000000..331f640 --- /dev/null +++ b/services/bridge/internal/bridge/archtest/transaction_handler_test.go @@ -0,0 +1,119 @@ +package archtest + +import ( + "context" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + + bridgeconfig "github.com/diadata.org/Spectra-interoperability/services/bridge/config" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/bridge" + bridgetypes "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" +) + +// stubArchDest is a minimal Destination that returns a pre-configured TxResult. +type stubArchDest struct { + chainID int64 + result bridge.TxResult +} + +func (s *stubArchDest) Send(_ context.Context, _ *bridgetypes.UpdateRequest) (bridge.TxResult, error) { + return s.result, nil +} +func (s *stubArchDest) ReceiverAddress() string { return "" } +func (s *stubArchDest) ChainID() int64 { return s.chainID } +func (s *stubArchDest) Kind() string { return "arch" } + +func archUpdateReq(chainID int64) *bridgetypes.UpdateRequest { + return &bridgetypes.UpdateRequest{ + DestinationChain: &bridgeconfig.DestinationConfig{ChainID: chainID}, + } +} + +// TestProcess_ArchPartialDelivery_PersistsRejections verifies that when +// Destination.Send returns 2 rejections, TransactionHandler.Process executes +// 2 INSERT statements into dia_arch_rejections. +func TestProcess_ArchPartialDelivery_PersistsRejections(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + hash1 := [32]byte{0x11} + hash2 := [32]byte{0x22} + signer1 := [20]byte{0xaa} + signer2 := [20]byte{0xbb} + txID := "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" + + // Expect 2 INSERT calls — one per rejection. + mock.ExpectExec(`INSERT INTO dia_arch_rejections`). + WithArgs(nil, hash1[:], "BTC/USD", signer1[:], "UnauthorizedSigner", txID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`INSERT INTO dia_arch_rejections`). + WithArgs(nil, hash2[:], "ETH/USD", signer2[:], "AlreadyProcessed", txID). + WillReturnResult(sqlmock.NewResult(2, 1)) + + dest := &stubArchDest{ + chainID: -1, + result: bridge.TxResult{ + TxID: txID, + Status: "Processed", + Rejections: []bridge.IntentRejection{ + {IntentHash: hash1, Symbol: "BTC/USD", Signer: signer1, Reason: "UnauthorizedSigner"}, + {IntentHash: hash2, Symbol: "ETH/USD", Signer: signer2, Reason: "AlreadyProcessed"}, + }, + }, + } + + handler := bridge.NewTransactionHandler( + map[int64]bridge.Destination{-1: dest}, + nil, + nil, + db, + ) + + if err := handler.Process(context.Background(), archUpdateReq(-1)); err != nil { + t.Fatalf("Process: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestProcess_ArchSuccess_NoRejections verifies that a fully delivered Arch +// transaction triggers no INSERT calls. +func TestProcess_ArchSuccess_NoRejections(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + // No expectations set — any unexpected SQL call will fail the test. + + dest := &stubArchDest{ + chainID: -1, + result: bridge.TxResult{ + TxID: "0000000000000000000000000000000000000000000000000000000000000000", + Status: "Processed", + Rejections: nil, + }, + } + + handler := bridge.NewTransactionHandler( + map[int64]bridge.Destination{-1: dest}, + nil, + nil, + db, + ) + + if err := handler.Process(context.Background(), archUpdateReq(-1)); err != nil { + t.Fatalf("Process: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index 7ea8ab2..e5705e4 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -2,6 +2,7 @@ package bridge import ( "context" + "database/sql" "encoding/hex" "fmt" "math/big" @@ -620,7 +621,11 @@ func (b *Bridge) handleUpdateRequest(ctx context.Context, task *worker.WorkerTas } }() - handler := NewTransactionHandler(b.writeClients, b.routerRegistry, b.metricsManager.GetTracker()) + var rawDB *sql.DB + if b.db != nil { + rawDB = b.db.DB + } + handler := NewTransactionHandler(b.writeClients, b.routerRegistry, b.metricsManager.GetTracker(), rawDB) return handler.Process(ctx, task.Request) } diff --git a/services/bridge/internal/bridge/transaction_handler.go b/services/bridge/internal/bridge/transaction_handler.go index c61c6f7..bcb26ef 100644 --- a/services/bridge/internal/bridge/transaction_handler.go +++ b/services/bridge/internal/bridge/transaction_handler.go @@ -2,6 +2,7 @@ package bridge import ( "context" + "database/sql" "fmt" "math/big" "time" @@ -12,6 +13,7 @@ import ( "github.com/diadata.org/Spectra-interoperability/pkg/logger" "github.com/diadata.org/Spectra-interoperability/pkg/rpc" "github.com/diadata.org/Spectra-interoperability/services/bridge/config" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/database" bridgetypes "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" "github.com/diadata.org/Spectra-interoperability/services/bridge/pkg/router" ) @@ -36,14 +38,16 @@ type TransactionHandler struct { writeClients map[int64]Destination routerRegistry *router.GenericRegistry metricsTracker *MetricsTracker + db *sql.DB } // NewTransactionHandler creates a new transaction handler -func NewTransactionHandler(writeClients map[int64]Destination, registry *router.GenericRegistry, tracker *MetricsTracker) *TransactionHandler { +func NewTransactionHandler(writeClients map[int64]Destination, registry *router.GenericRegistry, tracker *MetricsTracker, db *sql.DB) *TransactionHandler { return &TransactionHandler{ writeClients: writeClients, routerRegistry: registry, metricsTracker: tracker, + db: db, } } @@ -59,9 +63,47 @@ func (h *TransactionHandler) Process(ctx context.Context, updateReq *bridgetypes if err != nil { return fmt.Errorf("arch send (chain %d): %w", updateReq.DestinationChain.ChainID, err) } - // TODO(task-13): persist TxResult to DB and record metrics here. - logger.Infof("Arch transaction sent: txID=%s status=%s chain=%d", - res.TxID, res.Status, updateReq.DestinationChain.ChainID) + + chainID := updateReq.DestinationChain.ChainID + rejCount := len(res.Rejections) + + // Determine outcome status label. + outcomeStatus := "delivered" + if res.Status == "Failed" { + outcomeStatus = "failed" + } else if rejCount > 0 { + outcomeStatus = "partially_delivered" + } + + // Persist per-intent rejections into dia_arch_rejections. + // eventID=0 maps to NULL FK because we cannot reconstruct the + // composite processed_events.id without the upstream sha256 key — + // that key is built in generic_event_processor.go with data not + // available here. + // TODO(follow-up): wire eventID once processed_events gains a + // stable lookup path accessible from TransactionHandler. + // + // TODO(follow-up): write arch_logs JSONB column to processed_events + // once the schema adds that column (schema gap — no arch_logs field + // exists in processed_events as of this task). + if h.db != nil { + for _, rej := range res.Rejections { + if insErr := database.InsertArchRejection(ctx, h.db, 0, rej.IntentHash, rej.Symbol, rej.Signer, rej.Reason, res.TxID); insErr != nil { + logger.Warnf("arch: persist rejection (txID=%s intentHash=%x): %v", res.TxID, rej.IntentHash, insErr) + } + } + } + + // Structured outcome log. + switch outcomeStatus { + case "failed": + logger.Warnf("Arch transaction outcome: txID=%s status=%s chain=%d rejections=%d partialDelivery=%v", + res.TxID, outcomeStatus, chainID, rejCount, false) + default: + logger.Infof("Arch transaction outcome: txID=%s status=%s chain=%d rejections=%d partialDelivery=%v", + res.TxID, outcomeStatus, chainID, rejCount, outcomeStatus == "partially_delivered") + } + return nil } } From d3186604fba1a6e0e1cf8bdeb2bb133e39024614 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:18:50 +0200 Subject: [PATCH 15/21] fix(bridge): task-13 review fixes (status TODO + go.mod direct dep) --- services/bridge/go.mod | 4 ++-- services/bridge/go.sum | 6 ------ services/bridge/internal/bridge/transaction_handler.go | 5 +++++ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/services/bridge/go.mod b/services/bridge/go.mod index 48726de..c2664b2 100644 --- a/services/bridge/go.mod +++ b/services/bridge/go.mod @@ -9,6 +9,8 @@ replace github.com/diadata.org/Spectra-interoperability/proto => ../../proto replace github.com/diadata.org/Spectra-interoperability => ../../ require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/btcsuite/btcd/btcec/v2 v2.3.4 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/diadata.org/Spectra-interoperability v0.0.0-00010101000000-000000000000 github.com/diadata.org/Spectra-interoperability/proto v0.0.0-00010101000000-000000000000 @@ -25,12 +27,10 @@ require ( ) require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/gnark-crypto v0.18.0 // indirect diff --git a/services/bridge/go.sum b/services/bridge/go.sum index 74aeda5..89bb6b4 100644 --- a/services/bridge/go.sum +++ b/services/bridge/go.sum @@ -14,8 +14,6 @@ github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3M github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= -github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= -github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= @@ -49,12 +47,8 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= diff --git a/services/bridge/internal/bridge/transaction_handler.go b/services/bridge/internal/bridge/transaction_handler.go index bcb26ef..9ac18e1 100644 --- a/services/bridge/internal/bridge/transaction_handler.go +++ b/services/bridge/internal/bridge/transaction_handler.go @@ -94,6 +94,11 @@ func (h *TransactionHandler) Process(ctx context.Context, updateReq *bridgetypes } } + // TODO(follow-up): mutate processed_events.status based on outcomeStatus + // (deferred to Task 13 step 2). processed_events table has no status + // column (would require schema migration with broad impact), and + // composite intent-hash lookup back to the row is not trivially available. + // Structured outcome log. switch outcomeStatus { case "failed": From 7051641b34ceb3e2434897fa8c1810c2fe0e3552 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:22:39 +0200 Subject: [PATCH 16/21] feat(metrics): Arch destination Prometheus collectors --- .../internal/bridge/arch_write_client.go | 15 +++++ services/bridge/internal/bridge/bridge.go | 4 ++ .../bridge/internal/metrics/arch_metrics.go | 65 +++++++++++++++++++ .../internal/metrics/arch_metrics_test.go | 19 ++++++ 4 files changed, 103 insertions(+) create mode 100644 services/bridge/internal/metrics/arch_metrics.go create mode 100644 services/bridge/internal/metrics/arch_metrics_test.go diff --git a/services/bridge/internal/bridge/arch_write_client.go b/services/bridge/internal/bridge/arch_write_client.go index 7b0169f..ed6dfe1 100644 --- a/services/bridge/internal/bridge/arch_write_client.go +++ b/services/bridge/internal/bridge/arch_write_client.go @@ -4,9 +4,11 @@ import ( "context" "encoding/hex" "fmt" + "strconv" "time" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/metrics" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" ) @@ -178,6 +180,19 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T }) } } + // Emit metrics from parsed events. + chainIDStr := strconv.FormatInt(c.chainID, 10) + routerID := req.RouterID + for _, e := range events { + switch e.Kind { + case "update": + metrics.ArchIntentUpdates.WithLabelValues(routerID, chainIDStr, e.Symbol).Inc() + case "stale": + metrics.ArchIntentStale.WithLabelValues(routerID, chainIDStr, e.Symbol).Inc() + case "rejected": + metrics.ArchIntentRejected.WithLabelValues(routerID, chainIDStr, e.Reason).Inc() + } + } return TxResult{ TxID: txID, Status: processed.Status, diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index e5705e4..6fa1e12 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/prometheus/client_golang/prometheus" "github.com/diadata.org/Spectra-interoperability/pkg/logger" "github.com/diadata.org/Spectra-interoperability/pkg/rpc" @@ -172,6 +173,9 @@ func NewBridge(modularCfg *config.ModularConfig, cfgService *config.ConfigServic eventChan := make(chan *bridgetypes.EventData, 100) errorChan := make(chan error, 10) + // Register Arch-specific Prometheus collectors once at startup. + metrics.RegisterArchMetrics(prometheus.DefaultRegisterer) + // Create metrics manager metricsManager := NewMetricsManager(metricsCollector) diff --git a/services/bridge/internal/metrics/arch_metrics.go b/services/bridge/internal/metrics/arch_metrics.go new file mode 100644 index 0000000..6cda06a --- /dev/null +++ b/services/bridge/internal/metrics/arch_metrics.go @@ -0,0 +1,65 @@ +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +var ( + ArchIntentUpdates = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dia_arch_intent_updates_total", + Help: "Per-symbol count of DIA_ORACLE.INTENT_UPDATE events emitted by the receiver.", + }, + []string{"router", "chain_id", "symbol"}, + ) + ArchIntentStale = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dia_arch_intent_stale_total", + Help: "Per-symbol count of DIA_ORACLE.INTENT_STALE events (stored value newer than incoming intent).", + }, + []string{"router", "chain_id", "symbol"}, + ) + ArchIntentRejected = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "dia_arch_intent_rejected_total", + Help: "Per-reason count of DIA_ORACLE.INTENT_REJECTED events (UnauthorizedSigner/AlreadyProcessed/InvalidSignature).", + }, + []string{"router", "chain_id", "reason"}, + ) + ArchTxConfirmationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "dia_arch_tx_confirmation_seconds", + Help: "Time from SendTransaction to processed status.", + Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30}, + }, + []string{"router", "chain_id", "outcome"}, + ) + ArchFeeVaultLamports = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dia_arch_fee_vault_lamports", + Help: "Current lamport balance of the fee-hook vault PDA.", + }, + []string{"router", "chain_id"}, + ) + ArchPayerBalanceLamports = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dia_arch_payer_balance_lamports", + Help: "Current lamport balance of the relayer payer account.", + }, + []string{"router", "chain_id"}, + ) +) + +// RegisterArchMetrics registers all six collectors against reg. Call once at +// startup. Idempotent: re-registration after the first call is a no-op. +func RegisterArchMetrics(reg prometheus.Registerer) { + for _, c := range []prometheus.Collector{ + ArchIntentUpdates, ArchIntentStale, ArchIntentRejected, + ArchTxConfirmationSeconds, ArchFeeVaultLamports, ArchPayerBalanceLamports, + } { + // MustRegister panics on duplicate; use Register and swallow AlreadyRegisteredError. + if err := reg.Register(c); err != nil { + if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + panic(err) + } + } + } +} diff --git a/services/bridge/internal/metrics/arch_metrics_test.go b/services/bridge/internal/metrics/arch_metrics_test.go new file mode 100644 index 0000000..9e20d38 --- /dev/null +++ b/services/bridge/internal/metrics/arch_metrics_test.go @@ -0,0 +1,19 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestArchMetricsRegisteredOnce(t *testing.T) { + reg := prometheus.NewRegistry() + RegisterArchMetrics(reg) + // Increment one counter; verify it's visible to the registry. + ArchIntentUpdates.WithLabelValues("dia-arch-testnet", "-1", "BTC/USD").Inc() + got := testutil.ToFloat64(ArchIntentUpdates.WithLabelValues("dia-arch-testnet", "-1", "BTC/USD")) + if got != 1 { + t.Fatalf("got %v, want 1", got) + } +} From 066f8ae44cdca74e22775c7ff89e96ee10cb657a Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:26:46 +0200 Subject: [PATCH 17/21] feat(bridge): background poller for fee_vault and payer balance gauges --- .../bridge/internal/bridge/arch_poller.go | 53 ++++++++++++++++++ .../internal/bridge/arch_write_client.go | 1 + .../bridge/archtest/arch_poller_test.go | 56 +++++++++++++++++++ .../bridge/archtest/arch_write_client_test.go | 4 ++ services/bridge/internal/bridge/bridge.go | 8 +++ 5 files changed, 122 insertions(+) create mode 100644 services/bridge/internal/bridge/arch_poller.go create mode 100644 services/bridge/internal/bridge/archtest/arch_poller_test.go diff --git a/services/bridge/internal/bridge/arch_poller.go b/services/bridge/internal/bridge/arch_poller.go new file mode 100644 index 0000000..8d1b391 --- /dev/null +++ b/services/bridge/internal/bridge/arch_poller.go @@ -0,0 +1,53 @@ +package bridge + +import ( + "context" + "log" + "strconv" + "time" + + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/metrics" +) + +// StartArchPoller polls the fee vault + payer balance gauges on a ticker. +// It runs until ctx is cancelled. +func StartArchPoller(ctx context.Context, routerID string, c *ArchWriteClient, interval time.Duration) { + if interval <= 0 { + interval = 30 * time.Second + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + // Run one tick immediately to seed gauges before the first interval. + pollOnce(ctx, routerID, c) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pollOnce(ctx, routerID, c) + } + } + }() +} + +func pollOnce(ctx context.Context, routerID string, c *ArchWriteClient) { + // archRPCInterface includes ReadAccountInfo, so the cast always succeeds + // for any properly constructed ArchWriteClient. + rpc := c.rpc + chainStr := strconv.FormatInt(c.chainID, 10) + + vaultPDA, _ := arch.FeeVaultPDA(c.feeHookProgramID) + if info, err := rpc.ReadAccountInfo(ctx, vaultPDA); err == nil && info != nil { + metrics.ArchFeeVaultLamports.WithLabelValues(routerID, chainStr).Set(float64(info.Lamports)) + } else if err != nil { + log.Printf("arch poller: read fee vault: %v", err) + } + + payer := c.signer.Pubkey() + if info, err := rpc.ReadAccountInfo(ctx, payer); err == nil && info != nil { + // routerID is empty at gauge level; per-update counters carry RouterID label. + metrics.ArchPayerBalanceLamports.WithLabelValues(routerID, chainStr).Set(float64(info.Lamports)) + } +} diff --git a/services/bridge/internal/bridge/arch_write_client.go b/services/bridge/internal/bridge/arch_write_client.go index ed6dfe1..7f298b1 100644 --- a/services/bridge/internal/bridge/arch_write_client.go +++ b/services/bridge/internal/bridge/arch_write_client.go @@ -18,6 +18,7 @@ type archRPCInterface interface { GetBestBlockHash(ctx context.Context) ([32]byte, error) SendTransaction(ctx context.Context, signed []byte) (string, error) GetProcessedTransaction(ctx context.Context, txID string) (*arch.ProcessedTx, error) + ReadAccountInfo(ctx context.Context, pubkey arch.Pubkey) (*arch.AccountInfo, error) } // ArchWriteClient is the Arch-Network implementation of the Destination diff --git a/services/bridge/internal/bridge/archtest/arch_poller_test.go b/services/bridge/internal/bridge/archtest/arch_poller_test.go new file mode 100644 index 0000000..412d68b --- /dev/null +++ b/services/bridge/internal/bridge/archtest/arch_poller_test.go @@ -0,0 +1,56 @@ +package archtest + +import ( + "context" + "testing" + "time" + + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/bridge" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/metrics" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// readMock implements bridge.ArchRPCInterface and always returns 123456 lamports. +type readMock struct{} + +func (readMock) GetBestBlockHash(_ context.Context) ([32]byte, error) { return [32]byte{}, nil } +func (readMock) SendTransaction(_ context.Context, _ []byte) (string, error) { + return "", nil +} +func (readMock) GetProcessedTransaction(_ context.Context, _ string) (*arch.ProcessedTx, error) { + return nil, nil +} +func (readMock) ReadAccountInfo(_ context.Context, _ arch.Pubkey) (*arch.AccountInfo, error) { + return &arch.AccountInfo{Lamports: 123456}, nil +} + +func TestArchPoller_UpdatesGauges(t *testing.T) { + metrics.RegisterArchMetrics(prometheus.NewRegistry()) + + signer, err := arch.NewSignerFromHex("1111111111111111111111111111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + + c := bridge.NewArchWriteClientWithRPC( + -1, + arch.Pubkey{0xab}, + arch.Pubkey{0xcd}, + readMock{}, + signer, + time.Second, + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + bridge.StartArchPoller(ctx, "test-router", c, 50*time.Millisecond) + time.Sleep(75 * time.Millisecond) + + got := testutil.ToFloat64(metrics.ArchFeeVaultLamports.WithLabelValues("test-router", "-1")) + if got != 123456 { + t.Fatalf("ArchFeeVaultLamports got %v, want 123456", got) + } +} diff --git a/services/bridge/internal/bridge/archtest/arch_write_client_test.go b/services/bridge/internal/bridge/archtest/arch_write_client_test.go index 6ef1dc5..8bc6fb1 100644 --- a/services/bridge/internal/bridge/archtest/arch_write_client_test.go +++ b/services/bridge/internal/bridge/archtest/arch_write_client_test.go @@ -44,6 +44,10 @@ func (m *mockArchRPC) GetProcessedTransaction(_ context.Context, _ string) (*arc return m.processedTx, nil } +func (m *mockArchRPC) ReadAccountInfo(_ context.Context, _ arch.Pubkey) (*arch.AccountInfo, error) { + return nil, nil +} + func sampleIntent(_ *testing.T) types.OracleIntent { return types.OracleIntent{ IntentType: "PriceUpdate", diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index 6fa1e12..14f7432 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -363,6 +363,14 @@ func (b *Bridge) Start(ctx context.Context) error { // Start worker pool b.workerPool.Start(ctx) + // Start background fee-vault + payer-balance pollers for each Arch destination. + // routerID is empty at gauge level; per-update counters carry RouterID label. + for _, dest := range b.writeClients { + if archClient, ok := dest.(*ArchWriteClient); ok { + StartArchPoller(ctx, "", archClient, 30*time.Second) + } + } + // Start block scanner if enabled if b.blockScanner != nil { if err := b.blockScanner.Start(ctx); err != nil { From afbf3b91214bfa7d5badea1f029c8cd3717bcfe2 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:28:39 +0200 Subject: [PATCH 18/21] docs(bridge): operator-facing Arch destination config examples --- services/bridge/config/examples/README.md | 19 +++++++++++++++++++ .../config/examples/arch-testnet.chains.yaml | 15 +++++++++++++++ .../examples/arch-testnet.contracts.yaml | 9 +++++++++ .../config/examples/arch-testnet.router.yaml | 16 ++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 services/bridge/config/examples/README.md create mode 100644 services/bridge/config/examples/arch-testnet.chains.yaml create mode 100644 services/bridge/config/examples/arch-testnet.contracts.yaml create mode 100644 services/bridge/config/examples/arch-testnet.router.yaml diff --git a/services/bridge/config/examples/README.md b/services/bridge/config/examples/README.md new file mode 100644 index 0000000..aa2e5e0 --- /dev/null +++ b/services/bridge/config/examples/README.md @@ -0,0 +1,19 @@ +# Arch destination config examples + +To enable Arch as a destination in your bridge deployment: + +1. Use `arch-oracle-cli` (sub-project 2) to deploy the receiver + fee-hook + programs, initialize them, and authorize at least one signer. +2. Copy the two `address` / `fee_hook_program_id` values from + `arch-oracle profile show ` into `arch-testnet.contracts.yaml`. +3. Merge `arch-testnet.chains.yaml` into your `chains.yaml`. +4. Merge `arch-testnet.contracts.yaml` into your `contracts.yaml`. +5. Drop `arch-testnet.router.yaml` into your `routers/` directory. +6. Generate a relayer keypair (`arch-oracle wallet create --name arch-relayer`) + and set `ARCH_RELAYER_PRIVATE_KEY` to its `secretKeyHex` field. On localnet + /devnet fund it via `arch-oracle wallet faucet`; on testnet/mainnet fund + out-of-band. +7. Restart the bridge. + +For mainnet, mirror the same steps with `chain_id: -2`, the mainnet RPC URL, +the mainnet receiver/fee-hook program IDs, and `enabled: true`. diff --git a/services/bridge/config/examples/arch-testnet.chains.yaml b/services/bridge/config/examples/arch-testnet.chains.yaml new file mode 100644 index 0000000..f596d88 --- /dev/null +++ b/services/bridge/config/examples/arch-testnet.chains.yaml @@ -0,0 +1,15 @@ +arch-testnet: + chain_id: -1 + name: "Arch Network (Testnet)" + kind: "arch" + rpc_urls: + - "https://explorer.arch.network/api/v1/testnet/rpc" + enabled: true + +arch-mainnet: + chain_id: -2 + name: "Arch Network (Mainnet)" + kind: "arch" + rpc_urls: + - "https://explorer.arch.network/api/v1/mainnet/rpc" + enabled: false diff --git a/services/bridge/config/examples/arch-testnet.contracts.yaml b/services/bridge/config/examples/arch-testnet.contracts.yaml new file mode 100644 index 0000000..7e40071 --- /dev/null +++ b/services/bridge/config/examples/arch-testnet.contracts.yaml @@ -0,0 +1,9 @@ +arch-testnet-receiver: + chain_id: -1 + type: "arch-oracle-receiver" + # Replace with the program ID printed by: + # arch-oracle deploy ../arch-oracle-program/target/deploy/dia_arch_oracle_receiver.so --profile testnet + address: "0000000000000000000000000000000000000000000000000000000000000000" + # Replace with the fee-hook program ID printed by: + # arch-oracle deploy ../arch-oracle-program/target/deploy/dia_arch_fee_hook.so --profile testnet + fee_hook_program_id: "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/services/bridge/config/examples/arch-testnet.router.yaml b/services/bridge/config/examples/arch-testnet.router.yaml new file mode 100644 index 0000000..1187cef --- /dev/null +++ b/services/bridge/config/examples/arch-testnet.router.yaml @@ -0,0 +1,16 @@ +id: "dia-arch-testnet" +type: "intent-relayer" +enabled: true +# The env var holding the 64-char hex secp256k1 secret key of the relayer. +# Generate via: +# arch-oracle wallet create --name arch-relayer +# and read out the secretKeyHex field from the resulting JSON. +private_key_env: "ARCH_RELAYER_PRIVATE_KEY" +triggers: + events: ["IntentRegistered"] +destinations: + - chain_id: -1 + contract: "arch-testnet-receiver" + method: + kind: "arch-handle-intent-update" + gas_limit: 200000 # repurposed as an Arch compute-budget hint (optional) From c6f49843dd8b3bcad638a4dc48a8163a653a63e5 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:32:06 +0200 Subject: [PATCH 19/21] test(integration): live Arch destination end-to-end test --- services/bridge/test/integration/README.md | 49 ++++++ services/bridge/test/integration/arch_test.go | 154 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 services/bridge/test/integration/README.md create mode 100644 services/bridge/test/integration/arch_test.go diff --git a/services/bridge/test/integration/README.md b/services/bridge/test/integration/README.md new file mode 100644 index 0000000..fd4f892 --- /dev/null +++ b/services/bridge/test/integration/README.md @@ -0,0 +1,49 @@ +# Arch destination — integration test + +End-to-end test against a running Arch validator. Gated by environment +variables — `go test` skips cleanly when any are missing. + +## Prerequisites + +1. Run a local Arch validator following the + `arch-oracle-program/README.md` "Local validator quickstart" section. +2. Build the receiver + fee-hook SBF artifacts (`cargo build-sbf` in + `arch-oracle-program/`). +3. Use `arch-oracle-cli` to: create a wallet, faucet it, deploy both + programs, init fee-hook, init receiver with at least the seed-0x11 + signer authorized: + + ```bash + cd arch-oracle-cli + npm install && npm run build + arch-oracle wallet create --name bridge-relayer + arch-oracle profile create local --rpc http://127.0.0.1:9002 --payer bridge-relayer --network localnet + arch-oracle wallet faucet bridge-relayer --profile local --count 20 + arch-oracle deploy ../arch-oracle-program/target/deploy/dia_arch_fee_hook.so --profile local + arch-oracle init fee-hook --fee 1000 --profile local + arch-oracle deploy ../arch-oracle-program/target/deploy/dia_arch_oracle_receiver.so --profile local + DS=$(arch-oracle domain-separator --name "DIA Oracle Intent Registry" --version 1 --chain-id 1 \ + --verifying-contract 0x0102030405060708090a0b0c0d0e0f1011121314) + arch-oracle init receiver --domain-sep "$DS" --profile local + arch-oracle configure set-signer 0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a --authorized true --profile local + ``` + +4. Export the env vars the test reads: + + ```bash + export ARCH_RPC_URL=http://127.0.0.1:9002 + export ARCH_RELAYER_PRIVATE_KEY=$(jq -r .secretKeyHex ~/.config/arch-oracle-cli/keypairs/bridge-relayer.json) + export ARCH_RECEIVER_PROGRAM_ID=$(arch-oracle profile show local --json | jq -r .receiverProgramId) + export ARCH_FEE_HOOK_PROGRAM_ID=$(arch-oracle profile show local --json | jq -r .feeHookProgramId) + ``` + +5. Run the test: + + ```bash + cd services/bridge + go test ./test/integration/ -run TestArchBridge_EndToEnd -v + ``` + + The test sends the `intent_a.json` fixture, asserts the Price PDA gets + populated, replays the same intent and asserts the replay is rejected + with `AlreadyProcessed`. diff --git a/services/bridge/test/integration/arch_test.go b/services/bridge/test/integration/arch_test.go new file mode 100644 index 0000000..2e97323 --- /dev/null +++ b/services/bridge/test/integration/arch_test.go @@ -0,0 +1,154 @@ +package integration + +import ( + "context" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/diadata.org/Spectra-interoperability/services/bridge/config" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/bridge" + "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/types" + "github.com/ethereum/go-ethereum/common" +) + +func envOrSkip(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Skipf("%s not set", name) + } + return v +} + +type fixtureIntent struct { + IntentHex string `json:"intent_hex"` + SignerHex string `json:"signer_hex"` +} + +func loadIntentFixture(t *testing.T, path string) types.OracleIntent { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var f fixtureIntent + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + rawBytes, err := hex.DecodeString(f.IntentHex) + if err != nil { + t.Fatalf("decode intent hex: %v", err) + } + archIntent, err := arch.UnmarshalOracleIntent(rawBytes) + if err != nil { + t.Fatalf("UnmarshalOracleIntent: %v", err) + } + // Convert arch.OracleIntent to bridge types.OracleIntent for the in-process pipeline. + bridgeIntent := types.OracleIntent{ + IntentType: archIntent.IntentType, + Version: archIntent.Version, + ChainID: arch.BigIntFromU256(archIntent.ChainID), + Nonce: arch.BigIntFromU256(archIntent.Nonce), + Expiry: arch.BigIntFromU256(archIntent.Expiry), + Symbol: archIntent.Symbol, + Price: arch.BigIntFromU256(archIntent.Price), + Timestamp: arch.BigIntFromU256(archIntent.Timestamp), + Source: archIntent.Source, + Signature: types.HexBytes(archIntent.Signature), + Signer: common.Address(archIntent.Signer), + } + return bridgeIntent +} + +func TestArchBridge_EndToEnd(t *testing.T) { + rpcURL := envOrSkip(t, "ARCH_RPC_URL") + secretHex := envOrSkip(t, "ARCH_RELAYER_PRIVATE_KEY") + receiverHex := envOrSkip(t, "ARCH_RECEIVER_PROGRAM_ID") + feeHookHex := envOrSkip(t, "ARCH_FEE_HOOK_PROGRAM_ID") + + signer, err := arch.NewSignerFromHex(secretHex) + if err != nil { + t.Fatalf("NewSignerFromHex: %v", err) + } + receiverPK := mustDecodePubkey(t, receiverHex) + feeHookPK := mustDecodePubkey(t, feeHookHex) + rpc := arch.NewRPC(rpcURL) + + client := bridge.NewArchWriteClient(-1, receiverPK, feeHookPK, rpc, signer, 30*time.Second) + + // From services/bridge/test/integration/, the relative path to testdata is: + // ../../internal/arch/testdata/intent_a.json + intent := loadIntentFixture(t, filepath.Join("..", "..", "internal", "arch", "testdata", "intent_a.json")) + + req := &types.UpdateRequest{ + RouterID: "integration-test", + ExtractedData: &config.ExtractedData{ + Enrichment: map[string]interface{}{"fullIntent": &intent}, + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + res, err := client.Send(ctx, req) + if err != nil { + t.Fatalf("Send: %v", err) + } + if res.Status != "Processed" { + t.Fatalf("Status = %q (logs=%v)", res.Status, res.Logs) + } + // First send should have stored the value: no rejections expected for a + // fresh symbol. + if len(res.Rejections) > 0 { + t.Logf("rejections (acceptable if symbol already populated): %+v", res.Rejections) + } + + // Read the Price PDA and assert the stored value matches. + pricePDA, _ := arch.PricePDA(receiverPK, intent.Symbol) + info, err := rpc.ReadAccountInfo(ctx, pricePDA) + if err != nil { + t.Fatalf("ReadAccountInfo(price PDA): %v", err) + } + if info == nil { + t.Fatalf("price PDA missing on chain") + } + // PriceAccount Borsh: version(1) + symbol(len-prefixed) + timestamp(u128) + value(u128) + lastIntentHash([32]) + reserved([32]) + // We trust the receiver to write correctly and just sanity-check the + // length here. Full PriceAccount decoder lives in arch package if needed. + if len(info.Data) < 1+4+len(intent.Symbol)+16+16+32+32 { + t.Fatalf("price PDA data too short: %d bytes", len(info.Data)) + } + + // Variant: replay the same intent. Expect partially_delivered (rejected + // with AlreadyProcessed) on the receiver side. + res2, err := client.Send(ctx, req) + if err != nil { + t.Fatalf("Send replay: %v", err) + } + if len(res2.Rejections) == 0 { + t.Fatalf("replay: expected at least one rejection, got 0 (logs=%v)", res2.Logs) + } + if res2.Rejections[0].Reason != "AlreadyProcessed" { + t.Errorf("replay rejection reason = %q, want AlreadyProcessed", res2.Rejections[0].Reason) + } + + t.Logf("integration test passed: %s", intent.Symbol) +} + +func mustDecodePubkey(t *testing.T, h string) arch.Pubkey { + t.Helper() + if len(h) != 64 { + t.Fatalf("pubkey hex must be 64 chars, got %d", len(h)) + } + raw, err := hex.DecodeString(h) + if err != nil { + t.Fatalf("decode pubkey: %v", err) + } + var out arch.Pubkey + copy(out[:], raw) + return out +} From f3963479d2c7617f51fd6ceffc0c8ceecef0b5a7 Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:34:48 +0200 Subject: [PATCH 20/21] docs(bridge): operator runbook + README section for Arch destinations --- services/bridge/README.md | 26 ++++++++++ services/bridge/docs/ARCH_DESTINATION.md | 63 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 services/bridge/README.md create mode 100644 services/bridge/docs/ARCH_DESTINATION.md diff --git a/services/bridge/README.md b/services/bridge/README.md new file mode 100644 index 0000000..da6d7c5 --- /dev/null +++ b/services/bridge/README.md @@ -0,0 +1,26 @@ +# Bridge service + +The bridge service relays signed oracle intents from Lasernet to destination +chains. It is configured via YAML files in `config/` and exposes Prometheus +metrics for operational observability. + +## Supported destinations + +### EVM destinations + +The bridge supports arbitrary EVM-compatible destinations. Each destination is +identified by its chain ID in `chains.yaml` and wired to a receiver contract in +`contracts.yaml`. + +### Arch Network destinations + +The bridge supports `kind: "arch"` destinations alongside the existing EVM +backends. An Arch destination relays a signed `OracleIntent` to the receiver +program deployed via the `arch-oracle-cli` (sub-project 2). Two reserved +chain-ID sentinels: + +- `-1` — Arch testnet +- `-2` — Arch mainnet + +See `docs/ARCH_DESTINATION.md` for the operator runbook and +`config/examples/` for a paste-ready YAML stub. diff --git a/services/bridge/docs/ARCH_DESTINATION.md b/services/bridge/docs/ARCH_DESTINATION.md new file mode 100644 index 0000000..06a63e7 --- /dev/null +++ b/services/bridge/docs/ARCH_DESTINATION.md @@ -0,0 +1,63 @@ +# Arch destination runbook + +Sub-project 3 of the DIA × Arch Network integration. + +## Prerequisites + +- A deployed receiver + fee-hook program on the target Arch network. Use + `arch-oracle-cli` (sibling repo): `arch-oracle deploy`, `arch-oracle init`, + `arch-oracle configure set-signer`. +- A relayer keypair generated with `arch-oracle wallet create`. The 64-char + `secretKeyHex` becomes the value of the env var named in `private_key_env`. +- A funded relayer account. Localnet/devnet: `arch-oracle wallet faucet`. + Testnet/mainnet: fund out-of-band, at least 0.01 BTC equivalent in lamports. + +## Config + +Three YAML changes: + +1. `chains.yaml`: add an entry with `kind: "arch"` and one of the reserved + chain IDs (-1 testnet, -2 mainnet). +2. `contracts.yaml`: add a contract entry with `address` set to the receiver + program ID and `fee_hook_program_id` set to the fee-hook program ID. +3. `routers/.yaml`: add a router with `destinations[].method.kind: + "arch-handle-intent-update"`. + +Paste-ready stubs live under `config/examples/`. + +## Behavior + +The bridge sends one `HandleIntentUpdate` transaction per intent +(V1 — no batching). It signs with BIP-340 Schnorr (Taproot key-path), +waits for processed status (timeout 30s), then parses the receiver's +`DIA_ORACLE.*` log lines. + +- Successful update → rejection-free `TxResult`; logged with outcome + `delivered`. No DB writes from the Arch path beyond the upstream + processed_events row. +- Per-intent rejection (UnauthorizedSigner, AlreadyProcessed, + InvalidSignature) → one row per rejection in `dia_arch_rejections`; + logged with outcome `partially_delivered`. (A `processed_events.status` + column is planned but not yet schema-migrated.) +- Tx-level failure (BatchTooLarge, InvalidAccountList, RPC error) → + logged with outcome `failed`. No persistence on the Arch path; the + existing EVM-style metrics path handles transaction-failure surfacing. + +## Metrics + +| Metric | Type | Labels | +|---|---|---| +| `dia_arch_intent_updates_total` | counter | router, chain_id, symbol | +| `dia_arch_intent_stale_total` | counter | router, chain_id, symbol | +| `dia_arch_intent_rejected_total` | counter | router, chain_id, reason | +| `dia_arch_tx_confirmation_seconds` | histogram | router, chain_id, outcome | +| `dia_arch_fee_vault_lamports` | gauge | router, chain_id | +| `dia_arch_payer_balance_lamports` | gauge | router, chain_id | + +The two gauges are polled every 30s by a per-destination goroutine. Use the +payer balance gauge for relayer-out-of-funds alerting. + +## Testing + +- Unit tests (no live infra): `go test ./internal/arch/ ./internal/bridge/`. +- Live end-to-end test: see `test/integration/README.md`. From 8f49da84fa51225c1e5ebc5751a82749fa14c62f Mon Sep 17 00:00:00 2001 From: Evgeny Nasretdinov Date: Mon, 29 Jun 2026 20:46:00 +0200 Subject: [PATCH 21/21] fix(bridge): final-review must-fix items (per-router key, tx-confirmation histogram, poller symmetry, single-contract guard) --- .../bridge/internal/bridge/arch_poller.go | 6 ++- .../internal/bridge/arch_write_client.go | 16 +++++- services/bridge/internal/bridge/bridge.go | 49 ++++++++++++++++++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/services/bridge/internal/bridge/arch_poller.go b/services/bridge/internal/bridge/arch_poller.go index 8d1b391..fa4b9f7 100644 --- a/services/bridge/internal/bridge/arch_poller.go +++ b/services/bridge/internal/bridge/arch_poller.go @@ -2,10 +2,10 @@ package bridge import ( "context" - "log" "strconv" "time" + "github.com/diadata.org/Spectra-interoperability/pkg/logger" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/arch" "github.com/diadata.org/Spectra-interoperability/services/bridge/internal/metrics" ) @@ -42,12 +42,14 @@ func pollOnce(ctx context.Context, routerID string, c *ArchWriteClient) { if info, err := rpc.ReadAccountInfo(ctx, vaultPDA); err == nil && info != nil { metrics.ArchFeeVaultLamports.WithLabelValues(routerID, chainStr).Set(float64(info.Lamports)) } else if err != nil { - log.Printf("arch poller: read fee vault: %v", err) + logger.Warnf("arch poller: read fee vault: %v", err) } payer := c.signer.Pubkey() if info, err := rpc.ReadAccountInfo(ctx, payer); err == nil && info != nil { // routerID is empty at gauge level; per-update counters carry RouterID label. metrics.ArchPayerBalanceLamports.WithLabelValues(routerID, chainStr).Set(float64(info.Lamports)) + } else if err != nil { + logger.Warnf("arch poller: read payer balance: %v", err) } } diff --git a/services/bridge/internal/bridge/arch_write_client.go b/services/bridge/internal/bridge/arch_write_client.go index 7f298b1..f5c96eb 100644 --- a/services/bridge/internal/bridge/arch_write_client.go +++ b/services/bridge/internal/bridge/arch_write_client.go @@ -157,8 +157,13 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T if err != nil { return TxResult{}, fmt.Errorf("arch send: build/sign: %w", err) } + chainIDStr := strconv.FormatInt(c.chainID, 10) + routerID := req.RouterID + + start := time.Now() txID, err := c.rpc.SendTransaction(ctx, signed) if err != nil { + metrics.ArchTxConfirmationSeconds.WithLabelValues(routerID, chainIDStr, "failed").Observe(time.Since(start).Seconds()) return TxResult{}, fmt.Errorf("arch send: rpc send: %w", err) } @@ -166,6 +171,7 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T for { processed, err := c.rpc.GetProcessedTransaction(ctx, txID) if err != nil { + metrics.ArchTxConfirmationSeconds.WithLabelValues(routerID, chainIDStr, "failed").Observe(time.Since(start).Seconds()) return TxResult{}, fmt.Errorf("arch send: confirm %s: %w", txID, err) } if processed != nil { @@ -182,8 +188,6 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T } } // Emit metrics from parsed events. - chainIDStr := strconv.FormatInt(c.chainID, 10) - routerID := req.RouterID for _, e := range events { switch e.Kind { case "update": @@ -194,6 +198,13 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T metrics.ArchIntentRejected.WithLabelValues(routerID, chainIDStr, e.Reason).Inc() } } + // Observe confirmation latency. Use "processed" for the normal success + // path; any non-Processed status (e.g. "Failed") is "failed". + outcome := "processed" + if processed.Status != "Processed" { + outcome = "failed" + } + metrics.ArchTxConfirmationSeconds.WithLabelValues(routerID, chainIDStr, outcome).Observe(time.Since(start).Seconds()) return TxResult{ TxID: txID, Status: processed.Status, @@ -202,6 +213,7 @@ func (c *ArchWriteClient) Send(ctx context.Context, req *types.UpdateRequest) (T }, nil } if time.Now().After(deadline) { + metrics.ArchTxConfirmationSeconds.WithLabelValues(routerID, chainIDStr, "timeout").Observe(time.Since(start).Seconds()) return TxResult{}, fmt.Errorf("arch send: confirm %s: timeout after %s", txID, c.confirmTimeout) } select { diff --git a/services/bridge/internal/bridge/bridge.go b/services/bridge/internal/bridge/bridge.go index 14f7432..89629f6 100644 --- a/services/bridge/internal/bridge/bridge.go +++ b/services/bridge/internal/bridge/bridge.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "math/big" + "os" "sync" "time" @@ -132,10 +133,21 @@ func NewBridge(modularCfg *config.ModularConfig, cfgService *config.ConfigServic var destClient Destination if chainConfig.Kind == "arch" { - // Arch Network: use the first enabled contract as the receiver program. + // Arch Network: exactly one contract must be configured per chain. + if len(contracts) > 1 { + return nil, fmt.Errorf("chain %d (arch): expected exactly 1 contract, got %d", chainConfig.ChainID, len(contracts)) + } archContract := contracts[0] + + // Resolve signer key with per-router precedence: + // 1. router-level private_key literal + // 2. router-level private_key_env (env var name) + // 3. fall back to global infrastructure private_key + signerSecretHex, signerSource := resolveArchSignerKey(chainConfig.ChainID, routerRegistry, cfgService.GetInfrastructure().PrivateKey) + logger.Infof("Arch chain %d: signer key source = %s", chainConfig.ChainID, signerSource) + var buildErr error - destClient, buildErr = buildDestination(*chainConfig, *archContract, cfgService.GetInfrastructure().PrivateKey) + destClient, buildErr = buildDestination(*chainConfig, *archContract, signerSecretHex) if buildErr != nil { logger.Errorf("Failed to create Arch destination client for chain %d: %v", chainConfig.ChainID, buildErr) continue @@ -289,6 +301,39 @@ func logMonitorConfig(config leader.MonitorConfig, status string) { status, config.Enabled, config.TimeThresholdOffset, priceDevPercent, config.CheckInterval) } +// resolveArchSignerKey returns the signer secret hex and a description of the +// source used, following this precedence per Arch destination: +// 1. router-level private_key literal +// 2. router-level private_key_env (environment variable name) +// 3. global infrastructure private_key (fallback) +// +// The first active router whose destination targets archChainID wins. +func resolveArchSignerKey(archChainID int64, reg *router.GenericRegistry, globalKey string) (signerHex, source string) { + if reg != nil { + for _, r := range reg.GetActiveRouters() { + for _, dest := range r.GetConfigDestinations() { + if dest.ChainID != archChainID { + continue + } + // Matched a router targeting this Arch chain — inspect its key config. + rc := r.GetConfig() + if rc == nil { + continue + } + if rc.PrivateKey != "" { + return rc.PrivateKey, "router-literal" + } + if rc.PrivateKeyEnv != "" { + if val := os.Getenv(rc.PrivateKeyEnv); val != "" { + return val, fmt.Sprintf("router-env $%s", rc.PrivateKeyEnv) + } + } + } + } + } + return globalKey, "infrastructure-default" +} + // countDestinationsForChain counts all destinations (oracles) configured for a specific chain func countDestinationsForChain(routerRegistry *router.GenericRegistry, chainID int64) int { if routerRegistry == nil {