diff --git a/localenv/admin-auth/docker-compose.yml b/localenv/admin-auth/docker-compose.yml index 192a812a41..8f8dd6a37b 100644 --- a/localenv/admin-auth/docker-compose.yml +++ b/localenv/admin-auth/docker-compose.yml @@ -27,8 +27,10 @@ services: args: PATH_TO_KRATOS_CONFIG: ./localenv/admin-auth/cloud-nine-kratos.yml depends_on: - - shared-database - - mailslurper + shared-database: + condition: service_healthy + mailslurper: + condition: service_started environment: DEV_MODE: true ports: @@ -43,8 +45,10 @@ services: args: PATH_TO_KRATOS_CONFIG: ./localenv/admin-auth/happy-life-kratos.yml depends_on: - - shared-database - - mailslurper + shared-database: + condition: service_healthy + mailslurper: + condition: service_started environment: DEV_MODE: true ports: diff --git a/localenv/cloud-nine-wallet/dbinit.sql b/localenv/cloud-nine-wallet/dbinit.sql index 0fe8c3e88b..4ee97df8e6 100644 --- a/localenv/cloud-nine-wallet/dbinit.sql +++ b/localenv/cloud-nine-wallet/dbinit.sql @@ -13,3 +13,11 @@ ALTER DATABASE happy_life_bank_backend OWNER TO happy_life_bank_backend; CREATE USER happy_life_bank_auth WITH PASSWORD 'happy_life_bank_auth'; CREATE DATABASE happy_life_bank_auth; ALTER DATABASE happy_life_bank_auth OWNER TO happy_life_bank_auth; + +CREATE USER global_bank_backend WITH PASSWORD 'global_bank_backend'; +CREATE DATABASE global_bank_backend; +ALTER DATABASE global_bank_backend OWNER TO global_bank_backend; + +CREATE USER global_bank_auth WITH PASSWORD 'global_bank_auth'; +CREATE DATABASE global_bank_auth; +ALTER DATABASE global_bank_auth OWNER TO global_bank_auth; diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 2771d4b79d..338fe3cd92 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -33,6 +33,8 @@ services: depends_on: cloud-nine-backend: condition: service_healthy + shared-database: + condition: service_healthy cloud-nine-backend: hostname: cloud-nine-wallet-backend image: rafiki-backend @@ -78,11 +80,14 @@ services: WALLET_ADDRESS_URL: ${CLOUD_NINE_WALLET_ADDRESS_URL:-https://cloud-nine-wallet-backend/.well-known/pay} ILP_CONNECTOR_URL: ${CLOUD_NINE_CONNECTOR_URL:-http://cloud-nine-wallet-backend:3002} ENABLE_TELEMETRY: true + ENABLE_ILP_TIMING: true KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0 OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 depends_on: - - shared-database - - shared-redis + shared-database: + condition: service_healthy + shared-redis: + condition: service_started healthcheck: test: ["CMD", "wget", "--spider", "http://localhost:3001/healthz"] start_period: 60s @@ -123,8 +128,10 @@ services: OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 SERVICE_API_PORT: 3011 depends_on: - - shared-database - - shared-redis + shared-database: + condition: service_healthy + shared-redis: + condition: service_started shared-database: image: 'postgres:15' # use latest official postgres version restart: unless-stopped @@ -138,6 +145,12 @@ services: environment: POSTGRES_PASSWORD: password POSTGRES_USER: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s shared-redis: image: 'redis:7' restart: unless-stopped diff --git a/localenv/cloud-nine-wallet/seed.multihop.yml b/localenv/cloud-nine-wallet/seed.multihop.yml new file mode 100644 index 0000000000..ef40de9193 --- /dev/null +++ b/localenv/cloud-nine-wallet/seed.multihop.yml @@ -0,0 +1,97 @@ +assets: + - code: USD + scale: 2 + liquidity: 10000000 + liquidityThreshold: 10000000 + - code: EUR + scale: 2 + liquidity: 10000000 + liquidityThreshold: 10000000 +peeringAsset: 'USD' +peers: + # In multihop mode, route via global-bank and make happy-life-bank unreachable + - initialLiquidity: '10000000' + peerUrl: http://global-bank-backend:3002 + peerIlpAddress: test.global-bank + liquidityThreshold: 1000000 + tokens: + incoming: + - global-to-cloud-nine + outgoing: cloud-nine-to-global + routes: + - "test.happy-life-bank" + - initialLiquidity: '10000000' + peerUrl: http://happy-life-bank-backend:3002 + peerIlpAddress: test.unreachable.happy-life-bank + liquidityThreshold: 1000000 + tokens: + incoming: + - happy-to-cloud-nine + outgoing: cloud-nine-to-happy + routes: + - "test.unreachable.happy-life-bank" +accounts: + - name: 'Grace Franklin' + path: accounts/gfranklin + id: 742ab7cd-1624-4d2e-af6e-e15a71638669 + initialBalance: 40000000 + brunoEnvVar: gfranklinWalletAddress + assetCode: USD + - name: 'Bert Hamchest' + id: a9adbe1a-df31-4766-87c9-d2cb2e636a9b + initialBalance: 40000000 + path: accounts/bhamchest + brunoEnvVar: bhamchestWalletAddress + assetCode: USD + - name: "World's Best Donut Co" + id: 5726eefe-8737-459d-a36b-0acce152cb90 + initialBalance: 20000000 + path: accounts/wbdc + brunoEnvVar: wbdcWalletAddress + assetCode: USD + - name: "Broke Account" + id: 5a95366f-8cb4-4925-88d9-ae57dcb444bb + initialBalance: 50 + path: accounts/broke + brunoEnvVar: brokeWalletAddress + assetCode: USD + - name: "Luca Rossi" + id: 63dcc665-d946-4263-ac27-d0da1eb08a83 + initialBalance: 50 + path: accounts/lrossi + brunoEnvVar: lrossiWalletAddressId + assetCode: EUR +rates: + EUR: + MXN: 18.78 + USD: 1.10 + JPY: 157.83 + USD: + MXN: 17.07 + EUR: 0.91 + JPY: 147.71 + MXN: + USD: 0.059 + EUR: 0.054 + JPY: 8.65 + JPY: + USD: 0.007 + EUR: 0.006 + MXN: 0.12 +fees: + - fixed: 100 + basisPoints: 200 + asset: USD + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: EUR + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: MXN + scale: 2 + - fixed: 1 + basisPoints: 200 + asset: JPY + scale: 0 diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index e3f7beadc6..29426c9182 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -1,30 +1,35 @@ assets: - code: USD scale: 2 - liquidity: 100000000 + liquidity: 10000000 liquidityThreshold: 10000000 - code: EUR scale: 2 - liquidity: 100000000 + liquidity: 10000000 liquidityThreshold: 10000000 - - code: MXN - scale: 2 - liquidity: 100000000 - liquidityThreshold: 10000000 - - code: JPY - scale: 0 - liquidity: 1000000 - liquidityThreshold: 100000 peeringAsset: 'USD' peers: + # Address and routes for this peer MUST remain unchanged when NOT in multihop mode so that happy-life packets aren't routed through global-bank + - initialLiquidity: '10000000' + peerUrl: http://global-bank-backend:3002 + peerIlpAddress: test.unreachable.global-bank + liquidityThreshold: 1000000 + tokens: + incoming: + - global-to-cloud-nine + outgoing: cloud-nine-to-global + routes: + - "test.unreachable.global-bank" - initialLiquidity: '10000000' peerUrl: http://happy-life-bank-backend:3002 peerIlpAddress: test.happy-life-bank liquidityThreshold: 1000000 tokens: incoming: - - test-USD-happy-life-bank-cloud-nine-wallet - outgoing: test-USD-cloud-nine-wallet-happy-life-bank + - happy-to-cloud-nine + outgoing: cloud-nine-to-happy + routes: + - "test.happy-life-bank" accounts: - name: 'Grace Franklin' path: accounts/gfranklin @@ -89,4 +94,4 @@ fees: - fixed: 1 basisPoints: 200 asset: JPY - scale: 0 + scale: 0 \ No newline at end of file diff --git a/localenv/cloud-ten-wallet/seed.yml b/localenv/cloud-ten-wallet/seed.yml index 7cf65b5502..d98136bbca 100644 --- a/localenv/cloud-ten-wallet/seed.yml +++ b/localenv/cloud-ten-wallet/seed.yml @@ -22,9 +22,11 @@ peers: peerIlpAddress: test.happy-life-bank liquidityThreshold: 1000000 tokens: - incoming: - - test-USD-happy-life-bank-cloud-ten-wallet - outgoing: test-USD-cloud-ten-wallet-happy-life-bank + incoming: + - happy-to-cloud-ten + outgoing: cloud-ten-to-happy + routes: + - "test.happy-life-bank" accounts: - name: 'Frace Granklin' path: accounts/fgranklin diff --git a/localenv/global-bank/docker-compose.yml b/localenv/global-bank/docker-compose.yml new file mode 100644 index 0000000000..b0288ba467 --- /dev/null +++ b/localenv/global-bank/docker-compose.yml @@ -0,0 +1,149 @@ +name: gb +services: + global-bank-mock-ase: + hostname: global-bank + image: rafiki-mock-ase + pull_policy: never + restart: always + networks: + - rafiki + ports: + - '3032:80' + environment: + LOG_LEVEL: debug + PORT: 80 + SEED_FILE_LOCATION: /workspace/seed.yml + KEY_FILE: /workspace/private-key.pem + OPEN_PAYMENTS_URL: ${GLOBAL_BANK_OPEN_PAYMENTS_URL:-https://global-bank-backend} + GRAPHQL_URL: http://global-bank-backend:3001/graphql + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= + DISPLAY_NAME: Global Bank + DISPLAY_ICON: bank-icon.svg + OPERATOR_TENANT_ID: 53f2d913-e98a-40b9-b270-372d0547f23e + FRONTEND_PORT: 5010 + volumes: + - ../global-bank/seed.yml:/workspace/seed.yml + - ../global-bank/private-key.pem:/workspace/private-key.pem + depends_on: + global-bank-backend: + condition: service_healthy + shared-database: + condition: service_healthy + global-bank-backend: + hostname: global-bank-backend + image: rafiki-backend + pull_policy: never + volumes: + - type: bind + source: ../../packages/backend/src + target: /home/rafiki/packages/backend/src + read_only: true + restart: always + privileged: true + ports: + - "5000:80" + - "5001:3001" + - "5002:3002" + - '9233:9229' + networks: + - rafiki + environment: + NODE_ENV: development + INSTANCE_NAME: GLOBAL-BANK + LOG_LEVEL: debug + ADMIN_PORT: 3001 + CONNECTOR_PORT: 3002 + OPEN_PAYMENTS_PORT: 80 + DATABASE_URL: postgresql://global_bank_backend:global_bank_backend@shared-database/global_bank_backend + USE_TIGERBEETLE: false + AUTH_SERVER_GRANT_URL: ${GLOBAL_BANK_AUTH_SERVER_DOMAIN:-http://global-bank-auth:3006} + AUTH_SERVER_INTROSPECTION_URL: http://global-bank-auth:3007 + AUTH_ADMIN_API_URL: 'http://global-bank-auth:5003/graphql' + AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' + AUTH_SERVICE_API_URL: 'http://global-bank-auth:5011' + ILP_ADDRESS: test.global-bank + ILP_CONNECTOR_URL: http://global-bank-backend:5002 + STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= + API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + WEBHOOK_URL: http://global-bank/webhooks + OPEN_PAYMENTS_URL: ${GLOBAL_BANK_OPEN_PAYMENTS_URL:-https://global-bank-backend} + EXCHANGE_RATES_URL: http://global-bank/rates + REDIS_URL: redis://shared-redis:6379/4 + WALLET_ADDRESS_URL: ${GLOBAL_BANK_WALLET_ADDRESS_URL:-https://global-bank-backend/.well-known/pay} + ENABLE_TELEMETRY: true + ENABLE_ILP_TIMING: true + KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23e + OPERATOR_TENANT_ID: 53f2d913-e98a-40b9-b270-372d0547f23e + depends_on: + shared-database: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3001/healthz"] + start_period: 60s + start_interval: 5s + interval: 30s + retries: 1 + timeout: 3s + global-bank-auth: + hostname: global-bank-auth + image: rafiki-auth + pull_policy: never + restart: always + volumes: + - type: bind + source: ../../packages/auth/src + target: /home/rafiki/packages/auth/src + read_only: true + networks: + - rafiki + ports: + - '5003:3003' + - '5006:3006' + - '9234:9229' + - '5009:3009' + - '5011:5011' + environment: + NODE_ENV: development + AUTH_DATABASE_URL: postgresql://global_bank_auth:global_bank_auth@shared-database/global_bank_auth + AUTH_SERVER_URL: ${GLOBAL_BANK_AUTH_SERVER_DOMAIN:-http://localhost:5006} + REDIS_URL: redis://shared-redis:6379/5 + IDENTITY_SERVER_URL: http://localhost:3032/mock-idp/ + IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= + COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 + ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: 53f2d913-e98a-40b9-b270-372d0547f23e + SERVICE_API_PORT: 5011 + depends_on: + shared-database: + condition: service_healthy + happy-life-auth: + condition: service_started + global-bank-admin: + hostname: global-bank-admin + image: rafiki-frontend + pull_policy: never + volumes: + - type: bind + source: ../../packages/frontend/app + target: /home/rafiki/packages/frontend/app + read_only: true + restart: always + networks: + - rafiki + ports: + - '5010:5010' + environment: + PORT: 5010 + LOG_LEVEL: debug + NODE_ENV: development + GRAPHQL_URL: http://global-bank-backend:3001/graphql + OPEN_PAYMENTS_URL: https://global-bank-backend/ + ENABLE_INSECURE_MESSAGE_COOKIE: true + AUTH_ENABLED: false + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + depends_on: + - happy-life-admin + - global-bank-backend \ No newline at end of file diff --git a/localenv/global-bank/private-key.pem b/localenv/global-bank/private-key.pem new file mode 100644 index 0000000000..213295ae3a --- /dev/null +++ b/localenv/global-bank/private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIEqezmcPhOE8bkwN+jQrppfRYzGIdFTVWQGTHJIKpz88 +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/localenv/global-bank/seed.yml b/localenv/global-bank/seed.yml new file mode 100644 index 0000000000..0ea4d5ba8c --- /dev/null +++ b/localenv/global-bank/seed.yml @@ -0,0 +1,58 @@ +assets: + - code: USD + scale: 2 + liquidity: 10000000000 + liquidityThreshold: 10000000 +peeringAsset: 'USD' +peers: + - initialLiquidity: '100000000000000' + peerUrl: http://cloud-nine-wallet-backend:3002 + peerIlpAddress: test.cloud-nine-wallet + liquidityThreshold: 1000000 + maxPacketAmount: 10000 + tokens: + incoming: + - cloud-nine-to-global + outgoing: global-to-cloud-nine + routes: + - "test.cloud-nine-wallet" + - initialLiquidity: '100000000000000' + peerUrl: http://happy-life-bank-backend:3002 + peerIlpAddress: test.happy-life-bank + liquidityThreshold: 1000000 + maxPacketAmount: 10000 + tokens: + incoming: + - happy-to-global + outgoing: global-to-happy + routes: + - "test.happy-life-bank" +accounts: + - name: 'John Doe' + path: accounts/jdoe + id: 77a3a431-8ee1-48fc-ac85-70e2f5eba8e6 + initialBalance: 1000000 + brunoEnvVar: jdoeWalletAddress + assetCode: USD + - name: 'Global Corp' + id: 4455cc54-b583-455b-836a-e5275c5c05b8 + initialBalance: 5000000 + path: accounts/gcorp + brunoEnvVar: gcorpWalletAddress + assetCode: USD + - name: 'Jane Smith' + path: accounts/jsmith + id: 447ac10b-58cc-4372-a567-0e02b2c3d480 + initialBalance: 2000000 + brunoEnvVar: jsmithWalletAddress + assetCode: USD +rates: + USD: + MXN: 17.07 + EUR: 0.91 + JPY: 147.71 +fees: + - fixed: 100 + basisPoints: 200 + asset: USD + scale: 2 \ No newline at end of file diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index ecd0bc9914..8edf554255 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -74,6 +74,7 @@ services: REDIS_URL: redis://shared-redis:6379/2 WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay} ENABLE_TELEMETRY: true + ENABLE_ILP_TIMING: true KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d depends_on: diff --git a/localenv/happy-life-bank/seed.multihop.yml b/localenv/happy-life-bank/seed.multihop.yml new file mode 100644 index 0000000000..92a94f8358 --- /dev/null +++ b/localenv/happy-life-bank/seed.multihop.yml @@ -0,0 +1,118 @@ +assets: + - code: USD + scale: 2 + liquidity: 2000000000000 + liquidityThreshold: 10000000 + - code: EUR + scale: 2 + liquidity: 100000000 + liquidityThreshold: 10000000 + - code: MXN + scale: 2 + liquidity: 100000000 + liquidityThreshold: 10000000 + - code: JPY + scale: 0 + liquidity: 1000000 + liquidityThreshold: 100000 +peeringAsset: 'USD' +peers: + # In multihop mode, route via global-bank and make cloud-nine and cloud-ten unreachable + - initialLiquidity: '100000000000000' + peerUrl: http://cloud-nine-wallet-backend:3002 + peerIlpAddress: test.unreachable.cloud-nine-wallet + liquidityThreshold: 1000000 + maxPacketAmount: 10000 + tokens: + incoming: + - cloud-nine-to-happy + outgoing: happy-to-cloud-nine + routes: + - "test.unreachable.cloud-nine-wallet" + - initialLiquidity: '100000000000000' + peerUrl: http://cloud-nine-wallet-backend:3002 + peerIlpAddress: test.unreachable.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8 + liquidityThreshold: 1000000 + maxPacketAmount: 10000 + tokens: + incoming: + - cloud-ten-to-happy + outgoing: happy-to-cloud-ten + routes: + - "test.unreachable.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8" + - initialLiquidity: '100000000000000' + peerUrl: http://global-bank-backend:3002 + peerIlpAddress: test.global-bank + liquidityThreshold: 1000000 + tokens: + incoming: + - global-to-happy + outgoing: happy-to-global + routes: + - "test.cloud-nine-wallet" +accounts: + - name: 'Philip Fry' + path: accounts/pfry + id: 97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 + initialBalance: 1 + brunoEnvVar: pfryWalletAddress + assetCode: USD + - name: 'PlanEx Corp' + id: a455cc54-b583-455b-836a-e5275c5c05b7 + initialBalance: 2000000 + path: accounts/planex + brunoEnvVar: planexWalletAddress + assetCode: USD + - name: 'Alice Smith' + path: accounts/asmith + id: f47ac10b-58cc-4372-a567-0e02b2c3d479 + initialBalance: 5000000 + brunoEnvVar: asmithWalletAddress + skipWalletAddressCreation: true + assetCode: USD + - name: 'Lars' + path: accounts/lars + id: fd4ecbc9-205d-4ecd-a030-507d6ce2bde6 + initialBalance: 50000000 + brunoEnvVar: larsWalletAddress + assetCode: EUR + - name: 'David' + path: accounts/david + id: 60257507-3191-4507-9d77-9071fd6b3c30 + initialBalance: 1500000000 + brunoEnvVar: davidWalletAddress + assetCode: MXN +rates: + EUR: + MXN: 18.78 + USD: 1.10 + JPY: 157.83 + USD: + MXN: 17.07 + EUR: 0.91 + JPY: 147.71 + MXN: + USD: 0.059 + EUR: 0.054 + JPY: 8.65 + JPY: + USD: 0.007 + EUR: 0.006 + MXN: 0.12 +fees: + - fixed: 100 + basisPoints: 200 + asset: USD + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: EUR + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: MXN + scale: 2 + - fixed: 1 + basisPoints: 200 + asset: JPY + scale: 0 diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index 3eb4824dc9..3d27543ba2 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -1,38 +1,55 @@ assets: - code: USD scale: 2 - liquidity: 10000000000 - liquidityThreshold: 100000000 + liquidity: 2000000000000 + liquidityThreshold: 10000000 - code: EUR scale: 2 - liquidity: 10000000000 - liquidityThreshold: 1000000 + liquidity: 100000000 + liquidityThreshold: 10000000 - code: MXN scale: 2 - liquidity: 10000000000 + liquidity: 100000000 liquidityThreshold: 10000000 - code: JPY scale: 0 - liquidity: 1000000000 - liquidityThreshold: 1000000 + liquidity: 1000000 + liquidityThreshold: 100000 peeringAsset: 'USD' peers: - - initialLiquidity: '1000000000000' + - initialLiquidity: '100000000000000' peerUrl: http://cloud-nine-wallet-backend:3002 peerIlpAddress: test.cloud-nine-wallet liquidityThreshold: 1000000 + maxPacketAmount: 10000 tokens: - incoming: - - test-USD-cloud-nine-wallet-happy-life-bank - outgoing: test-USD-happy-life-bank-cloud-nine-wallet - - initialLiquidity: '1000000000000' + incoming: + - cloud-nine-to-happy + outgoing: happy-to-cloud-nine + routes: + - "test.cloud-nine-wallet" + - initialLiquidity: '100000000000000' peerUrl: http://cloud-nine-wallet-backend:3002 peerIlpAddress: test.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8 liquidityThreshold: 1000000 + maxPacketAmount: 10000 + tokens: + incoming: + - cloud-ten-to-happy + outgoing: happy-to-cloud-ten + routes: + - "test.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8" + # Address and routes for this peer MUST remain unchanged when NOT in multihop mode so that cloud-nine/cloud-ten packets aren't routed through global-bank + - initialLiquidity: '100000000000000' + peerUrl: http://global-bank-backend:3002 + peerIlpAddress: test.unreachable.global-bank + liquidityThreshold: 1000000 tokens: - incoming: - - test-USD-cloud-ten-wallet-happy-life-bank - outgoing: test-USD-happy-life-bank-cloud-ten-wallet + incoming: + - global-to-happy + outgoing: happy-to-global + routes: + - "test.unreachable.global-bank" accounts: - name: 'Philip Fry' path: accounts/pfry diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index bfd2e00bb3..faf5124256 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -312,6 +312,8 @@ export type CreatePeerInput = { maxPacketAmount?: InputMaybe; /** Internal name of the peer. */ name?: InputMaybe; + /** Routes for the peer. */ + routes?: InputMaybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['input']; }; @@ -1166,6 +1168,8 @@ export type Peer = Model & { maxPacketAmount?: Maybe; /** Public name for the peer. */ name?: Maybe; + /** Routes for the peer. */ + routes?: Maybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; /** Unique identifier of the tenant associated with the peer. */ @@ -1604,6 +1608,8 @@ export type UpdatePeerInput = { maxPacketAmount?: InputMaybe; /** New public name for the peer. */ name?: InputMaybe; + /** New routes for the peer. */ + routes?: InputMaybe>; /** New ILP address for the peer. */ staticIlpAddress?: InputMaybe; }; @@ -2528,6 +2534,7 @@ export type PeerResolvers, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + routes?: Resolver>, ParentType, ContextType>; staticIlpAddress?: Resolver; tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/localenv/multihop/docker-compose.yml b/localenv/multihop/docker-compose.yml new file mode 100644 index 0000000000..b697a0a497 --- /dev/null +++ b/localenv/multihop/docker-compose.yml @@ -0,0 +1,8 @@ +name: multihop +services: + cloud-nine-mock-ase: + volumes: + - ../cloud-nine-wallet/seed.multihop.yml:/workspace/seed.yml + happy-life-mock-ase: + volumes: + - ../happy-life-bank/seed.multihop.yml:/workspace/seed.yml diff --git a/localenv/telemetry/docker-compose.yml b/localenv/telemetry/docker-compose.yml index 227f7b2d9e..bda3c2d5f5 100644 --- a/localenv/telemetry/docker-compose.yml +++ b/localenv/telemetry/docker-compose.yml @@ -6,6 +6,7 @@ services: LIVENET: false OPEN_TELEMETRY_COLLECTOR_URLS: http://otel-collector:4317 OPEN_TELEMETRY_TRACE_COLLECTOR_URLS: http://otel-collector:4317 + ENABLE_ILP_TIMING: true happy-life-backend: environment: @@ -14,6 +15,16 @@ services: LIVENET: false OPEN_TELEMETRY_COLLECTOR_URLS: http://otel-collector:4317 OPEN_TELEMETRY_TRACE_COLLECTOR_URLS: http://otel-collector:4317 + ENABLE_ILP_TIMING: true + + global-bank-backend: + environment: + ENABLE_TELEMETRY: true + ENABLE_TELEMETRY_TRACES: true + LIVENET: false + OPEN_TELEMETRY_COLLECTOR_URLS: http://otel-collector:4317 + OPEN_TELEMETRY_TRACE_COLLECTOR_URLS: http://otel-collector:4317 + ENABLE_ILP_TIMING: true otel-collector: image: otel/opentelemetry-collector:latest diff --git a/localenv/telemetry/grafana/provisioning/dashboards/ilp-metrics-dashboard.json b/localenv/telemetry/grafana/provisioning/dashboards/ilp-metrics-dashboard.json new file mode 100644 index 0000000000..ed2b7e2344 --- /dev/null +++ b/localenv/telemetry/grafana/provisioning/dashboards/ilp-metrics-dashboard.json @@ -0,0 +1,352 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.0+security-01", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum by (le, operation) (rate(ilp_prepare_packet_processing_ms_bucket[$__rate_interval])))", + "hide": false, + "instant": false, + "legendFormat": "{{operation}}", + "range": true, + "refId": "A" + } + ], + "title": "ILP Prepare Packet Processing Time by Operation (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.0+security-01", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(ilp_prepare_packet_processing_ms_bucket[$__rate_interval])))", + "hide": false, + "instant": false, + "legendFormat": "{{operation}}", + "range": true, + "refId": "A" + } + ], + "title": "ILP Prepare Packet Processing Time by Operation (p95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["mean", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.0+security-01", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${PrometheusDS}" + }, + "editorMode": "code", + "expr": "sum(rate(ilp_payment_round_trip_ms_sum[$__rate_interval])) by (error) / sum(rate(ilp_payment_round_trip_ms_count[$__rate_interval])) by (error)", + "hide": false, + "instant": false, + "legendFormat": "{{error}}", + "range": true, + "refId": "A" + } + ], + "title": "Average ILP Payment Round Trip Time", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "15s", + "schemaVersion": 41, + "tags": ["ilp", "performance", "metrics"], + "templating": { + "list": [ + { + "current": { + "text": "Prometheus", + "value": "PBFA97CFB590B2093" + }, + "description": "The Prometheus data-source.", + "includeAll": false, + "name": "PrometheusDS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "15s", + "30s", + "1m", + "2m", + "5m", + "10m", + "15m", + "30m", + "1h" + ] + }, + "timezone": "browser", + "title": "ILP Metrics Dashboard", + "uid": "ilp-metrics-dashboard", + "version": 1 +} diff --git a/package.json b/package.json index d2e1621dc9..f296bd81f6 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,13 @@ "check:prettier": "prettier --check .", "clean": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", "build": "tsc --build", - "localenv:compose:psql": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml", + "localenv:compose:psql": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml", "localenv:compose": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", + "localenv:compose:multihop": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/multihop/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:multitenancy": "docker compose -f ./localenv/cloud-ten-wallet/docker-compose.yml -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", - "localenv:compose:psql:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml", - "localenv:compose:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", - "localenv:compose:adminauth": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/admin-auth/docker-compose.yml", + "localenv:compose:psql:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml", + "localenv:compose:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", + "localenv:compose:adminauth": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/global-bank/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/admin-auth/docker-compose.yml", "localenv:seed:auth": "pnpm -C ./packages/auth knex seed:run --env=development && pnpm -C ./packages/auth knex seed:run --env=peerdevelopment", "sanity": "pnpm -r build && pnpm -r test", "localenv:compose:autopeer": "run-p tunnel:start wait-tunnel:localenv:compose", diff --git a/packages/backend/migrations/20250605095446_add_routes_to_peers.js b/packages/backend/migrations/20250605095446_add_routes_to_peers.js new file mode 100644 index 0000000000..39471acbff --- /dev/null +++ b/packages/backend/migrations/20250605095446_add_routes_to_peers.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('peers', function (table) { + table.specificType('routes', 'text[]').defaultTo('{}') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('peers', function (table) { + table.dropColumn('routes') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 9952324002..a95b4a2071 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -113,6 +113,7 @@ import { TenantSettingService } from './tenants/settings/service' import { StreamCredentialsService } from './payment-method/ilp/stream-credentials/service' import { PaymentMethodProviderService } from './payment-method/provider/service' +import { RouterService } from './payment-method/ilp/connector/ilp-routing/service' export interface AppContextData { logger: Logger container: AppContainer @@ -246,6 +247,7 @@ export interface AppServices { assetService: Promise accountingService: Promise peerService: Promise + routerService: Promise walletAddressService: Promise spspRoutes: Promise incomingPaymentRoutes: Promise diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index aab1ba3422..1e25fa0ba9 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -195,6 +195,7 @@ export const Config = { walletAddressRedirectHtmlPage: process.env.WALLET_ADDRESS_REDIRECT_HTML_PAGE, localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000), operatorTenantId: envString('OPERATOR_TENANT_ID'), + enableIlpTiming: envBool('ENABLE_ILP_TIMING', true), dbSchema: undefined as string | undefined, sendTenantWebhooksToOperator: envBool( 'SEND_TENANT_WEBHOOKS_TO_OPERATOR', diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 10bbfb273a..3248ffe936 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -1847,6 +1847,26 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "routes", + "description": "Routes for the peer.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "staticIlpAddress", "description": "ILP address of the peer.", @@ -6495,6 +6515,26 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "routes", + "description": "Routes for the peer.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "staticIlpAddress", "description": "ILP address of the peer.", @@ -9096,6 +9136,26 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "routes", + "description": "New routes for the peer.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "staticIlpAddress", "description": "New ILP address for the peer.", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index bfd2e00bb3..faf5124256 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -312,6 +312,8 @@ export type CreatePeerInput = { maxPacketAmount?: InputMaybe; /** Internal name of the peer. */ name?: InputMaybe; + /** Routes for the peer. */ + routes?: InputMaybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['input']; }; @@ -1166,6 +1168,8 @@ export type Peer = Model & { maxPacketAmount?: Maybe; /** Public name for the peer. */ name?: Maybe; + /** Routes for the peer. */ + routes?: Maybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; /** Unique identifier of the tenant associated with the peer. */ @@ -1604,6 +1608,8 @@ export type UpdatePeerInput = { maxPacketAmount?: InputMaybe; /** New public name for the peer. */ name?: InputMaybe; + /** New routes for the peer. */ + routes?: InputMaybe>; /** New ILP address for the peer. */ staticIlpAddress?: InputMaybe; }; @@ -2528,6 +2534,7 @@ export type PeerResolvers, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + routes?: Resolver>, ParentType, ContextType>; staticIlpAddress?: Resolver; tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/backend/src/graphql/resolvers/auto-peering.test.ts b/packages/backend/src/graphql/resolvers/auto-peering.test.ts index ee13a3caa2..8514b97122 100644 --- a/packages/backend/src/graphql/resolvers/auto-peering.test.ts +++ b/packages/backend/src/graphql/resolvers/auto-peering.test.ts @@ -94,6 +94,8 @@ describe('Auto Peering Resolvers', (): void => { afterEach(async (): Promise => { await truncateTables(deps) + const staticRoutesStore = await deps.use('staticRoutesStore') + await staticRoutesStore.deleteAll() }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/peer.ts b/packages/backend/src/graphql/resolvers/peer.ts index 89862759d3..ccbe0d40c1 100644 --- a/packages/backend/src/graphql/resolvers/peer.ts +++ b/packages/backend/src/graphql/resolvers/peer.ts @@ -71,6 +71,7 @@ export const getPeerByAddressAndAsset: QueryResolvers['pe const peer = await peerService.getByDestinationAddress( args.staticIlpAddress, ctx.tenant.id, + undefined, args.assetId ) return peer ? peerToGraphql(peer) : null @@ -84,8 +85,11 @@ export const createPeer: MutationResolvers['createPeer'] ): Promise => { const peerService = await ctx.container.use('peerService') const peerOrError = await peerService.create({ - ...args.input, - tenantId: ctx.isOperator ? undefined : ctx.tenant.id + ...({ + ...args.input, + routes: args.input, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }.routes || []) }) if (isPeerError(peerOrError)) { throw new GraphQLError(errorToMessage[peerOrError], { @@ -107,8 +111,11 @@ export const updatePeer: MutationResolvers['updatePeer'] ): Promise => { const peerService = await ctx.container.use('peerService') const peerOrError = await peerService.update({ - ...args.input, - tenantId: ctx.isOperator ? undefined : ctx.tenant.id + ...{ + ...args.input, + routes: args.input, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }.routes }) if (isPeerError(peerOrError)) { throw new GraphQLError(errorToMessage[peerOrError], { @@ -148,6 +155,7 @@ export const peerToGraphql = (peer: Peer): SchemaPeer => ({ http: peer.http, asset: assetToGraphql(peer.asset), staticIlpAddress: peer.staticIlpAddress, + routes: peer.routes || [], name: peer.name, liquidityThreshold: peer.liquidityThreshold, createdAt: new Date(+peer.createdAt).toISOString(), diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 69daac09f5..c0360cb714 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -457,6 +457,8 @@ input CreatePeerInput { initialLiquidity: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String + "Routes for the peer." + routes: [String!] } input CreateOrUpdatePeerByUrlInput { @@ -491,6 +493,8 @@ input UpdatePeerInput { liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String + "New routes for the peer." + routes: [String!] } input HttpInput { @@ -755,6 +759,8 @@ type Peer implements Model { liquidity: UInt64 "The date and time when the peer was created." createdAt: String! + "Routes for the peer." + routes: [String!] "Unique identifier of the tenant associated with the peer." tenantId: ID! } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 57b8ba759b..e1ee3d8b6f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -64,6 +64,7 @@ import { createTenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' import { createTenantSettingService } from './tenants/settings/service' import { createPaymentMethodProviderService } from './payment-method/provider/service' +import { createRouterService } from './payment-method/ilp/connector/ilp-routing/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -322,7 +323,8 @@ export function initIocContainer( logger: await deps.use('logger'), accountingService: await deps.use('accountingService'), assetService: await deps.use('assetService'), - httpTokenService: await deps.use('httpTokenService') + httpTokenService: await deps.use('httpTokenService'), + routerService: await deps.use('routerService') }) }) container.singleton('authServerService', async (deps) => { @@ -443,6 +445,19 @@ export function initIocContainer( knex: await deps.use('knex') }) }) + container.singleton('staticRoutesStore', async () => { + return createInMemoryDataStore(Number.MAX_SAFE_INTEGER) + }) + + container.singleton('routerService', async (deps) => { + const config = await deps.use('config') + return await createRouterService({ + logger: await deps.use('logger'), + staticIlpAddress: config.ilpAddress, + staticRoutes: await deps.use('staticRoutesStore'), + config + }) + }) container.singleton('connectorApp', async (deps) => { const config = await deps.use('config') @@ -652,6 +667,33 @@ export const gracefulShutdown = async ( telemetry.shutdown() } +const loadRoutesFromDatabase = async ( + container: IocContract +): Promise => { + const peerService = await container.use('peerService') + const routerService = await container.use('routerService') + const logger = await container.use('logger') + + const peers = await peerService.getPage() + logger.info( + { peerCount: peers.length }, + 'loading static routes from database' + ) + + for (const peer of peers) { + // If no routes are set, we use the static address of our peers as the only routes + const routes = peer.routes || [peer.staticIlpAddress] + for (const route of routes) { + await routerService.addStaticRoute( + route, + peer.id, + peer.tenantId, + peer.assetId + ) + } + } +} + export const start = async ( container: IocContract, app: App @@ -727,6 +769,7 @@ export const start = async ( if (error) throw error await app.boot() + await loadRoutesFromDatabase(container) await app.startAdminServer(config.adminPort) logger.info(`Admin listening on ${app.getAdminPort()}`) diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts index 745fe63420..f1fac54bae 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts @@ -41,6 +41,8 @@ describe('Auto Peering Service', (): void => { afterEach(async (): Promise => { await truncateTables(deps) + const staticRoutesStore = await deps.use('staticRoutesStore') + await staticRoutesStore.deleteAll() }) afterAll(async (): Promise => { diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.ts index 4463c2514e..5143109f95 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.ts @@ -299,6 +299,7 @@ async function updatePeer( const peer = await deps.peerService.getByDestinationAddress( args.staticIlpAddress, args.tenantId, + undefined, args.assetId ) diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts index 35a2d3551d..a6a25e7867 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts @@ -101,10 +101,12 @@ export function createAccountMiddleware(): ILPMiddleware { return walletAddress } } + const address = ctx.request.prepare.destination const peer = await peers.getByDestinationAddress( address, - incomingAccount.tenantId + incomingAccount.tenantId, + incomingAccount.id ) if (peer) { return peer @@ -133,6 +135,7 @@ export function createAccountMiddleware(): ILPMiddleware { return outgoingAccount! } } + await next() } } diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts index 7574ff6bdf..0be365b184 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts @@ -86,6 +86,7 @@ export function createBalanceMiddleware(): ILPMiddleware { } const trxOrError = await services.accounting.createTransfer(transferOptions) + if (isTransferError(trxOrError)) { logger.error( { transferOptions, transferError: trxOrError }, diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/ilp-timing.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/ilp-timing.ts new file mode 100644 index 0000000000..38711ff476 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/ilp-timing.ts @@ -0,0 +1,59 @@ +import { IncomingPayment } from '../../../../../open_payments/payment/incoming/model' +import { OutgoingPayment } from '../../../../../open_payments/payment/outgoing/model' +import { Peer } from '../../../peer/model' +import { + ILPContext, + ILPMiddleware, + IncomingAccount, + OutgoingAccount +} from '../rafiki' + +function determineOperation( + incoming: IncomingAccount, + outgoing: OutgoingAccount +): string { + if (incoming instanceof OutgoingPayment && outgoing instanceof Peer) { + return 'outgoing_payment' + } + + if (incoming instanceof Peer && outgoing instanceof IncomingPayment) { + return 'incoming_payment' + } + + if (incoming instanceof Peer && outgoing instanceof Peer) { + return 'routing' + } + return 'unknown' +} + +export function createIlpTimingMiddleware(): ILPMiddleware { + return async function ilpTiming( + ctx: ILPContext, + next: () => Promise + ): Promise { + if (!ctx.services.config.enableIlpTiming) { + await next() + return + } + + const { telemetry } = ctx.services + let operation = 'unknown' + const stopTimer = telemetry.startTimer('ilp_prepare_packet_processing_ms', { + operation + }) + + try { + await next() + } finally { + if (ctx.accounts?.incoming && ctx.accounts?.outgoing) { + operation = determineOperation( + ctx.accounts.incoming, + ctx.accounts.outgoing + ) + } + stopTimer({ + operation + }) + } + } +} diff --git a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts index ddbedb29ce..9beffbd9b4 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts @@ -63,7 +63,6 @@ export interface TransferOptions { } export interface RafikiServices { - //router: Router accounting: AccountingService telemetry: TelemetryService walletAddresses: WalletAddressService @@ -101,6 +100,7 @@ export type ILPContext = { request: { prepare: ZeroCopyIlpPrepare rawPrepare: Buffer + nextHop?: string } response: IlpResponse throw: (status: number, msg: string) => never diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts index e669118cab..859f4c8ca0 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts @@ -134,7 +134,8 @@ describe('Account Middleware', () => { expect(ctx.accounts.outgoing).toEqual(outgoingAccount) expect(getByDestinationAddressSpy).toHaveBeenCalledWith( 'test.123', - tenantId + tenantId, + incomingAccount.id ) }) diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/README.md b/packages/backend/src/payment-method/ilp/connector/ilp-routing/README.md index 4aa26c850c..90e97de45b 100644 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/README.md +++ b/packages/backend/src/payment-method/ilp/connector/ilp-routing/README.md @@ -1,49 +1,16 @@ -# ilp-routing +## ILP static routing -> (BETA Stage) Router and Route Manager for Interledger-style addressing +At startup of a Rafiki instance, static routes are loaded from the database and stored in the in memory routing table. All subsequent peer updates will also refresh the routing table. For backwards compatibility, if no routes exist then direct peers' address and asset id will be used to populate the routing table. - +where: -## Project +- `tenantId` is the tenant id of the caller +- `destination` is the static ILP address of the payment receiver. +- `next hop` is the peer id of the direct peer that will either route or be the destination of the packet +- `asset id` is the asset id of the next hop peer -> this field is mandatory when adding/removing a route but not when querying for the next hop, as one could or could not be interested in what asset the peering relationship has when forwarding the packet. -## Members - -### ilp-router - -Stand alone router for ilp address space. Contains a routing table and a forwarding routing table. Routing table is used to determine where the nextHop is based on a given address. The forwarding routing table is used to broadcasting routes to peers. - -### ilp-route-manager - -A route manager that deals with adding/removing peers and adding/removing routes for given peers. Based on these it will update the routing table accordingly. - -#### Note CCP is outside the scope of the functionalities of this library. - -### Folders - -All source code is expected to be TypeScript and is placed in the `src` folder. Tests are put in the `test` folder. - -The NPM package will not contain any TypeScript files (`*.ts`) but will have typings and source maps. - -### TODO - -- [ ] Add Logging -- [ ] Add example file -- [ ] Add Auth -- [x] Add weight to lessen the need for relations to be used -- [ ] Add Performance Regression -- [ ] Increase test coverage -- [ ] Ensure adding and removing routes are deterministic and no race conditions exist -- [ ] Add dragon filtering back to the layer between the routing and forwarding routing table -- [ ] Create a more favorable data structure for Peers incoming routes table. One way this can be achieved is having a write heavy data structure that is read fast. (This is probably preferable.) - -### Future notes/reading - -Implement BGP type path based filtering -https://www.cisco.com/c/en/us/support/docs/ip/border-gateway-protocol-bgp/13753-25.html#bestpath -Multipath could also be an interesting area to pursue. +`tenantId:destination` is called `prefix` in the implementation and is the key of the table. **Longest prefix matching is done against this key.** diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/index.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/index.ts deleted file mode 100644 index 1a83eb0dd2..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Relation } from '../types/relation' -import { Router } from '../ilp-router' -import { Peer } from './peer' -import { IncomingRoute, Route } from '../types/routing' - -export class RouteManager { - private peers: Map = new Map() - private router: Router - - constructor(router: Router) { - this.router = router - } - - // Possibly also allow a route to be added defaulted? - addPeer(peerId: string, relation: Relation): void { - const peer = new Peer({ peerId: peerId, relation: relation }) - this.peers.set(peerId, peer) - } - - removePeer(peerId: string): void { - const peer = this.getPeer(peerId) - if (peer) { - const prefixes = peer.getPrefixes() - this.peers.delete(peerId) - prefixes.forEach((prefix) => this.updatePrefix(prefix)) - } - } - - getPeer(peerId: string): Peer | undefined { - return this.peers.get(peerId) - } - - getPeerList(): string[] { - return Array.from(this.peers.keys()) - } - - // Do a check if the peerId exists as a peer and then also add the route to the routing table - addRoute(route: IncomingRoute): void { - const peer = this.getPeer(route.peer) - if (peer) { - // Gotcha the insert of the route into the peers routing table must occur before calling updatePrefix - peer.insertRoute(route) - this.updatePrefix(route.prefix) - } - } - - removeRoute(peerId: string, prefix: string): void { - const peer = this.getPeer(peerId) - if (peer) { - peer.deleteRoute(prefix) - this.updatePrefix(prefix) - } - } - - /** - * get best peer for prefix and updateRouting based on the new route - * @param prefix prefix - */ - private updatePrefix(prefix: string): void { - const newBest = this.getBestPeerForPrefix(prefix) - this.updateRouteInRouter(prefix, newBest) - } - - /** - * Find the best peer for the prefix routing to - * 1. Ideal to use configure routes - * 2. Else look in localRoutes as built before to find exact route - * 3. If not exact route need to find 'bestRoute' based on peers - * @param prefix prefix - */ - private getBestPeerForPrefix(prefix: string): Route | undefined { - const bestRoute = Array.from(this.peers.values()) - .map((peer) => peer.getPrefix(prefix)) - .filter((a): a is IncomingRoute => !!a) - .sort((a?: IncomingRoute, b?: IncomingRoute) => { - if (!a && !b) { - return 0 - } else if (!a) { - return 1 - } else if (!b) { - return -1 - } - - // First sort by peer weight - const weightA = a.weight ? a.weight : 0 - const weightB = b.weight ? b.weight : 0 - - if (weightA !== weightB) { - return weightB - weightA - } - - // Then sort by path length - const pathA = a.path.length - const pathB = b.path.length - - if (pathA !== pathB) { - return pathA - pathB - } - - // Catch all - return 0 - })[0] - - return ( - bestRoute && { - nextHop: bestRoute.peer, - path: bestRoute.path, - weight: bestRoute.weight, - auth: bestRoute.auth - } - ) - } - - private updateRouteInRouter( - prefix: string, - newBest: Route | undefined - ): void { - if (newBest) { - this.router.addRoute(prefix, newBest) - } else { - this.router.removeRoute(prefix) - } - } -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/peer.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/peer.ts deleted file mode 100644 index 948212938f..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-route-manager/peer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import PrefixMap from '../lib/prefix-map' -import { IncomingRoute } from '../types/routing' -import { Relation } from '../types/relation' -import { randomBytes } from 'crypto' -import { hmac, sha256 } from '../lib/utils' - -export interface PeerOpts { - peerId: string - relation: Relation - routingSecret?: string - shouldAuth?: boolean -} - -export class Peer { - private peerId: string - private relation: Relation - private routes: PrefixMap - - private routingSecret: Buffer - private shouldAuth: boolean - - constructor({ peerId, relation, routingSecret, shouldAuth }: PeerOpts) { - this.peerId = peerId - this.relation = relation - - this.routingSecret = routingSecret - ? Buffer.from(routingSecret, 'base64') - : randomBytes(32) - this.shouldAuth = shouldAuth || false - - // TODO: Possibly inefficient instantiating this if not a ccp-receiver? Though the code is quite clean without needing to pass the data to here - this.routes = new PrefixMap() - } - - getPrefix(prefix: string): IncomingRoute | undefined { - return this.routes.get(prefix) - } - - insertRoute(route: IncomingRoute): boolean { - if (this.shouldAuth) { - const auth = hmac(this.routingSecret, route.prefix) - if (sha256(auth) !== route.auth) { - return false - } - } - this.routes.insert(route.prefix, route) - // TODO Check if actually changed - return true - } - - deleteRoute(prefix: string): boolean { - this.routes.delete(prefix) - - // TODO Check if actually changed - return true - } - - getPrefixes(): string[] { - return this.routes.keys() - } - - getRelation(): Relation { - return this.relation - } -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/forwarding-routing-table.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/forwarding-routing-table.ts deleted file mode 100644 index 2091391622..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/forwarding-routing-table.ts +++ /dev/null @@ -1,15 +0,0 @@ -import PrefixMap from '../lib/prefix-map' -import { Route } from '../types/routing' -import { uuid } from '../lib/utils' - -export interface RouteUpdate { - epoch: number - prefix: string - route?: Route -} - -export default class ForwardingRoutingTable extends PrefixMap { - public routingTableId: string = uuid() - public log: (RouteUpdate | null)[] = [] - public currentEpoch = 0 // Superfluous? As the log length is analogous to the epoch it seems. -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/index.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/index.ts deleted file mode 100644 index 6dc7f441ba..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -import RoutingTable from './routing-table' -import { Route } from '../types/routing' -import ForwardingRoutingTable, { RouteUpdate } from './forwarding-routing-table' -// eslint-disable-next-line -import { canDragonFilter } from '../lib/dragon' -import { sha256 } from '../lib/utils' - -export class Router { - private globalPrefix: string - private ownAddress?: string - private routingTable: RoutingTable - private forwardingRoutingTable: ForwardingRoutingTable - - constructor() { - this.routingTable = new RoutingTable() - this.forwardingRoutingTable = new ForwardingRoutingTable() - this.globalPrefix = 'g' - } - - setGlobalPrefix(prefix: string): void { - this.globalPrefix = prefix - } - - setOwnAddress(address: string): void { - this.ownAddress = address - } - - getOwnAddress(): string { - if (this.ownAddress === undefined) { - throw new Error('ownAddress not set') - } - return this.ownAddress - } - - addRoute(prefix: string, route: Route): void { - this.updateLocalRoute(prefix, route) - } - - removeRoute(prefix: string): void { - this.updateLocalRoute(prefix) - } - - getRoutingTable(): RoutingTable { - return this.routingTable - } - - getForwardingRoutingTable(): ForwardingRoutingTable { - return this.forwardingRoutingTable - } - - // TODO: Maybe this shouldn't throw an error and instead just return undefined - nextHop(prefix: string): string { - const route = this.routingTable.resolve(prefix) - const nextHop = route && route.nextHop - if (nextHop) { - return nextHop - } else { - throw new Error( - "Can't route the request due to no route found for given prefix" - ) - } - } - - /** - * Get currentBest from localRoutingTable - * check if newNextHop has changed. If it has, update localRoutingTable and forwardingRouteTable - * @param prefix prefix - * @param route route - */ - private updateLocalRoute(prefix: string, route?: Route): boolean { - const currentBest = this.routingTable.get(prefix) - const currentNextHop = currentBest && currentBest.nextHop - const newNextHop = route && route.nextHop - - if (newNextHop !== currentNextHop) { - if (route) { - // log.trace('new best route for prefix. prefix=%s oldBest=%s newBest=%s', prefix, currentNextHop, newNextHop) - this.routingTable.insert(prefix, route) - } else { - // log.trace('no more route available for prefix. prefix=%s', prefix) - this.routingTable.delete(prefix) - } - - this.updateForwardingRoute(prefix, route) - - return true - } - - return false - } - - private getGlobalPrefix(): string { - return this.globalPrefix - } - - /** - * updating forwarding routing table - * 1. If route is defined. - * 2. Update path and auth on route - * 3. If various checks on route set route to undefined - * 4. Get Current best from forwarding Routing Table - * 5. if newNextHop - * 6. Update the forwarding routing table - * 7. Check to apply dragon filtering based on the update on other prefixes. - * 8. Else do nothing - * @param prefix prefix - * @param route route - */ - private updateForwardingRoute(prefix: string, route?: Route): void { - if (route) { - route = { - ...route, - path: [this.getOwnAddress(), ...route.path], - auth: route.auth ? sha256(route.auth) : Buffer.from('') - } - - if ( - // Routes must start with the global prefix - !prefix.startsWith(this.getGlobalPrefix()) || - // Don't publish the default route - prefix === this.getGlobalPrefix() || - // Don't advertise local customer routes that we originated. Packets for - // these destinations should still reach us because we are advertising our - // own address as a prefix. - (prefix.startsWith(this.getOwnAddress() + '.') && - route.path.length === 1) // || - - // canDragonFilter( - // this.forwardingRoutingTable, - // this.getAccountRelation, - // prefix, - // route - // ) - ) { - route = undefined - } - } - - const currentBest = this.forwardingRoutingTable.get(prefix) - - const currentNextHop = - currentBest && currentBest.route && currentBest.route.nextHop - const newNextHop = route && route.nextHop - - if (currentNextHop !== newNextHop) { - const epoch = this.forwardingRoutingTable.currentEpoch++ - const routeUpdate: RouteUpdate = { - prefix, - route, - epoch - } - - this.forwardingRoutingTable.insert(prefix, routeUpdate) - - // log.trace('logging route update. update=%j', routeUpdate) - - // Remove from forwarding routing table. - if (currentBest) { - this.forwardingRoutingTable.log[currentBest.epoch] = null - } - - this.forwardingRoutingTable.log[epoch] = routeUpdate - - if (route) { - // We need to re-check any prefixes that start with this prefix to see - // if we can apply DRAGON filtering. - // - // Note that we do this check *after* we have added the new route above. - const subPrefixes = - this.forwardingRoutingTable.getKeysStartingWith(prefix) - - for (const subPrefix of subPrefixes) { - if (subPrefix === prefix) continue - - const routeUpdate = this.forwardingRoutingTable.get(subPrefix) - - if (!routeUpdate || !routeUpdate.route) continue - - this.updateForwardingRoute(subPrefix, routeUpdate.route) - } - } - } - } -} - -export { ForwardingRoutingTable, RouteUpdate } diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/routing-table.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/routing-table.ts deleted file mode 100644 index 4574bdf135..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/ilp-router/routing-table.ts +++ /dev/null @@ -1,4 +0,0 @@ -import PrefixMap from '../lib/prefix-map' -import { Route } from '../types/routing' - -export default class RoutingTable extends PrefixMap {} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/index.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/index.ts deleted file mode 100644 index 72f194fadd..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './ilp-router' -export * from './ilp-route-manager' -export * from './types' diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/dragon.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/dragon.ts deleted file mode 100644 index 1112984a6d..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/dragon.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Route } from '../types/routing' -// import { create as createLogger } from '../common/log' -// const log = createLogger('dragon') -import { Relation, getRelationPriority } from '../types/relation' -import ForwardingRoutingTable from '../ilp-router/forwarding-routing-table' - -/** - * Check whether a route can be filtered out based on DRAGON rules. - * - * See http://route-aggregation.net/. - * - * The basic idea is that if we have a more general route that is as good as a - * more specific route, we don't need to advertise the more specific route. - * - * This removes a lot of routing updates across a large network and has basically - * no downside. - * - * Note that we use DRAGON filtering, but *not* DRAGON aggregation. There are - * several reasons for this: - * - * * ILP address space is a lot less dense than IPv4 address space, so - * DRAGON aggregation would not be a significant optimization. - * - * * We may want to secure our routing protocol using a mechanism similar to - * BGPsec, which precludes aggregation. - * - * * We will recommend that owners of tier-1 ILP address space are also real - * connectors which participate in the routing protocol and originate a route - * advertisement for their tier-1 prefix. This will enable DRAGON filtering - * to apply to a lot more situations where otherwise only DRAGON aggregation - * would be applicable. - */ -export function canDragonFilter( - routingTable: ForwardingRoutingTable, - getRelation: (prefix: string) => Relation, - prefix: string, - route: Route -): boolean { - // Find any less specific route - for (const parentPrefix of routingTable.getKeysPrefixesOf(prefix)) { - const parentRouteUpdate = routingTable.get(parentPrefix) - - if (!parentRouteUpdate || !parentRouteUpdate.route) { - // log.warn('found a parent prefix, but no parent route; this should never happen. prefix=%s parentPrefix=%s', prefix, parentPrefix) - continue - } - - const parentRoute = parentRouteUpdate.route - - if (parentRoute.nextHop === '') { - // We are the origin of the parent route, cannot DRAGON filter - continue - } - - const parentRelation = getRelation(parentRoute.nextHop) - const childRelation = getRelation(route.nextHop) - if ( - getRelationPriority(parentRelation) < getRelationPriority(childRelation) - ) { - // The more specific route is better for us, so we keep it - continue - } - - // log.trace('applied DRAGON route filter. prefix=%s parentPrefix=%s', prefix, parentPrefix) - return true - } - - return false -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/prefix-map.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/prefix-map.ts deleted file mode 100644 index 9f2b6e3989..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/prefix-map.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * A key-value map where the members' keys represent prefixes. - * - * Example: - * const map = new PrefixMap() - * map.insert("foo", 1) - * map.insert("bar", 2) - * map.get("foo") // ⇒ 1 - * map.get("foo.bar") // ⇒ 1 ("foo" is the longest known prefix of "foo.bar") - * map.get("bar") // ⇒ 2 - * map.get("bar.foo") // ⇒ 2 ("bar" is the longest known prefix of "bar.foo") - * map.get("random") // ⇒ null - */ -export default class PrefixMap { - protected prefixes: string[] - protected items: { [key: string]: T } - - constructor() { - this.prefixes = [] - this.items = {} - } - - keys(): string[] { - return this.prefixes - } - - size(): number { - return this.prefixes.length - } - - /** - * Find the value of the longest matching prefix key. - */ - resolve(key: string): T | undefined { - const prefix = this.resolvePrefix(key) - - return typeof prefix !== 'undefined' ? this.items[prefix] : undefined - } - - /** - * Find the longest matching prefix key. - */ - resolvePrefix(key: string): string | undefined { - // Exact match - if (this.items[key]) return key // redundant; optimization? - // prefix match (the list is in descending length order, and secondarily, reverse-alphabetically) - const index = this.prefixes.findIndex((e: string) => - key.startsWith(e + '.') - ) - if (index === -1) return undefined - const prefix = this.prefixes[index] - return prefix - } - - get(prefix: string): T | undefined { - return this.items[prefix] - } - - /** - * Look up all keys that start with a certain prefix. - */ - *getKeysStartingWith(prefix: string): IterableIterator { - // TODO: This could be done *much* more efficiently - const predicate = (key: string): boolean => key.startsWith(prefix) - for (let index = 0; index < this.prefixes.length; index++) { - if (predicate(this.prefixes[index])) { - yield this.prefixes[index] - } - } - } - - *getKeysPrefixesOf(search: string): IterableIterator { - const predicate = (key: string): boolean => search.startsWith(key + '.') - for (let index = 0; index < this.prefixes.length; index++) { - if (predicate(this.prefixes[index])) { - yield this.prefixes[index] - } - } - } - - /** - * @param {function(item, key)} fn - */ - each(fn: (item: T, key: string) => void): void { - for (const prefix of this.prefixes) { - fn(this.items[prefix], prefix) - } - } - - /** - * Insert the prefix while keeping the prefixes sorted first in length order - * and if two prefixes are the same length, sort them in reverse alphabetical order - */ - insert(prefix: string, item: T): T { - if (!this.items[prefix]) { - const index = this.prefixes.findIndex((e: string) => { - if (prefix.length === e.length) { - return prefix > e - } - return prefix.length > e.length - }) - - if (index === -1) { - this.prefixes.push(prefix) - } else { - this.prefixes.splice(index, 0, prefix) - } - } - this.items[prefix] = item - return item - } - - delete(prefix: string): void { - const index = this.prefixes.indexOf(prefix) - if (this.prefixes[index] === prefix) this.prefixes.splice(index, 1) - delete this.items[prefix] - } - - toJSON(): { [key: string]: T } { - return this.items - } - - /** - * Find the shortest unambiguous prefix of an ILP address in a prefix map. - * - * This let's us figure out what addresses the selected route applies to. For - * example, the most specific route for destination "a.b.c" might be "a", but - * that doesn't mean that that route applies to any destination starting with - * "a" because there may be a more specific route like "a.c". - * - * So we would call this utility function to find out that the least specific - * prefix for which there are no other more specific routes is "a.b". - * - * In order to force a minimum prefix, it can be passed as the third parameter. - * This function may make it even more specific if necessary to make it - * unambiguous, but it will never return a less specific prefix. - */ - getShortestUnambiguousPrefix(address: string, prefix = ''): string { - if (!address.startsWith(prefix)) { - throw new Error( - `address must start with prefix. address=${address} prefix=${prefix}` - ) - } - - this.keys().forEach((secondPrefix: string) => { - if (secondPrefix === prefix) { - return - } - - while (secondPrefix.startsWith(prefix)) { - if (secondPrefix === prefix) { - return - } - - const nextSegmentEnd = address.indexOf('.', prefix.length + 1) - - if (nextSegmentEnd === -1) { - prefix = address - return - } else { - prefix = address.slice(0, nextSegmentEnd) - } - } - }) - - return prefix - } -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/utils.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/utils.ts deleted file mode 100644 index b9e57e00aa..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/lib/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { randomBytes, createHash, createHmac } from 'crypto' - -export const sha256 = (preimage: Buffer): Buffer => { - return createHash('sha256').update(preimage).digest() -} - -export function hmac(secret: Buffer, message: string): Buffer { - const hmac = createHmac('sha256', secret) - hmac.update(message, 'utf8') - return hmac.digest() -} - -export function uuid(): string { - const random = randomBytes(16) - random[6] = (random[6] & 0x0f) | 0x40 - random[8] = (random[8] & 0x3f) | 0x80 - return random - .toString('hex') - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5') -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.test.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.test.ts new file mode 100644 index 0000000000..2cd3424a5a --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.test.ts @@ -0,0 +1,506 @@ +import { RouterService } from './service' +import { Config } from '../../../../config/app' +import { createTestApp, TestContainer } from '../../../../tests/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../../../..' +import { AppServices } from '../../../../app' +import { truncateTables } from '../../../../tests/tableManager' + +describe('RouterService', (): void => { + const tenantId1 = 'tenant-1' + const tenantId2 = 'tenant-2' + const peerId1 = 'peer-1' + const peerId2 = 'peer-2' + const peerId3 = 'peer-3' + const assetId1 = 'asset-1' + const assetId2 = 'asset-2' + const prefix = 'g.test1' + + let deps: IocContract + let appContainer: TestContainer + let routerService: RouterService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + routerService = await deps.use('routerService') + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + const staticRoutesStore = await deps.use('staticRoutesStore') + await staticRoutesStore.deleteAll() + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('addStaticRoute', (): void => { + test('adds route for specific tenant and asset', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect(nextHop).toBe(peerId1) + }) + + test('adds multiple peers for same prefix, tenant and asset', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect([peerId1, peerId2]).toContain(nextHop) + }) + + test('isolates routes between tenants', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId2, assetId1) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId2, + undefined, + assetId1 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId2) + }) + + test('isolates routes between assets', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId2) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId2) + }) + + test('allows same peer for different assets', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId2) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId1) + }) + }) + + describe('removeStaticRoute', (): void => { + test('removes route for specific tenant and asset', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.removeStaticRoute( + prefix, + peerId1, + tenantId1, + assetId1 + ) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect(nextHop).toBeUndefined() + }) + + test('removes only specific peer from tenant and asset', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId1) + await routerService.removeStaticRoute( + prefix, + peerId1, + tenantId1, + assetId1 + ) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect(nextHop).toBe(peerId2) + }) + + test('handles removing non-existent route gracefully', async (): Promise => { + await routerService.removeStaticRoute( + prefix, + peerId1, + tenantId1, + assetId1 + ) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect(nextHop).toBeUndefined() + }) + + test('removes only specific asset route when peer has multiple assets', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId2) + await routerService.removeStaticRoute( + prefix, + peerId1, + tenantId1, + assetId1 + ) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + + expect(nextHop1).toBeUndefined() + expect(nextHop2).toBe(peerId1) + }) + + test('does not affect other tenants when removing route', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId1, tenantId2, assetId1) + await routerService.removeStaticRoute( + prefix, + peerId1, + tenantId1, + assetId1 + ) + + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId2, + undefined, + assetId1 + ) + expect(nextHop2).toBe(peerId1) + }) + }) + + describe('getNextHop - longest prefix match', (): void => { + test('finds next hop for exact prefix match', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop(prefix, tenantId1) + expect(nextHop).toBe(peerId1) + expect(nextHop).toBe(sameNextHop) + }) + + test('finds next hop for longer destination using longest prefix match', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + `${prefix}.sub`, + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop( + `${prefix}.sub`, + tenantId1 + ) + expect(nextHop).toBe(peerId1) + expect(nextHop).toBe(sameNextHop) + }) + + test('prefers longer prefix over shorter prefix', async (): Promise => { + await routerService.addStaticRoute('g', peerId1, tenantId1, assetId1) + await routerService.addStaticRoute( + 'g.test1', + peerId2, + tenantId1, + assetId1 + ) + + const nextHop = await routerService.getNextHop( + 'g.test1.sub', + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop( + 'g.test1.sub', + tenantId1 + ) + expect(nextHop).toBe(peerId2) + expect(nextHop).toBe(sameNextHop) + }) + + test('returns undefined for unknown destination', async (): Promise => { + const nextHop = await routerService.getNextHop( + 'g.unknown', + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop('g.unknown', tenantId1) + expect(nextHop).toBeUndefined() + expect(nextHop).toBe(sameNextHop) + }) + + test('isolates routing between tenants', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId2, assetId1) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId2, + undefined, + assetId1 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId2) + }) + + test('returns undefined for destination of different tenant', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + const nextHop = await routerService.getNextHop( + prefix, + tenantId2, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop(prefix, tenantId2) + + expect(nextHop).toBeUndefined() + expect(nextHop).toBe(sameNextHop) + }) + + test('handles multiple peers for same prefix and asset (random selection)', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId3, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + expect([peerId1, peerId2, peerId3]).toContain(nextHop) + }) + }) + + describe('getNextHop - asset filtering', (): void => { + test('filters routes by asset when multiple assets exist', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId2) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId2) + }) + + test('filters out incoming peer route', async (): Promise => { + // In case destinations are general, such as `test`, + // there is a possibility that a sender gets it's packets routed to itself + // Therefore, we should filter out the incoming peer route + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + peerId1 + ) + + expect(nextHop1).toBeUndefined() + }) + + test('filters out incoming peer route with asset id', async (): Promise => { + // In case destinations are general, such as `test`, + // there is a possibility that a sender gets it's packets routed to itself + // Therefore, we should filter out the incoming peer route + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId2, tenantId1, assetId2) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + peerId2, + assetId1 + ) + + expect(nextHop1).toBe(peerId1) + + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + peerId2, + assetId2 + ) + + expect(nextHop2).toBeUndefined() + }) + + test('returns undefined when asset does not match', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + expect(nextHop).toBeUndefined() + }) + + test('works without assetId parameter', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop(prefix, tenantId1) + expect(nextHop).toBe(peerId1) + }) + + test('handles multiple assets for same peer', async (): Promise => { + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId1) + await routerService.addStaticRoute(prefix, peerId1, tenantId1, assetId2) + + const nextHop1 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId1 + ) + const nextHop2 = await routerService.getNextHop( + prefix, + tenantId1, + undefined, + assetId2 + ) + + expect(nextHop1).toBe(peerId1) + expect(nextHop2).toBe(peerId1) + }) + }) + + describe('getNextHop - edge cases', (): void => { + test('handles empty destination', async (): Promise => { + const nextHop = await routerService.getNextHop( + '', + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop('', tenantId1) + + expect(nextHop).toBeUndefined() + expect(nextHop).toBe(sameNextHop) + }) + + test('handles single segment destination', async (): Promise => { + await routerService.addStaticRoute('g', peerId1, tenantId1, assetId1) + + const nextHop = await routerService.getNextHop( + 'g', + tenantId1, + undefined, + assetId1 + ) + const sameNextHop = await routerService.getNextHop('g', tenantId1) + + expect(nextHop).toBe(peerId1) + expect(nextHop).toBe(sameNextHop) + }) + + test('handles case where no prefix matches with asset', async (): Promise => { + await routerService.addStaticRoute( + 'g.test1', + peerId1, + tenantId1, + assetId1 + ) + + const nextHop = await routerService.getNextHop( + 'h.test1', + tenantId1, + undefined, + assetId1 + ) + expect(nextHop).toBeUndefined() + }) + }) + + describe('getOwnAddress', (): void => { + test('returns configured ILP address', (): void => { + const ownAddress = routerService.getOwnAddress() + expect(ownAddress).toBe('test.rafiki') + }) + }) +}) diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.ts new file mode 100644 index 0000000000..3e51ce57c3 --- /dev/null +++ b/packages/backend/src/payment-method/ilp/connector/ilp-routing/service.ts @@ -0,0 +1,161 @@ +import { CacheDataStore } from '../../../../middleware/cache/data-stores' +import { BaseService } from '../../../../shared/baseService' +import { IAppConfig } from '../../../../config/app' + +interface RouteEntry { + peerId: string + assetId: string +} + +export interface RouterService extends BaseService { + addStaticRoute( + prefix: string, + peerId: string, + tenantId: string, + assetId: string + ): Promise + removeStaticRoute( + prefix: string, + peerId: string, + tenantId: string, + assetId: string + ): Promise + getNextHop( + destination: string, + tenantId: string, + incomingPeerId?: string, + assetId?: string + ): Promise + getOwnAddress(): string +} + +export interface RouterServiceDependencies extends BaseService { + staticIlpAddress: string + staticRoutes: CacheDataStore + config: IAppConfig +} + +export async function createRouterService({ + logger, + staticIlpAddress, + staticRoutes, + config +}: RouterServiceDependencies): Promise { + const log = logger.child({ service: 'RouterService' }) + const ownAddress = staticIlpAddress + log.debug({ ownAddress }, 'ownAddress') + + const deps: RouterServiceDependencies = { + logger: log, + staticIlpAddress, + staticRoutes, + config + } + + async function addStaticRoute( + deps: RouterServiceDependencies, + destination: string, + peerId: string, + tenantId: string, + assetId: string + ) { + const key = `${tenantId}:${destination}` + const existingRoutes = (await deps.staticRoutes.get(key)) || [] + const existingRoute = existingRoutes.find( + (route) => route.peerId === peerId && route.assetId === assetId + ) + + if (!existingRoute) { + existingRoutes.push({ peerId, assetId }) + await deps.staticRoutes.set(key, existingRoutes) + deps.logger.debug( + { prefix: destination, peerId, assetId, tenantId }, + 'added static route' + ) + } + } + + async function removeStaticRoute( + deps: RouterServiceDependencies, + destination: string, + peerId: string, + tenantId: string, + assetId: string + ) { + const key = `${tenantId}:${destination}` + const existingRoutes = await deps.staticRoutes.get(key) + if (existingRoutes) { + const updatedRoutes = existingRoutes.filter( + (route) => !(route.peerId === peerId && route.assetId === assetId) + ) + if (updatedRoutes.length > 0) { + await deps.staticRoutes.set(key, updatedRoutes) + } else { + await deps.staticRoutes.delete(key) + } + deps.logger.debug( + { destination, peerId, assetId, tenantId }, + 'removed static route' + ) + } + } + + async function getNextHop( + deps: RouterServiceDependencies, + destination: string, + tenantId: string, + incomingPeerId?: string, + assetId?: string + ): Promise { + const segments = destination.split('.') + for (let i = segments.length; i > 0; i--) { + const prefix = segments.slice(0, i).join('.') + const key = `${tenantId}:${prefix}` + const routes = await deps.staticRoutes.get(key) + + if (routes && routes.length > 0) { + const filteredRoutes = routes.filter((route) => { + if (incomingPeerId && route.peerId === incomingPeerId) { + return false + } + if (assetId && route.assetId !== assetId) { + return false + } + return true + }) + + if (filteredRoutes.length > 0) { + // If multiple routes are found, select one randomly + const selectedRoute = + filteredRoutes.length === 1 + ? filteredRoutes[0] + : filteredRoutes[ + Math.floor(Math.random() * filteredRoutes.length) + ] + + return selectedRoute.peerId + } + } + } + deps.logger.debug( + { destination, tenantId, assetId }, + 'no static route found' + ) + return undefined + } + + function getOwnAddress(deps: RouterServiceDependencies): string { + return deps.staticIlpAddress + } + + return { + logger: log, + addStaticRoute: (destination, peerId, tenantId, assetId) => + addStaticRoute(deps, destination, peerId, tenantId, assetId), + removeStaticRoute: (destination, peerId, tenantId, assetId) => + removeStaticRoute(deps, destination, peerId, tenantId, assetId), + getNextHop: (destination, tenantId, incomingPeerId, assetId) => + getNextHop(deps, destination, tenantId, incomingPeerId, assetId), + getOwnAddress: () => getOwnAddress(deps) + } +} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/index.test.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/index.test.ts deleted file mode 100644 index b0c395bd96..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/index.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Router } from '..' - -describe('ilp-router', function () { - describe('routes', function () { - let router: Router - - beforeEach(function () { - router = new Router() - router.setOwnAddress('test.rafiki') - }) - - test('can add a route for a peer', function () { - router.addRoute('g.harry', { - nextHop: 'harry', - path: [] - }) - - const table = router.getRoutingTable() - - expect(table.keys().includes('g.harry')).toBe(true) - expect(table.resolve('g.harry.sally')).toEqual({ - nextHop: 'harry', - path: [] - }) - }) - - test('can remove a route for a peer', function () { - router.addRoute('g.harry', { - nextHop: 'harry', - path: [] - }) - - router.removeRoute('g.harry') - - const table = router.getRoutingTable() - expect(table.keys().includes('g.harry')).toBe(false) - expect(table.resolve('g.harry.sally')).not.toBeDefined() - }) - }) - - describe('nextHop', function () { - let router: Router - - beforeEach(function () { - router = new Router() - router.setOwnAddress('test.rafiki') - router.addRoute('g.harry', { - nextHop: 'harry', - path: [] - }) - }) - - test('returns peerId if nextHop called for route to a peer', function () { - const nextHop = router.nextHop('g.harry.met.sally') - expect(nextHop).toEqual('harry') - }) - - test("throws an error if can't route request", function () { - expect(() => router.nextHop('g.sally')).toThrow() - }) - }) - - describe('weighting', function () { - /* TODO */ - }) - - // TODO: Need to add functionality to check that adding a route propagates to the forwardingRoutingTable or perhaps the Route Manager should handle that? -}) diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/peer.test.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/peer.test.ts deleted file mode 100644 index 745948bcbc..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/peer.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Peer } from '../ilp-route-manager/peer' -import { IncomingRoute } from '../types/routing' - -describe('peer', function () { - let peer: Peer - - beforeEach(function () { - peer = new Peer({ - peerId: 'harry', - relation: 'peer' - }) - }) - - test('can insert a route', function () { - const incomingRoute: IncomingRoute = { - peer: 'harry', - prefix: 'g.harry', - path: [] - } - expect(peer.getPrefix('g.harry')).not.toBeDefined() - - peer.insertRoute(incomingRoute) - - expect(peer.getPrefix('g.harry')).toEqual(incomingRoute) - }) - - test('can delete a route', function () { - const incomingRoute: IncomingRoute = { - peer: 'harry', - prefix: 'g.harry', - path: [] - } - peer.insertRoute(incomingRoute) - expect(peer.getPrefix('g.harry')).toBeDefined() - - peer.deleteRoute('g.harry') - - expect(peer.getPrefix('g.harry')).not.toBeDefined() - }) - - test('can get a route', function () { - peer.insertRoute({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - peer.insertRoute({ - peer: 'harry', - prefix: 'g.sally', - path: [] - }) - - expect(peer.getPrefix('g.harry')).toEqual({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - expect(peer.getPrefix('g.sally')).toEqual({ - peer: 'harry', - prefix: 'g.sally', - path: [] - }) - }) -}) diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/route-manager.test.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/route-manager.test.ts deleted file mode 100644 index d12a40be5e..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/test/route-manager.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Router } from '..' -import { RouteManager } from '../ilp-route-manager' -import { Peer } from '../ilp-route-manager/peer' - -describe('ilp-route-manager', function () { - let router: Router - - beforeEach(function () { - router = new Router() - router.setOwnAddress('test.rafiki') - }) - - describe('instantiation', function () { - test('can be instantiated', function () { - const routeManager = new RouteManager(router) - - expect(routeManager).toBeInstanceOf(RouteManager) - }) - }) - - describe('peer', function () { - test('can add a peer', function () { - const routeManager = new RouteManager(router) - - routeManager.addPeer('harry', 'peer') - - const peer = routeManager.getPeer('harry') - expect(routeManager.getPeer('harry')).toBeDefined() - expect(peer).toBeInstanceOf(Peer) - }) - - test('can remove a peer', function () { - const routeManager = new RouteManager(router) - - routeManager.removePeer('harry') - - expect(routeManager.getPeer('harry')).not.toBeDefined() - }) - - test('can get all peers', function () { - const routeManager = new RouteManager(router) - - routeManager.addPeer('harry', 'peer') - - const peers = routeManager.getPeerList() - expect(peers).toEqual(['harry']) - }) - }) - - describe('route', function () { - let routeManager: RouteManager - let peer: Peer | undefined - - beforeEach(function () { - routeManager = new RouteManager(router) - routeManager.addPeer('harry', 'peer') - peer = routeManager.getPeer('harry') - }) - - describe('adding', function () { - test('adding a route adds it to peer routing table', function () { - routeManager.addRoute({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - - const route = peer!.getPrefix('g.harry') - - expect(route).toEqual({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - }) - - test('adding a better route adds it to the routingTable', function () { - routeManager.addPeer('mary', 'child') - routeManager.addRoute({ - peer: 'harry', - prefix: 'g.nick', - path: ['g.potter'] - }) - - routeManager.addRoute({ - peer: 'mary', - prefix: 'g.nick', - path: [] - }) - - const nextHop = router.nextHop('g.nick') - expect(nextHop).toEqual('mary') - }) - - test('adding a worse route does not update routing table', function () { - routeManager.addPeer('mary', 'child') - routeManager.addRoute({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - - routeManager.addRoute({ - peer: 'mary', - prefix: 'g.harry', - path: ['g.turtle'] - }) - - const nextHop = router.nextHop('g.harry') - expect(nextHop).toEqual('harry') - }) - }) - - // Section for testing weighting stuff - describe('weighting', function () { - /* TODO */ - }) - - test('removing a route removes from peer routing table', function () { - routeManager.addRoute({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - - routeManager.removeRoute('harry', 'g.harry') - - const route = peer!.getPrefix('g.harry') - expect(route).not.toBeDefined() - }) - - test('does not add a route for a peer that does not exist', function () { - routeManager.addRoute({ - peer: 'mary', - prefix: 'g.harry', - path: [] - }) - - const nextHop = router.getRoutingTable().get('g.harry') - expect(nextHop).not.toBeDefined() - }) - - test('removing a peer should remove all its routes from the routing table', function () { - routeManager.addRoute({ - peer: 'harry', - prefix: 'g.harry', - path: [] - }) - expect(router.getRoutingTable().get('g.harry')).toBeDefined() - - routeManager.removePeer('harry') - - expect(router.getRoutingTable().get('g.harry')).not.toBeDefined() - }) - }) -}) diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/index.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/index.ts deleted file mode 100644 index 6a20f3c982..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './relation' -export * from './routing' diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/relation.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/relation.ts deleted file mode 100644 index fc96b745fa..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/relation.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type Relation = 'parent' | 'child' | 'peer' | 'local' - -export function getRelationPriority(relation: Relation): number { - return { - parent: 0, - peer: 1, - child: 2, - local: 3 - }[relation] -} diff --git a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/routing.ts b/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/routing.ts deleted file mode 100644 index b5c15bcad5..0000000000 --- a/packages/backend/src/payment-method/ilp/connector/ilp-routing/types/routing.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface IncomingRoute { - peer: string - prefix: string - path: string[] - weight?: number - auth?: Buffer -} - -export interface Route { - nextHop: string - path: string[] - weight?: number - auth?: Buffer -} - -export interface BroadcastRoute extends Route { - prefix: string -} diff --git a/packages/backend/src/payment-method/ilp/connector/index.ts b/packages/backend/src/payment-method/ilp/connector/index.ts index 05a3091439..9114649b58 100644 --- a/packages/backend/src/payment-method/ilp/connector/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/index.ts @@ -29,6 +29,7 @@ import { import { TelemetryService } from '../../../telemetry/service' import { TenantSettingService } from '../../../tenants/settings/service' import { IAppConfig } from '../../../config/app' +import { createIlpTimingMiddleware } from './core/middleware/ilp-timing' interface ServiceDependencies extends BaseService { config: IAppConfig @@ -58,7 +59,6 @@ export async function createConnectorService({ }: ServiceDependencies): Promise { return createApp( { - //router: router, logger: logger.child({ service: 'ConnectorService' }), @@ -73,9 +73,13 @@ export async function createConnectorService({ tenantSettingService }, compose([ + // ILP packet processing time (must be first to measure entire chain) + createIlpTimingMiddleware(), + // Incoming Rules createIncomingErrorHandlerMiddleware(ilpAddress), createStreamAddressMiddleware(), + createAccountMiddleware(), createIncomingMaxPacketAmountMiddleware(), createIncomingRateLimitMiddleware({}), diff --git a/packages/backend/src/payment-method/ilp/peer/model.ts b/packages/backend/src/payment-method/ilp/peer/model.ts index 813a7cdfd6..a76e45d99e 100644 --- a/packages/backend/src/payment-method/ilp/peer/model.ts +++ b/packages/backend/src/payment-method/ilp/peer/model.ts @@ -55,6 +55,8 @@ export class Peer public name?: string + public routes?: string[] + public readonly tenantId!: string public async onDebit( diff --git a/packages/backend/src/payment-method/ilp/peer/service.test.ts b/packages/backend/src/payment-method/ilp/peer/service.test.ts index 1b8d7b0533..ee3b9eab84 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.test.ts @@ -14,15 +14,18 @@ import { Pagination, SortOrder } from '../../../shared/baseModel' import { getPageTests } from '../../../shared/baseModel.test' import { createAsset } from '../../../tests/asset' import { createPeer } from '../../../tests/peer' +import { createTenant } from '../../../tests/tenant' import { truncateTables } from '../../../tests/tableManager' import { AccountingService } from '../../../accounting/service' import { TransferError } from '../../../accounting/errors' +import { RouterService } from '../connector/ilp-routing/service' describe('Peer Service', (): void => { let deps: IocContract let appContainer: TestContainer let peerService: PeerService let accountingService: AccountingService + let routerService: RouterService let asset: Asset let tenantId: string @@ -50,6 +53,7 @@ describe('Peer Service', (): void => { appContainer = await createTestApp(deps) peerService = await deps.use('peerService') accountingService = await deps.use('accountingService') + routerService = await deps.use('routerService') tenantId = Config.operatorTenantId }) @@ -59,6 +63,8 @@ describe('Peer Service', (): void => { afterEach(async (): Promise => { await truncateTables(deps) + const staticRoutesStore = await deps.use('staticRoutesStore') + await staticRoutesStore.deleteAll() }) afterAll(async (): Promise => { @@ -99,6 +105,14 @@ describe('Peer Service', (): void => { const retrievedPeer = await peerService.get(peer.id, peer.tenantId) if (!retrievedPeer) throw new Error('peer not found') expect(retrievedPeer).toEqual(peer) + + const nextHop = await routerService.getNextHop( + options.staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + expect(nextHop).toBe(peer.id) } ) @@ -118,6 +132,14 @@ describe('Peer Service', (): void => { const retrievedPeer = await peerService.get(peer.id, peer.tenantId) if (!retrievedPeer) throw new Error('peer not found') expect(retrievedPeer).toEqual(peer) + + const nextHop = await routerService.getNextHop( + options.staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + expect(nextHop).toBe(peer.id) }) test('Creating a peer creates a liquidity account', async (): Promise => { @@ -234,6 +256,84 @@ describe('Peer Service', (): void => { peerService.create({ ...options, tenantId: uuid() }) ).resolves.toEqual(PeerError.UnknownAsset) }) + + test('Create peer with custom routes', async (): Promise => { + const staticIlpAddress = 'test.peer1' + const customRoutes = ['g.custom1', 'g.custom2'] + const options = randomPeer({ + staticIlpAddress, + routes: customRoutes + }) + + const peer = await peerService.create(options) + assert.ok(!isPeerError(peer)) + + const nextHop1 = await routerService.getNextHop( + staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.custom1', + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop3 = await routerService.getNextHop( + 'g.custom2', + peer.tenantId, + undefined, + peer.assetId + ) + + expect(nextHop1).toBe(peer.id) + expect(nextHop2).toBe(peer.id) + expect(nextHop3).toBe(peer.id) + }) + + test('Isolate routes between tenants', async (): Promise => { + const staticIlpAddress = 'test.peer1' + + const tenant1 = await createTenant(deps) + const tenant2 = await createTenant(deps) + + const asset1 = await createAsset(deps, { tenantId: tenant1.id }) + const asset2 = await createAsset(deps, { tenantId: tenant2.id }) + + const options1 = randomPeer({ + staticIlpAddress, + tenantId: tenant1.id, + assetId: asset1.id + }) + const options2 = randomPeer({ + staticIlpAddress, + tenantId: tenant2.id, + assetId: asset2.id + }) + + const peer1 = await peerService.create(options1) + const peer2 = await peerService.create(options2) + + assert.ok(!isPeerError(peer1)) + assert.ok(!isPeerError(peer2)) + + const nextHop1 = await routerService.getNextHop( + staticIlpAddress, + tenant1.id, + undefined, + peer1.assetId + ) + const nextHop2 = await routerService.getNextHop( + staticIlpAddress, + tenant2.id, + undefined, + peer2.assetId + ) + + expect(nextHop1).toBe(peer1.id) + expect(nextHop2).toBe(peer2.id) + }) }) describe('Update Peer', (): void => { @@ -274,6 +374,14 @@ describe('Peer Service', (): void => { await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( peerOrError ) + + const nextHop = await routerService.getNextHop( + staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + expect(nextHop).toBe(peer.id) } ) @@ -374,6 +482,189 @@ describe('Peer Service', (): void => { peer ) }) + + test('Updates peer routes in router service', async (): Promise => { + const peer = await createPeer(deps) + const newRoutes = ['g.new1', 'g.new2'] + + const updateOptions: UpdateOptions = { + id: peer.id, + routes: newRoutes, + tenantId: peer.tenantId + } + + const updatedPeer = await peerService.update(updateOptions) + assert.ok(!isPeerError(updatedPeer)) + + const nextHop1 = await routerService.getNextHop( + 'g.new1', + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.new2', + peer.tenantId, + undefined, + peer.assetId + ) + + expect(nextHop1).toBe(peer.id) + expect(nextHop2).toBe(peer.id) + }) + + test('Updates peer static ILP address and routes', async (): Promise => { + const peer = await createPeer(deps) + const newStaticIlpAddress = 'test.newpeer' + const newRoutes = ['g.new1', 'g.new2'] + + const updateOptions: UpdateOptions = { + id: peer.id, + staticIlpAddress: newStaticIlpAddress, + routes: newRoutes, + tenantId: peer.tenantId + } + const removeSpy = jest.spyOn(routerService, 'removeStaticRoute') + const addSpy = jest.spyOn(routerService, 'addStaticRoute') + + const updatedPeer = await peerService.update(updateOptions) + assert.ok(!isPeerError(updatedPeer)) + + const nextHop1 = await routerService.getNextHop( + newStaticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.new1', + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop3 = await routerService.getNextHop( + 'g.new2', + peer.tenantId, + undefined, + peer.assetId + ) + + expect(nextHop1).toBe(peer.id) + expect(nextHop2).toBe(peer.id) + expect(nextHop3).toBe(peer.id) + + // Ensure previous routes were cleared before syncing new ones + expect(removeSpy).toHaveBeenCalledWith( + peer.staticIlpAddress, + peer.id, + peer.tenantId, + peer.assetId + ) + expect(addSpy).toHaveBeenCalledWith( + newStaticIlpAddress, + peer.id, + peer.tenantId, + peer.assetId + ) + expect(addSpy).toHaveBeenCalledWith( + 'g.new1', + peer.id, + peer.tenantId, + peer.assetId + ) + expect(addSpy).toHaveBeenCalledWith( + 'g.new2', + peer.id, + peer.tenantId, + peer.assetId + ) + + const firstAddCallOrder = addSpy.mock.invocationCallOrder[0] + const lastRemoveCallOrder = + removeSpy.mock.invocationCallOrder[ + removeSpy.mock.invocationCallOrder.length - 1 + ] + expect(lastRemoveCallOrder).toBeLessThan(firstAddCallOrder) + + removeSpy.mockRestore() + addSpy.mockRestore() + }) + + test('Clears routes when routes array is empty', async (): Promise => { + const peer = await createPeer(deps, { + routes: ['g.custom1', 'g.custom2'] + }) + + const updateOptions: UpdateOptions = { + id: peer.id, + routes: [], + tenantId: peer.tenantId + } + + const updatedPeer = await peerService.update(updateOptions) + assert.ok(!isPeerError(updatedPeer)) + + const nextHop1 = await routerService.getNextHop( + peer.staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.custom1', + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop3 = await routerService.getNextHop( + 'g.custom2', + peer.tenantId, + undefined, + peer.assetId + ) + + expect(nextHop1).toBe(peer.id) + expect(nextHop2).toBeUndefined() + expect(nextHop3).toBeUndefined() + }) + + test('Maintains tenant isolation during route updates', async (): Promise => { + const tenant1 = await createTenant(deps) + const tenant2 = await createTenant(deps) + + const peer1 = await createPeer(deps, { + staticIlpAddress: 'test.shared', + tenantId: tenant1.id + }) + const peer2 = await createPeer(deps, { + staticIlpAddress: 'test.shared', + tenantId: tenant2.id + }) + + const updateOptions: UpdateOptions = { + id: peer1.id, + routes: ['g.tenant1only'], + tenantId: peer1.tenantId + } + + await peerService.update(updateOptions) + + const nextHop1 = await routerService.getNextHop( + 'g.tenant1only', + tenant1.id, + undefined, + peer1.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.tenant1only', + tenant2.id, + undefined, + peer2.assetId + ) + + expect(nextHop1).toBe(peer1.id) + expect(nextHop2).toBeUndefined() + }) }) describe('Get Peer By ILP Address', (): void => { @@ -424,19 +715,25 @@ describe('Peer Service', (): void => { assetId: asset.id }) - const secondAsset = await createAsset(deps) + const secondAsset = await createAsset(deps, { tenantId: asset.tenantId }) const peerWithSecondAsset = await createPeer(deps, { staticIlpAddress, assetId: secondAsset.id }) await expect( - peerService.getByDestinationAddress('test.rafiki', tenantId, asset.id) + peerService.getByDestinationAddress( + 'test.rafiki', + tenantId, + undefined, + asset.id + ) ).resolves.toEqual(peer) await expect( peerService.getByDestinationAddress( 'test.rafiki', tenantId, + undefined, secondAsset.id ) ).resolves.toEqual(peerWithSecondAsset) @@ -522,6 +819,70 @@ describe('Peer Service', (): void => { peerService.delete(peer.id, peer.tenantId) ).resolves.toBeUndefined() }) + + test('Removes peer routes from router service on deletion', async (): Promise => { + const peer = await createPeer(deps, { + routes: ['g.custom1', 'g.custom2'] + }) + + const deletedPeer = await peerService.delete(peer.id, peer.tenantId) + expect(deletedPeer).toEqual(peer) + + const nextHop1 = await routerService.getNextHop( + peer.staticIlpAddress, + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'g.custom1', + peer.tenantId, + undefined, + peer.assetId + ) + const nextHop3 = await routerService.getNextHop( + 'g.custom2', + peer.tenantId, + undefined, + peer.assetId + ) + + expect(nextHop1).toBeUndefined() + expect(nextHop2).toBeUndefined() + expect(nextHop3).toBeUndefined() + }) + + test('Maintains tenant isolation during deletion', async (): Promise => { + const tenant1 = await createTenant(deps) + const tenant2 = await createTenant(deps) + + const peer1 = await createPeer(deps, { + staticIlpAddress: 'test.shared', + tenantId: tenant1.id + }) + const peer2 = await createPeer(deps, { + staticIlpAddress: 'test.shared', + tenantId: tenant2.id + }) + + await peerService.delete(peer1.id, peer1.tenantId) + + const nextHop1 = await routerService.getNextHop( + 'test.shared', + tenant1.id, + undefined, + peer1.assetId + ) + const nextHop2 = await routerService.getNextHop( + 'test.shared', + tenant2.id, + undefined, + peer2.assetId + ) + + expect(nextHop1).toBeUndefined() + expect(nextHop2).toBe(peer2.id) + }) }) describe('Deposit Liquidity', (): void => { diff --git a/packages/backend/src/payment-method/ilp/peer/service.ts b/packages/backend/src/payment-method/ilp/peer/service.ts index 6f16dd1fed..6847009bea 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.ts @@ -2,7 +2,6 @@ import { ForeignKeyViolationError, UniqueViolationError, NotFoundError, - raw, Transaction, TransactionOrKnex } from 'objection' @@ -22,7 +21,7 @@ import { BaseService } from '../../../shared/baseService' import { isValidHttpUrl } from '../../../shared/utils' import { v4 as uuid } from 'uuid' import { TransferError } from '../../../accounting/errors' -import PrefixMap from '../connector/ilp-routing/lib/prefix-map' +import { RouterService } from '../connector/ilp-routing/service' export interface HttpOptions { incoming?: { @@ -41,6 +40,7 @@ export type Options = { name?: string liquidityThreshold?: bigint initialLiquidity?: bigint + routes?: string[] } export type CreateOptions = Options & { @@ -67,6 +67,7 @@ export interface PeerService { getByDestinationAddress( address: string, tenantId: string, + incomingPeerId?: string, assetId?: string ): Promise getByIncomingToken(token: string): Promise @@ -86,6 +87,7 @@ interface ServiceDependencies extends BaseService { assetService: AssetService httpTokenService: HttpTokenService knex: TransactionOrKnex + routerService: RouterService } export async function createPeerService({ @@ -93,7 +95,8 @@ export async function createPeerService({ knex, accountingService, assetService, - httpTokenService + httpTokenService, + routerService }: ServiceDependencies): Promise { const log = logger.child({ service: 'PeerService' @@ -103,14 +106,26 @@ export async function createPeerService({ knex, accountingService, assetService, - httpTokenService + httpTokenService, + routerService } return { get: (id, tenantId) => getPeer(deps, id, tenantId), create: (options) => createPeer(deps, options), update: (options) => updatePeer(deps, options), - getByDestinationAddress: (destinationAddress, tenantId, assetId) => - getPeerByDestinationAddress(deps, destinationAddress, tenantId, assetId), + getByDestinationAddress: ( + destinationAddress, + tenantId, + incomingPeerId, + assetId + ) => + getPeerByDestinationAddress( + deps, + destinationAddress, + tenantId, + incomingPeerId, + assetId + ), getByIncomingToken: (token) => getPeerByIncomingToken(deps, token), getPage: (pagination?, sortOrder?, tenantId?) => getPeersPage(deps, pagination, sortOrder, tenantId), @@ -155,6 +170,11 @@ async function createPeer( try { return await Peer.transaction(deps.knex, async (trx) => { + const routes = [options.staticIlpAddress] + if (options.routes) { + routes.push(...options.routes) + } + const peer = await Peer.query(trx).insertAndFetch({ assetId: options.assetId, http: options.http, @@ -162,7 +182,8 @@ async function createPeer( staticIlpAddress: options.staticIlpAddress, name: options.name, liquidityThreshold: options.liquidityThreshold, - tenantId: asset.tenantId + tenantId: asset.tenantId, + routes }) peer.asset = asset @@ -204,6 +225,8 @@ async function createPeer( } } + await syncPeerRoutes(deps, peer) + return peer }) } catch (err) { @@ -244,6 +267,11 @@ async function updatePeer( try { return await Peer.transaction(deps.knex, async (trx) => { + const existingPeer = await Peer.query(trx).findById(options.id) + if (!existingPeer) { + return PeerError.UnknownPeer + } + if (options.http?.incoming) { await deps.httpTokenService.deleteByPeer(options.id, trx) const err = await addIncomingHttpTokens({ @@ -257,9 +285,21 @@ async function updatePeer( } } + const updateData = { ...options } + + if (options.routes !== undefined) { + const staticIlpAddress = + options.staticIlpAddress ?? existingPeer.staticIlpAddress + updateData.routes = [staticIlpAddress, ...options.routes] + } + const peer = await Peer.query(trx) - .patchAndFetchById(options.id, options) + .patchAndFetchById(options.id, updateData) .throwIfNotFound() + + await clearPeerRoutes(deps, existingPeer) + await syncPeerRoutes(deps, peer) + const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset return peer @@ -335,65 +375,22 @@ async function addIncomingHttpTokens({ async function getPeerByDestinationAddress( deps: ServiceDependencies, destinationAddress: string, - tenantId?: string, + tenantId: string, + incomingPeerId?: string, assetId?: string ): Promise { - // This query does the equivalent of the following regex - // for `staticIlpAddress`s in the accounts table: - // new RegExp('^' + staticIlpAddress + '($|\\.)')).test(destinationAddress) - const peerQuery = Peer.query(deps.knex) - .where( - raw('?', [destinationAddress]), - 'like', - // "_" is a Postgres pattern wildcard (matching any one character), and must be escaped. - // See: https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE - raw("REPLACE(REPLACE(??, '_', '\\\\_'), '%', '\\\\%') || '%'", [ - 'staticIlpAddress' - ]) - ) - .andWhere((builder) => { - builder - .where( - raw('length(??)', ['staticIlpAddress']), - destinationAddress.length - ) - .orWhere( - raw('substring(?, length(??)+1, 1)', [ - destinationAddress, - 'staticIlpAddress' - ]), - '.' - ) - }) - - if (assetId) { - peerQuery.andWhere('assetId', assetId) - } - - if (tenantId) { - peerQuery.andWhere('tenantId', tenantId) - } - - const peers = await peerQuery - const peer = getByLongestPrefixMatch(peers, destinationAddress) - - if (peer) { - const asset = await deps.assetService.get(peer.assetId) - if (asset) peer.asset = asset - } - return peer || undefined -} - -function getByLongestPrefixMatch( - peers: Peer[], - destinationAddress: string -): Peer | undefined { - const map = new PrefixMap() - for (const peer of peers) { - map.insert(peer.staticIlpAddress, peer) + const nextHop = await deps.routerService.getNextHop( + destinationAddress, + tenantId, + incomingPeerId, + assetId + ) + if (nextHop) { + const peer = await getPeer(deps, nextHop, tenantId) + if (peer) { + return peer + } } - - return map.resolve(destinationAddress) } async function getPeerByIncomingToken( @@ -449,8 +446,48 @@ async function deletePeer( .returning('*') .first() if (peer) { + await clearPeerRoutes(deps, peer) const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset } return peer } + +async function syncPeerRoutes( + deps: ServiceDependencies, + peer: Peer +): Promise { + // Always add the static ILP address + await deps.routerService.addStaticRoute( + peer.staticIlpAddress, + peer.id, + peer.tenantId, + peer.assetId + ) + + if (peer.routes && peer.routes.length > 0) { + for (const route of peer.routes) { + await deps.routerService.addStaticRoute( + route, + peer.id, + peer.tenantId, + peer.assetId + ) + } + } +} + +async function clearPeerRoutes( + deps: ServiceDependencies, + peer: Peer +): Promise { + const routes = peer.routes || [peer.staticIlpAddress] + for (const route of routes) { + await deps.routerService.removeStaticRoute( + route, + peer.id, + peer.tenantId, + peer.assetId + ) + } +} diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 633f01ea71..86cec16173 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -339,6 +339,13 @@ async function pay( const destination = resolveIlpDestination(receiver) + const stopTimerRoundTrip = deps.telemetry.startTimer( + 'ilp_payment_round_trip_ms', + { + description: 'ILP payment round trip time from sender to receiver', + callName: 'Pay:pay' + } + ) try { const receipt = await Pay.pay({ plugin, destination, quote }) @@ -369,6 +376,8 @@ async function pay( retryable: canRetryError(err as Error | Pay.PaymentError) }) } finally { + stopTimerRoundTrip() + try { await Pay.closeConnection(plugin, destination) } catch (error) { diff --git a/packages/backend/src/tests/peer.ts b/packages/backend/src/tests/peer.ts index 04aa1c1cf3..7093fa22d9 100644 --- a/packages/backend/src/tests/peer.ts +++ b/packages/backend/src/tests/peer.ts @@ -35,6 +35,13 @@ export async function createPeer( if (options.liquidityThreshold) { peerOptions.liquidityThreshold = options.liquidityThreshold } + if (options.tenantId) { + peerOptions.tenantId = options.tenantId + } + peerOptions.routes = [ + ...(options.routes || []), + peerOptions.staticIlpAddress + ].filter((route, index, arr) => arr.indexOf(route) === index) const peerService = await deps.use('peerService') const peer = await peerService.create(peerOptions) if (isPeerError(peer)) { diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index d82d4b063e..3c16bbe27e 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -312,6 +312,8 @@ export type CreatePeerInput = { maxPacketAmount?: InputMaybe; /** Internal name of the peer. */ name?: InputMaybe; + /** Routes for the peer. */ + routes?: InputMaybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['input']; }; @@ -1166,6 +1168,8 @@ export type Peer = Model & { maxPacketAmount?: Maybe; /** Public name for the peer. */ name?: Maybe; + /** Routes for the peer. */ + routes?: Maybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; /** Unique identifier of the tenant associated with the peer. */ @@ -1604,6 +1608,8 @@ export type UpdatePeerInput = { maxPacketAmount?: InputMaybe; /** New public name for the peer. */ name?: InputMaybe; + /** New routes for the peer. */ + routes?: InputMaybe>; /** New ILP address for the peer. */ staticIlpAddress?: InputMaybe; }; @@ -2528,6 +2534,7 @@ export type PeerResolvers, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + routes?: Resolver>, ParentType, ContextType>; staticIlpAddress?: Resolver; tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index bfd2e00bb3..faf5124256 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -312,6 +312,8 @@ export type CreatePeerInput = { maxPacketAmount?: InputMaybe; /** Internal name of the peer. */ name?: InputMaybe; + /** Routes for the peer. */ + routes?: InputMaybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['input']; }; @@ -1166,6 +1168,8 @@ export type Peer = Model & { maxPacketAmount?: Maybe; /** Public name for the peer. */ name?: Maybe; + /** Routes for the peer. */ + routes?: Maybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; /** Unique identifier of the tenant associated with the peer. */ @@ -1604,6 +1608,8 @@ export type UpdatePeerInput = { maxPacketAmount?: InputMaybe; /** New public name for the peer. */ name?: InputMaybe; + /** New routes for the peer. */ + routes?: InputMaybe>; /** New ILP address for the peer. */ staticIlpAddress?: InputMaybe; }; @@ -2528,6 +2534,7 @@ export type PeerResolvers, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + routes?: Resolver>, ParentType, ContextType>; staticIlpAddress?: Resolver; tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/mock-account-service-lib/src/requesters.ts b/packages/mock-account-service-lib/src/requesters.ts index 101387418c..7504e74a3c 100644 --- a/packages/mock-account-service-lib/src/requesters.ts +++ b/packages/mock-account-service-lib/src/requesters.ts @@ -17,7 +17,9 @@ import { type Asset, type TenantMutationResponse, type CreateTenantInput, - TenantSettingKey + TenantSettingKey, + type UpdatePeerMutationResponse, + type UpdatePeerInput } from './generated/graphql' import { v4 as uuid } from 'uuid' @@ -35,9 +37,11 @@ export function createRequesters( outgoingEndpoint: string, assetId: string, name: string, + routes: string[], liquidityThreshold: number, incomingTokens: string[], - outgoingToken: string + outgoingToken: string, + maxPacketAmount?: number ) => Promise createAutoPeer: ( peerUrl: string, @@ -77,6 +81,7 @@ export function createRequesters( staticIlpAddress: string, assetId: string ) => Promise + updatePeer: (input: UpdatePeerInput) => Promise } { return { createAsset: (code, scale, liquidityThreshold) => @@ -86,9 +91,11 @@ export function createRequesters( outgoingEndpoint, assetId, name, + routes, liquidityThreshold, incomingToken, - outgoingToken + outgoingToken, + maxPacketAmount ) => createPeer( apolloClient, @@ -97,9 +104,11 @@ export function createRequesters( outgoingEndpoint, assetId, name, + routes, liquidityThreshold, incomingToken, - outgoingToken + outgoingToken, + maxPacketAmount ), createAutoPeer: (peerUrl, assetId) => createAutoPeer(apolloClient, logger, peerUrl, assetId), @@ -123,7 +132,9 @@ export function createRequesters( getAssetByCodeAndScale(apolloClient, code, scale), getWalletAddressByURL: (url) => getWalletAddressByURL(apolloClient, url), getPeerByAddressAndAsset: (staticIlpAddress, assetId) => - getPeerByAddressAndAsset(apolloClient, staticIlpAddress, assetId) + getPeerByAddressAndAsset(apolloClient, staticIlpAddress, assetId), + updatePeer: (input: UpdatePeerInput) => + updatePeer(apolloClient, logger, input) } } @@ -223,9 +234,11 @@ export async function createPeer( outgoingEndpoint: string, assetId: string, name: string, + routes: string[], liquidityThreshold: number, incomingTokens: string[], - outgoingToken: string + outgoingToken: string, + maxPacketAmount?: number ): Promise { const createPeerMutation = gql` mutation CreatePeer($input: CreatePeerInput!) { @@ -248,9 +261,16 @@ export async function createPeer( }, assetId, name, - liquidityThreshold + liquidityThreshold, + maxPacketAmount, + routes } } + + if (maxPacketAmount) { + createPeerInput.input.maxPacketAmount = maxPacketAmount + } + return apolloClient .mutate({ mutation: createPeerMutation, @@ -265,6 +285,35 @@ export async function createPeer( }) } +export async function updatePeer( + apolloClient: ApolloClient, + logger: Logger, + input: UpdatePeerInput +): Promise { + const updatePeerMutation = gql` + mutation UpdatePeer($input: UpdatePeerInput!) { + updatePeer(input: $input) { + peer { + id + } + } + } + ` + + return apolloClient + .mutate({ + mutation: updatePeerMutation, + variables: { input } + }) + .then(({ data }): UpdatePeerMutationResponse => { + logger.debug(data) + if (!data?.updatePeer) { + throw new Error('Data was empty') + } + return data.updatePeer + }) +} + export async function createAutoPeer( apolloClient: ApolloClient, logger: Logger, diff --git a/packages/mock-account-service-lib/src/seed.ts b/packages/mock-account-service-lib/src/seed.ts index 637b942934..bf8d717583 100644 --- a/packages/mock-account-service-lib/src/seed.ts +++ b/packages/mock-account-service-lib/src/seed.ts @@ -62,6 +62,7 @@ export async function setupFromSeed( depositAssetLiquidity, setFee, createPeer, + updatePeer, depositPeerLiquidity, createAutoPeer, createWalletAddress, @@ -102,12 +103,30 @@ export async function setupFromSeed( : config.publicHost for (const peer of config.seed.peers) { + let peerStaticIlpAddress + // If peer needs to be unreachable, we need to use the staticIlpAddress without the `unreachable` segment in order to get the correct existing peer if any + const isUnreachablePeer = peer.peerIlpAddress.includes('unreachable.') + if (isUnreachablePeer) { + peerStaticIlpAddress = peer.peerIlpAddress.replace('unreachable.', '') + } else { + peerStaticIlpAddress = peer.peerIlpAddress + } + const existingPeer = await getPeerByAddressAndAsset( - peer.peerIlpAddress, + peerStaticIlpAddress, assets[peeringAsset].id ) - if (existingPeer && existingPeer.staticIlpAddress === peer.peerIlpAddress) { + if ( + existingPeer && + existingPeer.staticIlpAddress === peerStaticIlpAddress + ) { + // Needed for refreshing routes and staticIlpAddress when changed in seed (when going back and forth from multihop mode) + await updatePeer({ + id: existingPeer.id, + staticIlpAddress: peer.peerIlpAddress, + routes: peer.routes || [] + }) continue } @@ -116,9 +135,11 @@ export async function setupFromSeed( peer.peerUrl, assets[peeringAsset].id, peer.name, + peer.routes || [], peer.liquidityThreshold, peer.tokens.incoming, - peer.tokens.outgoing + peer.tokens.outgoing, + peer.maxPacketAmount ).then((response) => response.peer || null) if (!newPeer) { diff --git a/packages/mock-account-service-lib/src/types.ts b/packages/mock-account-service-lib/src/types.ts index 437c3a4469..a81b20f327 100644 --- a/packages/mock-account-service-lib/src/types.ts +++ b/packages/mock-account-service-lib/src/types.ts @@ -9,9 +9,13 @@ interface Asset { export interface Peering { liquidityThreshold: number + incomingToken: string + outgoingToken: string peerUrl: string peerIlpAddress: string + routes: string[] initialLiquidity: string + maxPacketAmount?: number name: string tokens: { incoming: string[] diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index bfd2e00bb3..faf5124256 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -312,6 +312,8 @@ export type CreatePeerInput = { maxPacketAmount?: InputMaybe; /** Internal name of the peer. */ name?: InputMaybe; + /** Routes for the peer. */ + routes?: InputMaybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['input']; }; @@ -1166,6 +1168,8 @@ export type Peer = Model & { maxPacketAmount?: Maybe; /** Public name for the peer. */ name?: Maybe; + /** Routes for the peer. */ + routes?: Maybe>; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; /** Unique identifier of the tenant associated with the peer. */ @@ -1604,6 +1608,8 @@ export type UpdatePeerInput = { maxPacketAmount?: InputMaybe; /** New public name for the peer. */ name?: InputMaybe; + /** New routes for the peer. */ + routes?: InputMaybe>; /** New ILP address for the peer. */ staticIlpAddress?: InputMaybe; }; @@ -2528,6 +2534,7 @@ export type PeerResolvers, ParentType, ContextType>; maxPacketAmount?: Resolver, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; + routes?: Resolver>, ParentType, ContextType>; staticIlpAddress?: Resolver; tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn;