GitHub
TransversePATH FINDER

SDK reference

Reachability

finder, sonar and resolver — and what the returned proofs are for.

finder(params)

Both steps, one call. Requires a credential unless you skip step 1.

const reach = await path.finder({
  identifierType: 'phone',
  identifier: '+12025550123',
});

reach.found;    // true
reach.member;   // "member-b"
reach.endpoint; // "https://api.member-b.com"
reach.proofs;   // { sonar, resolver: null }

Step 1 only, and the shape says so. SONAR answers which member holds this key — it does not return a destination, so there is nothing for step 2 to resolve yet and accepts comes back empty.

Pass an address to get both halves in one call:

const reach = await path.finder({
  identifierType: 'phone',
  identifier: '+12025550123',
  address: 'path:4a91c2f7e8d3',
});

reach.accepts;  // [{ asset: "USDC", chain: "base" }, …]
reach.limits;   // { max_single: "500000", currency: "USD" }
reach.proofs;   // { sonar, resolver }

The SDK will never send a raw identifier to a resolver. /resolver/{key} matches addresses, so the call could not succeed anyway — but it would put a plaintext phone number in another member's URL, and from there into their access logs. An identifier leaves this SDK hashed, or it does not leave.

Skipping step 1

const reach = await path.finder({
  identifierType: 'address',
  address: 'path:4a91c2f7e8d3',
  knownEndpoint: 'https://api.member-b.com',
});

No credential, no directory, no membership. The relationship profile.

Composition is client-side

The SDK makes both calls. It does not ask the operator to chain them, and that is deliberate: a network that fetched step 2 would see the destination's capability on top of every search — which rails a competitor supports, which assets, what limits, and how they change.

One extra round trip. What it buys is that no single party sees both halves.

Keep the proofs

await audit.record({
  intent: 'send',
  sonar_answer: reach.proofs.sonar,
  resolver_answer: reach.proofs.resolver,
});

Both are signed envelopes. When a payment lands somewhere unexpected, the question is what you were told before you sent — and a signed pair answers it where a log line does not.

Either can be null, and the type says so rather than pretending otherwise. sonar is null in the relationship profile, where step 1 never runs; resolver is null when the holder is listed but its endpoint did not answer — which is a real state a payer needs to tell apart from "nobody holds this key".

When the holder is down

if (reach.found && reach.accepts.length === 0) {
  // Someone holds this key; their endpoint did not answer.
}

found: true with no capabilities means step 1 succeeded and step 2 did not. Distinct from found: false, and worth telling a user differently: temporarily unreachable rather than not found.


sonar(params)

Step 1 alone. Requires a credential.

const answer = await path.sonar({
  identifierType: 'phone',
  identifier: '+12025550123',
  nonce: crypto.randomUUID(),
});

Use it directly when you want to route without immediately resolving — deciding whether a transfer is internal or cross-member, for instance.

Every call consumes a reciprocity budget and is logged against your member. budget_exhausted is not a rate limit: it is a monthly allowance tied to what you contribute to the index, and retrying more slowly does not help.

Negatives are uninformative by design

answer.found; // false — unknown key, or known but not visible to you

Do not build logic that tries to tell the two apart. A route that leaked the difference would answer "is this person a customer of somebody" to anyone patient enough to ask.


resolver(address, endpoint?)

Step 2 alone. No credential.

const answer = await path.resolver('path:4a91c2f7e8d3', 'https://api.member-b.com');

answer.standing;   // "holder" — read this first
answer.accepts;    // capabilities
answer.limits;
answer.commitment; // null when nothing was pledged — see below

Read standing before you read the terms

if (answer.standing === 'gateway') {
  // These terms, limits and fees belong to the responder, not to the destination.
  // The destination pledged nothing and does not know it is being addressed —
  // which is why `commitment` is null and cannot be checked against anything.
}

Both standings return the same shape, and that is exactly why the field has to be read: a caller that skips it shows a payer a middleman's price as though the receiver had quoted it.

Every answer is 'holder' today. A gateway is found through the member register rather than the directory, and no operator declares PATH-FINDER.Gateway yet — see PATH FINDER.

Check the commitment against the published record

import { checkCommitment } from '@pathprotocol/sdk';

const published = await path.addressCommitment('path:4a91c2f7e8d3', 'https://api.member-b.com');

const check = checkCommitment({
  address: 'path:4a91c2f7e8d3',
  accepts: answer.accepts,
  limits: answer.limits,
  answerCommitment: answer.commitment,
  publishedCommitment: published.commitment,
});

check.status is committed, uncommitted, or mismatch. Fetch published yourself rather than taking it from whoever handed you the answer — comparing an answer against a digest that arrived with it compares a statement with itself.

Omit endpoint to ask the client's own operator.

Verify before sending

import { verifyAgainstIssuer } from '@pathprotocol/sdk';

const { valid } = await verifyAgainstIssuer(answer, 'https://api.member-b.com');
if (!valid) throw new Error('Refusing to send against an unverified answer');

Revoked is not unknown

try {
  await path.resolver(address, endpoint);
} catch (err) {
  if (err instanceof PathApiError && err.code === 'path.address.revoked') {
    // The address moved. Ask the recipient for a current one.
  }
}

revoked means ask for a new address. not_found means you have the wrong destination. Telling a user the wrong one of those sends them to the wrong place for help.

On this page