Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ require (
github.com/go-chi/chi/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.4 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
Expand All @@ -16,12 +23,14 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/redis/go-redis/v9 v9.20.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
Expand All @@ -26,6 +34,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand All @@ -38,10 +48,18 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
Expand Down
111 changes: 111 additions & 0 deletions internal/chain/chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Package chain describes the networks an intent can settle on.
//
// Intents used to carry no chain at all. The dApp now routes per chain and
// connects wallets on both Arc and Stellar, so without a discriminator an
// intent submitted from the Stellar side is indistinguishable from an Arc one
// once it reaches this service — and would settle against the wrong contracts.
package chain

import (
"fmt"
"regexp"
"strings"
)

// Chain is the network an intent settles on.
type Chain string

const (
Arc Chain = "arc"
Stellar Chain = "stellar"
)

// Family is the execution environment. Code that must branch between the two
// worlds branches on this rather than on the chain name, so adding another EVM
// chain does not mean revisiting every switch.
type Family string

const (
FamilyEVM Family = "evm"
FamilyStellar Family = "stellar"
)

var families = map[Chain]Family{
Arc: FamilyEVM,
Stellar: FamilyStellar,
}

// Supported lists every chain an intent may name, in display order.
var Supported = []Chain{Arc, Stellar}

// Default is used when a request omits the chain, so existing clients that
// predate multi-chain keep working rather than failing validation.
const Default = Arc

func (c Chain) Valid() bool {
_, ok := families[c]
return ok
}

func (c Chain) Family() Family {
return families[c]
}

func (c Chain) String() string { return string(c) }

// Parse normalises and validates a chain name from a request.
func Parse(s string) (Chain, error) {
trimmed := strings.ToLower(strings.TrimSpace(s))
if trimmed == "" {
return Default, nil
}
c := Chain(trimmed)
if !c.Valid() {
return "", fmt.Errorf("unsupported chain %q (want one of: %s)", s, JoinSupported())
}
return c, nil
}

func JoinSupported() string {
names := make([]string, 0, len(Supported))
for _, c := range Supported {
names = append(names, string(c))
}
return strings.Join(names, ", ")
}

// EVM addresses are 20 hex bytes; Stellar public keys are 56 base32 characters
// beginning with G. The two are unmistakable, which is what makes it worth
// rejecting a mismatch at the API boundary rather than at settlement.
var (
evmAddress = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
stellarAddress = regexp.MustCompile(`^G[A-Z2-7]{55}$`)
)

// ValidateAddress reports whether an address belongs to the given chain.
//
// A single address column that accepts both formats without checking them
// against the chain will happily store a mismatched pair, and the failure then
// surfaces at settlement — the most expensive place to find it.
func ValidateAddress(c Chain, address string) error {
addr := strings.TrimSpace(address)
if addr == "" {
return fmt.Errorf("address is required")
}

switch c.Family() {
case FamilyEVM:
if !evmAddress.MatchString(addr) {
return fmt.Errorf("%q is not a valid %s address (want 0x followed by 40 hex characters)", addr, c)
}
case FamilyStellar:
// Stellar encodes in uppercase base32; a lowercase key is malformed
// rather than merely differently spelled.
if !stellarAddress.MatchString(addr) {
return fmt.Errorf("%q is not a valid %s address (want G followed by 55 base32 characters)", addr, c)
}
default:
return fmt.Errorf("unsupported chain %q", c)
}
return nil
}
76 changes: 76 additions & 0 deletions internal/chain/chain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package chain

import "testing"

func TestParseDefaultsToArcWhenOmitted(t *testing.T) {
// Clients written before multi-chain send no chain at all; they must keep
// working rather than start failing validation.
got, err := Parse("")
if err != nil || got != Arc {
t.Fatalf("Parse(\"\") = %q, %v; want arc, nil", got, err)
}
}

func TestParseNormalises(t *testing.T) {
for _, in := range []string{"STELLAR", " stellar ", "Stellar"} {
got, err := Parse(in)
if err != nil || got != Stellar {
t.Errorf("Parse(%q) = %q, %v; want stellar, nil", in, got, err)
}
}
}

func TestParseRejectsUnknownChain(t *testing.T) {
if _, err := Parse("ethereum"); err == nil {
t.Fatal("expected an unsupported chain to be rejected")
}
}

func TestFamilySeparatesEVMFromStellar(t *testing.T) {
if Arc.Family() != FamilyEVM {
t.Errorf("arc should be an EVM chain")
}
if Stellar.Family() != FamilyStellar {
t.Errorf("stellar should not be an EVM chain")
}
}

func TestValidateAddressAcceptsCorrectFormats(t *testing.T) {
if err := ValidateAddress(Arc, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"); err != nil {
t.Errorf("expected a valid EVM address to pass: %v", err)
}
// Circle's testnet USDC issuer — a real, well-formed Stellar key.
if err := ValidateAddress(Stellar, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); err != nil {
t.Errorf("expected a valid Stellar address to pass: %v", err)
}
}

// The mismatch this whole change exists to catch.
func TestValidateAddressRejectsWrongChainFormat(t *testing.T) {
if err := ValidateAddress(Stellar, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"); err == nil {
t.Error("expected an EVM address on a Stellar intent to be rejected")
}
if err := ValidateAddress(Arc, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); err == nil {
t.Error("expected a Stellar address on an Arc intent to be rejected")
}
}

func TestValidateAddressRejectsMalformed(t *testing.T) {
cases := []struct {
chain Chain
address string
why string
}{
{Arc, "", "empty"},
{Arc, "0x123", "too short"},
{Arc, "742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", "missing 0x"},
{Arc, "0xZZZZ35Cc6634C0532925a3b844Bc9e7595f0bEb0", "non-hex"},
{Stellar, "G123", "too short"},
{Stellar, "gbbd47if6lwk7p7mdevscwr7dpuwv3ny3dtqevfl4nat4aqh3zllfla5", "lowercase"},
}
for _, c := range cases {
if err := ValidateAddress(c.chain, c.address); err == nil {
t.Errorf("expected %s address to be rejected (%s)", c.chain, c.why)
}
}
}
Loading
Loading