| 1 | package discovery |
| 2 | |
| 3 | // MOLS selection ranks relays using a GF(64)-based MOLS grid with a |
| 4 | // non-invasive adaptive partition over local load telemetry. |
| 5 | // |
| 6 | // Ordering Pipeline: |
| 7 | // 1. Filter: Apply ban, expiry, and protocol compatibility gates. |
| 8 | // 2. Extract: Keep the top fixed-depth deterministic MOLS candidates. |
| 9 | // 3. Partition: Move saturated relays behind active relays. |
| 10 | // 4. Preserve: Keep intra-tier MOLS order unchanged. |
| 11 | import ( |
| 12 | "math" |
| 13 | "slices" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/gosuda/portal-tunnel/v2/portal/telemetry" |
| 17 | ) |
| 18 | |
| 19 | const ( |
| 20 | molsOrder = 64 |
| 21 | molsMagicConstant = molsOrder*molsOrder + 1 // n^2+1 = 4097 |
| 22 | |
| 23 | molsBaseM1 uint8 = 3 |
| 24 | molsBaseM2 uint8 = 5 |
| 25 | molsVariantM1 uint8 = 7 |
| 26 | molsVariantM2 uint8 = 11 |
| 27 | |
| 28 | molsCongestionRTTThreshold = 500 * time.Millisecond |
| 29 | molsCVThreshold = 0.5 |
| 30 | molsFallbackRTTThreshold = 2 * time.Second |
| 31 | molsMinActiveNodes = 2 |
| 32 | defaultMaxActiveRelays = 3 |
| 33 | molsCandidateDepth = 8 |
| 34 | ) |
| 35 | |
| 36 | // gf64Mul performs multiplication in GF(2^6) with primitive polynomial x^6 + x + 1 (0x43). |
| 37 | func gf64Mul(a, b uint8) uint8 { |
| 38 | a &= 0x3f |
| 39 | b &= 0x3f |
| 40 | var r uint8 |
| 41 | for b != 0 { |
| 42 | if b&1 != 0 { |
| 43 | r ^= a |
| 44 | } |
| 45 | if a&0x20 != 0 { |
| 46 | a = ((a << 1) ^ 0x43) & 0x3f |
| 47 | } else { |
| 48 | a = (a << 1) & 0x3f |
| 49 | } |
| 50 | b >>= 1 |
| 51 | } |
| 52 | return r |
| 53 | } |
| 54 | |
| 55 | // gridOrderForSize returns the smallest supported MOLS grid order that can |
| 56 | // accommodate the relay pool size. |
| 57 | func gridOrderForSize(poolSize int) int { |
| 58 | if poolSize <= molsOrder { |
| 59 | return molsOrder |
| 60 | } |
| 61 | rem := poolSize % 32 |
| 62 | if rem == 0 { |
| 63 | return poolSize |
| 64 | } |
| 65 | return poolSize + (32 - rem) |
| 66 | } |
| 67 | |
| 68 | func molsScore(i, j, m1, m2, order int) int { |
| 69 | if order == molsOrder { |
| 70 | l1 := gf64Mul(uint8(m1), uint8(i)) ^ uint8(j) |
| 71 | l2 := gf64Mul(uint8(m2), uint8(i)) ^ uint8(j) |
| 72 | return int(l1)*order + int(l2) + 1 |
| 73 | } |
| 74 | return ((m1*i+j)%order)*order + ((m2*i + j) % order) + 1 |
| 75 | } |
| 76 | |
| 77 | func molsCongestionScore(i, j, m1, m2, order int) int { |
| 78 | return (order*order + 1) - molsScore(i, (order-1)-j, m1, m2, order) |
| 79 | } |
| 80 | |
| 81 | func hashToGF64(s string) uint8 { |
| 82 | var h uint32 = 2166136261 |
| 83 | for i := 0; i < len(s); i++ { |
| 84 | h ^= uint32(s[i]) |
| 85 | h *= 16777619 |
| 86 | } |
| 87 | return uint8(h & 0x3f) |
| 88 | } |
| 89 | |
| 90 | func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) { |
| 91 | var count int |
| 92 | var sum float64 |
| 93 | for _, state := range states { |
| 94 | if state.DiscoveryRTTAt.IsZero() { |
| 95 | continue |
| 96 | } |
| 97 | count++ |
| 98 | sum += float64(state.DiscoveryRTT) |
| 99 | } |
| 100 | if count == 0 { |
| 101 | return 0, 0 |
| 102 | } |
| 103 | avg := sum / float64(count) |
| 104 | if count == 1 { |
| 105 | return time.Duration(avg), 0 |
| 106 | } |
| 107 | var sq float64 |
| 108 | for _, state := range states { |
| 109 | if state.DiscoveryRTTAt.IsZero() { |
| 110 | continue |
| 111 | } |
| 112 | d := float64(state.DiscoveryRTT) - avg |
| 113 | sq += d * d |
| 114 | } |
| 115 | stddev := math.Sqrt(sq / float64(count)) |
| 116 | if avg > 0 { |
| 117 | cv = stddev / avg |
| 118 | } |
| 119 | return time.Duration(avg), cv |
| 120 | } |
| 121 | |
| 122 | func isRelayFallback(state RelayState) bool { |
| 123 | return !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > molsFallbackRTTThreshold |
| 124 | } |
| 125 | |
| 126 | type molsCandidate struct { |
| 127 | state RelayState |
| 128 | score int |
| 129 | seq int |
| 130 | } |
| 131 | |
| 132 | func betterMOLSCandidate(a, b molsCandidate) bool { |
| 133 | if a.score != b.score { |
| 134 | return a.score > b.score |
| 135 | } |
| 136 | if a.state.Confirmed != b.state.Confirmed { |
| 137 | return a.state.Confirmed |
| 138 | } |
| 139 | aURL := a.state.Descriptor.APIHTTPSAddr |
| 140 | bURL := b.state.Descriptor.APIHTTPSAddr |
| 141 | if aURL != bURL { |
| 142 | return aURL < bURL |
| 143 | } |
| 144 | return a.seq < b.seq |
| 145 | } |
| 146 | |
| 147 | func selectAggregate(states []RelayState) []RelayState { |
| 148 | out := make([]RelayState, 0, len(states)) |
| 149 | for _, state := range states { |
| 150 | if !state.Banned { |
| 151 | out = append(out, state) |
| 152 | } |
| 153 | } |
| 154 | return out |
| 155 | } |
| 156 | |
| 157 | func selectConfirmed(states []RelayState) []RelayState { |
| 158 | out := make([]RelayState, 0) |
| 159 | for _, state := range states { |
| 160 | if state.Confirmed { |
| 161 | out = append(out, state) |
| 162 | } |
| 163 | } |
| 164 | return out |
| 165 | } |
| 166 | |
| 167 | func rankRelayPool(autoPool []RelayState, localAddress string) []string { |
| 168 | if len(autoPool) == 0 { |
| 169 | return nil |
| 170 | } |
| 171 | |
| 172 | ingressIdx := hashToGF64(localAddress) |
| 173 | avgRTT, cv := molsRTTStats(autoPool) |
| 174 | congested := avgRTT > molsCongestionRTTThreshold |
| 175 | nonLinear := cv > molsCVThreshold |
| 176 | |
| 177 | m1, m2 := molsBaseM1, molsBaseM2 |
| 178 | if nonLinear { |
| 179 | m1, m2 = molsVariantM1, molsVariantM2 |
| 180 | } |
| 181 | |
| 182 | order := gridOrderForSize(len(autoPool)) |
| 183 | scoreFor := func(state RelayState) int { |
| 184 | candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr) |
| 185 | row := int(ingressIdx) % order |
| 186 | col := int(candidateIdx) % order |
| 187 | if congested { |
| 188 | return molsCongestionScore(row, col, int(m1), int(m2), order) |
| 189 | } |
| 190 | return molsScore(row, col, int(m1), int(m2), order) |
| 191 | } |
| 192 | |
| 193 | activeStates := make([]RelayState, 0, len(autoPool)) |
| 194 | fallbackStates := make([]RelayState, 0) |
| 195 | for _, state := range autoPool { |
| 196 | if isRelayFallback(state) { |
| 197 | fallbackStates = append(fallbackStates, state) |
| 198 | } else { |
| 199 | activeStates = append(activeStates, state) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | if len(activeStates) < molsMinActiveNodes && len(fallbackStates) > 0 { |
| 204 | slices.SortFunc(fallbackStates, func(a, b RelayState) int { |
| 205 | if a.DiscoveryRTT < b.DiscoveryRTT { |
| 206 | return -1 |
| 207 | } |
| 208 | if a.DiscoveryRTT > b.DiscoveryRTT { |
| 209 | return 1 |
| 210 | } |
| 211 | return 0 |
| 212 | }) |
| 213 | promote := min(molsMinActiveNodes-len(activeStates), len(fallbackStates)) |
| 214 | activeStates = append(activeStates, fallbackStates[:promote]...) |
| 215 | fallbackStates = fallbackStates[promote:] |
| 216 | } |
| 217 | |
| 218 | rankTier := func(states []RelayState) []string { |
| 219 | if len(states) == 0 { |
| 220 | return nil |
| 221 | } |
| 222 | var candidates [molsCandidateDepth]molsCandidate |
| 223 | count := 0 |
| 224 | for i, state := range states { |
| 225 | state.EvaluateSaturation() |
| 226 | candidate := molsCandidate{ |
| 227 | state: state, |
| 228 | score: scoreFor(state), |
| 229 | seq: i, |
| 230 | } |
| 231 | insertAt := count |
| 232 | for insertAt > 0 && betterMOLSCandidate(candidate, candidates[insertAt-1]) { |
| 233 | if insertAt < molsCandidateDepth { |
| 234 | candidates[insertAt] = candidates[insertAt-1] |
| 235 | } |
| 236 | insertAt-- |
| 237 | } |
| 238 | if insertAt >= molsCandidateDepth { |
| 239 | continue |
| 240 | } |
| 241 | candidates[insertAt] = candidate |
| 242 | if count < molsCandidateDepth { |
| 243 | count++ |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | tierOut := make([]string, 0, count) |
| 248 | for i := 0; i < count; i++ { |
| 249 | if !candidates[i].state.IsSaturated { |
| 250 | tierOut = append(tierOut, candidates[i].state.Descriptor.APIHTTPSAddr) |
| 251 | } |
| 252 | } |
| 253 | for i := 0; i < count; i++ { |
| 254 | if candidates[i].state.IsSaturated { |
| 255 | tierOut = append(tierOut, candidates[i].state.Descriptor.APIHTTPSAddr) |
| 256 | } |
| 257 | } |
| 258 | return tierOut |
| 259 | } |
| 260 | |
| 261 | activeURLs := rankTier(activeStates) |
| 262 | fallbackURLs := rankTier(fallbackStates) |
| 263 | return append(activeURLs, fallbackURLs...) |
| 264 | } |
| 265 | |
| 266 | // selectPriorityWithTrace is the telemetry-instrumented sibling of |
| 267 | // SelectPriority. It returns the same ordered relay list plus a SelectionTrace. |
| 268 | func selectPriorityWithTrace(states []RelayState, cs RouteState) ([]string, telemetry.SelectionTrace) { |
| 269 | start := time.Now() |
| 270 | now := start.UTC() |
| 271 | |
| 272 | trace := telemetry.SelectionTrace{ |
| 273 | Timestamp: start, |
| 274 | ClientHash: hashToGF64(cs.LocalAddress), |
| 275 | Mode: "priority", |
| 276 | PoolTotal: len(states), |
| 277 | Reasons: make(map[string]string), |
| 278 | } |
| 279 | |
| 280 | for _, state := range states { |
| 281 | if state.Banned { |
| 282 | url := state.Descriptor.APIHTTPSAddr |
| 283 | trace.Suppressed = append(trace.Suppressed, url) |
| 284 | trace.Reasons[url] = "banned" |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | selected := selectAggregate(states) |
| 289 | if len(selected) == 0 { |
| 290 | trace.SelectionTook = time.Since(start) |
| 291 | return nil, trace |
| 292 | } |
| 293 | |
| 294 | explicit := make([]string, 0) |
| 295 | autoPool := make([]RelayState, 0, len(selected)) |
| 296 | for _, state := range selected { |
| 297 | relayURL := state.Descriptor.APIHTTPSAddr |
| 298 | if slices.Contains(cs.ExplicitRelayURLs, relayURL) { |
| 299 | if state.hasObservedDescriptor() && state.Descriptor.ExpiresAt.After(now) { |
| 300 | if cs.RequireUDP && !state.Descriptor.SupportsUDP { |
| 301 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 302 | trace.Reasons[relayURL] = "require_udp" |
| 303 | continue |
| 304 | } |
| 305 | if cs.RequireTCP && !state.Descriptor.SupportsTCP { |
| 306 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 307 | trace.Reasons[relayURL] = "require_tcp" |
| 308 | continue |
| 309 | } |
| 310 | } |
| 311 | explicit = append(explicit, relayURL) |
| 312 | continue |
| 313 | } |
| 314 | if state.hasObservedDescriptor() { |
| 315 | if !state.Descriptor.ExpiresAt.After(now) { |
| 316 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 317 | trace.Reasons[relayURL] = "expired" |
| 318 | continue |
| 319 | } |
| 320 | if cs.RequireUDP && !state.Descriptor.SupportsUDP { |
| 321 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 322 | trace.Reasons[relayURL] = "require_udp" |
| 323 | continue |
| 324 | } |
| 325 | if cs.RequireTCP && !state.Descriptor.SupportsTCP { |
| 326 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 327 | trace.Reasons[relayURL] = "require_tcp" |
| 328 | continue |
| 329 | } |
| 330 | } |
| 331 | if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) { |
| 332 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 333 | trace.Reasons[relayURL] = "suppressed" |
| 334 | continue |
| 335 | } |
| 336 | autoPool = append(autoPool, state) |
| 337 | } |
| 338 | |
| 339 | avgRTT, cv := molsRTTStats(autoPool) |
| 340 | trace.AvgRTT = avgRTT |
| 341 | trace.CV = cv |
| 342 | congested := avgRTT > molsCongestionRTTThreshold |
| 343 | nonLinear := cv > molsCVThreshold |
| 344 | trace.Congested = congested |
| 345 | trace.NonLinear = nonLinear |
| 346 | |
| 347 | m1, m2 := molsBaseM1, molsBaseM2 |
| 348 | if nonLinear { |
| 349 | m1, m2 = molsVariantM1, molsVariantM2 |
| 350 | } |
| 351 | trace.M1, trace.M2 = m1, m2 |
| 352 | |
| 353 | active, fallbacks := traceFallbackPartition(autoPool) |
| 354 | trace.PoolEligible = len(autoPool) |
| 355 | trace.PoolFallback = len(fallbacks) |
| 356 | if len(active) < molsMinActiveNodes && len(fallbacks) > 0 { |
| 357 | promote := min(molsMinActiveNodes-len(active), len(fallbacks)) |
| 358 | fallbacks = fallbacks[promote:] |
| 359 | } |
| 360 | demotedURLs := relayURLSet(fallbacks) |
| 361 | |
| 362 | ingressIdx := hashToGF64(cs.LocalAddress) |
| 363 | order := gridOrderForSize(len(autoPool)) |
| 364 | for _, state := range autoPool { |
| 365 | candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr) |
| 366 | row := int(ingressIdx) % order |
| 367 | col := int(candidateIdx) % order |
| 368 | score := molsScore(row, col, int(m1), int(m2), order) |
| 369 | if congested { |
| 370 | score = molsCongestionScore(row, col, int(m1), int(m2), order) |
| 371 | } |
| 372 | trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{ |
| 373 | URL: state.Descriptor.APIHTTPSAddr, |
| 374 | Score: score, |
| 375 | Confirmed: state.Confirmed, |
| 376 | RTT: state.DiscoveryRTT, |
| 377 | Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr], |
| 378 | }) |
| 379 | } |
| 380 | |
| 381 | autoURLs := rankRelayPool(autoPool, cs.LocalAddress) |
| 382 | maxActiveRelays := cs.MaxActiveRelays |
| 383 | if maxActiveRelays <= 0 { |
| 384 | maxActiveRelays = defaultMaxActiveRelays |
| 385 | } |
| 386 | if len(autoURLs) > maxActiveRelays { |
| 387 | autoURLs = autoURLs[:maxActiveRelays] |
| 388 | } |
| 389 | result := append(explicit, autoURLs...) |
| 390 | trace.OutputURLs = result |
| 391 | trace.SelectionTook = time.Since(start) |
| 392 | return result, trace |
| 393 | } |
| 394 | |
| 395 | func traceFallbackPartition(autoPool []RelayState) ([]RelayState, []RelayState) { |
| 396 | active := make([]RelayState, 0, len(autoPool)) |
| 397 | fallbacks := make([]RelayState, 0) |
| 398 | for _, state := range autoPool { |
| 399 | if isRelayFallback(state) { |
| 400 | fallbacks = append(fallbacks, state) |
| 401 | } else { |
| 402 | active = append(active, state) |
| 403 | } |
| 404 | } |
| 405 | return active, fallbacks |
| 406 | } |
| 407 | |
| 408 | func relayURLSet(states []RelayState) map[string]bool { |
| 409 | out := make(map[string]bool, len(states)) |
| 410 | for _, state := range states { |
| 411 | out[state.Descriptor.APIHTTPSAddr] = true |
| 412 | } |
| 413 | return out |
| 414 | } |
| 415 | |
| 416 | // SelectPriority returns the ordered list of relay URLs for a client using the |
| 417 | // MOLS selection. It delegates to selectPriorityWithTrace and discards the trace. |
| 418 | func SelectPriority(states []RelayState, routeState RouteState) []string { |
| 419 | out, _ := selectPriorityWithTrace(states, routeState) |
| 420 | return out |
| 421 | } |
| 422 | |
| 423 | // selectMultiHopWithTrace is the telemetry-instrumented sibling of |
| 424 | // SelectMultiHop. It returns the same ordered relay list plus a SelectionTrace. |
| 425 | func selectMultiHopWithTrace(states []RelayState, cs RouteState) ([]string, telemetry.SelectionTrace) { |
| 426 | start := time.Now() |
| 427 | now := start.UTC() |
| 428 | |
| 429 | trace := telemetry.SelectionTrace{ |
| 430 | Timestamp: start, |
| 431 | ClientHash: hashToGF64(cs.LocalAddress), |
| 432 | Mode: "multihop", |
| 433 | PoolTotal: len(states), |
| 434 | Reasons: make(map[string]string), |
| 435 | } |
| 436 | |
| 437 | if cs.MultiHopDepth <= 1 { |
| 438 | trace.SelectionTook = time.Since(start) |
| 439 | return nil, trace |
| 440 | } |
| 441 | |
| 442 | for _, state := range states { |
| 443 | if state.Banned { |
| 444 | url := state.Descriptor.APIHTTPSAddr |
| 445 | trace.Suppressed = append(trace.Suppressed, url) |
| 446 | trace.Reasons[url] = "banned" |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | selected := selectAggregate(states) |
| 451 | if len(selected) == 0 { |
| 452 | trace.SelectionTook = time.Since(start) |
| 453 | return nil, trace |
| 454 | } |
| 455 | |
| 456 | autoPool := make([]RelayState, 0, len(selected)) |
| 457 | for _, state := range selected { |
| 458 | relayURL := state.Descriptor.APIHTTPSAddr |
| 459 | if cs.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP { |
| 460 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 461 | trace.Reasons[relayURL] = "require_udp" |
| 462 | continue |
| 463 | } |
| 464 | if cs.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP { |
| 465 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 466 | trace.Reasons[relayURL] = "require_tcp" |
| 467 | continue |
| 468 | } |
| 469 | if !state.hasObservedDescriptor() { |
| 470 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 471 | trace.Reasons[relayURL] = "no_descriptor" |
| 472 | continue |
| 473 | } |
| 474 | if !state.Descriptor.ExpiresAt.After(now) { |
| 475 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 476 | trace.Reasons[relayURL] = "expired" |
| 477 | continue |
| 478 | } |
| 479 | if !state.Descriptor.HasOverlayPeer() { |
| 480 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 481 | trace.Reasons[relayURL] = "no_overlay_peer" |
| 482 | continue |
| 483 | } |
| 484 | if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) { |
| 485 | trace.Suppressed = append(trace.Suppressed, relayURL) |
| 486 | trace.Reasons[relayURL] = "suppressed" |
| 487 | continue |
| 488 | } |
| 489 | autoPool = append(autoPool, state) |
| 490 | } |
| 491 | |
| 492 | avgRTT, cv := molsRTTStats(autoPool) |
| 493 | trace.AvgRTT = avgRTT |
| 494 | trace.CV = cv |
| 495 | congested := avgRTT > molsCongestionRTTThreshold |
| 496 | nonLinear := cv > molsCVThreshold |
| 497 | trace.Congested = congested |
| 498 | trace.NonLinear = nonLinear |
| 499 | |
| 500 | m1, m2 := molsBaseM1, molsBaseM2 |
| 501 | if nonLinear { |
| 502 | m1, m2 = molsVariantM1, molsVariantM2 |
| 503 | } |
| 504 | trace.M1, trace.M2 = m1, m2 |
| 505 | |
| 506 | active, fallbacks := traceFallbackPartition(autoPool) |
| 507 | trace.PoolEligible = len(autoPool) |
| 508 | trace.PoolFallback = len(fallbacks) |
| 509 | if len(active) < molsMinActiveNodes && len(fallbacks) > 0 { |
| 510 | promote := min(molsMinActiveNodes-len(active), len(fallbacks)) |
| 511 | fallbacks = fallbacks[promote:] |
| 512 | } |
| 513 | demotedURLs := relayURLSet(fallbacks) |
| 514 | |
| 515 | ingressIdx := hashToGF64(cs.LocalAddress) |
| 516 | order := gridOrderForSize(len(autoPool)) |
| 517 | for _, state := range autoPool { |
| 518 | candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr) |
| 519 | row := int(ingressIdx) % order |
| 520 | col := int(candidateIdx) % order |
| 521 | score := molsScore(row, col, int(m1), int(m2), order) |
| 522 | if congested { |
| 523 | score = molsCongestionScore(row, col, int(m1), int(m2), order) |
| 524 | } |
| 525 | trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{ |
| 526 | URL: state.Descriptor.APIHTTPSAddr, |
| 527 | Score: score, |
| 528 | Confirmed: state.Confirmed, |
| 529 | RTT: state.DiscoveryRTT, |
| 530 | Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr], |
| 531 | }) |
| 532 | } |
| 533 | |
| 534 | multiHop := rankRelayPool(autoPool, cs.LocalAddress) |
| 535 | if len(multiHop) > cs.MultiHopDepth { |
| 536 | multiHop = multiHop[:cs.MultiHopDepth] |
| 537 | } |
| 538 | trace.OutputURLs = multiHop |
| 539 | trace.SelectionTook = time.Since(start) |
| 540 | return multiHop, trace |
| 541 | } |
| 542 | |
| 543 | // SelectMultiHop returns the ordered list of relay URLs for multi-hop routing. |
| 544 | // It delegates to selectMultiHopWithTrace and discards the trace. |
| 545 | func SelectMultiHop(states []RelayState, routeState RouteState) []string { |
| 546 | out, _ := selectMultiHopWithTrace(states, routeState) |
| 547 | return out |
| 548 | } |