Skip to content

Require Intent for GetVtxoChain/GetVirtualTxs#904

Open
bitcoin-coder-bob wants to merge 18 commits intomasterfrom
bob/intent-getvtxochain
Open

Require Intent for GetVtxoChain/GetVirtualTxs#904
bitcoin-coder-bob wants to merge 18 commits intomasterfrom
bob/intent-getvtxochain

Conversation

@bitcoin-coder-bob
Copy link
Collaborator

@bitcoin-coder-bob bitcoin-coder-bob commented Feb 6, 2026

Issue: #843

The general flow is that a caller calls GetVtxoChain which yields []txids which they then pass a param to a call to GetVirtualTxs. The idea of this PR is to create an auth token inside GetVtxoChain if the user can prove ownership of the intent they passed in, then to use this auth token when calling GetVirtualTxs. The spirit here is that: "A public virtual mempool means griefing attacks by third party is possible. Imagine you received a vtxo and now someone can just start unilaterally exiting your vtxo and you end up back onchain. Really annoying and can cause a number of problems with smart contracts."

This auth token is a message signed by the server's pubkey along with an outpoint, resulting in stateless auth system.

We need go-sdk changes:
A new intent param must be passed to GetVtxoChain and the response will now include a new auth token (can be empty string as well). Depending on the new config for arkd for the INDEXER_TX_EXPOSURE field (with the 3 options being public, private, or withheld) will determine the creation and enforcement of this auth token.

We will also want tests against GetVtxoChain , the new ValidateIntentWithProof and GetVirtualTxs

When public: no auth token created, empty auth token in response of GetVtxoChain and an empty auth token to be passed to GetVirtualTxs.

When private: the caller must pass ValidateIntentWithProof to get an auth token, and the auth token is required to call GetVirtualTxs

When withheld: the caller of GetVtxoChain has the option to pass OR fail ValidateIntentWithProof which will determine if they do or do not get an auth token. If they get a token they can use it when calling GetVrtualTxs and the behavior will function the same way as if public were set. However if they pass an invalid or empty auth token, we must remove from each PSBT the signature of arkd so the user cannot construct the full broadcastable txn.

The newly added ValidateIntentWithProof is largely the same flow as found here:

if message.ExpireAt > 0 {
expireAt := time.Unix(message.ExpireAt, 0)
if time.Now().After(expireAt) {
return errors.INVALID_INTENT_TIMERANGE.New("proof of ownership expired").
WithMetadata(errors.IntentTimeRangeMetadata{
ValidAt: 0,
ExpireAt: message.ExpireAt,
Now: time.Now().Unix(),
})
}
}
outpoints := proof.GetOutpoints()
proofTxid := proof.UnsignedTx.TxID()
boardingTxs := make(map[string]wire.MsgTx)
for i, outpoint := range outpoints {
psbtInput := proof.Inputs[i+1]
if len(psbtInput.TaprootLeafScript) == 0 {
return errors.INVALID_PSBT_INPUT.New("missing taproot leaf script on input %d", i+1).
WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
vtxoOutpoint := domain.Outpoint{
Txid: outpoint.Hash.String(),
VOut: outpoint.Index,
}
vtxosResult, err := s.repoManager.Vtxos().GetVtxos(ctx, []domain.Outpoint{vtxoOutpoint})
if err != nil || len(vtxosResult) == 0 {
if _, ok := boardingTxs[vtxoOutpoint.Txid]; !ok {
txhex, err := s.wallet.GetTransaction(ctx, outpoint.Hash.String())
if err != nil {
return errors.TX_NOT_FOUND.New(
"failed to get boarding input tx %s: %s", vtxoOutpoint.Txid, err,
).WithMetadata(errors.TxNotFoundMetadata{Txid: vtxoOutpoint.Txid})
}
var tx wire.MsgTx
if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txhex))); err != nil {
return errors.INVALID_PSBT_INPUT.New(
"failed to deserialize boarding tx %s: %s", vtxoOutpoint.Txid, err,
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
boardingTxs[vtxoOutpoint.Txid] = tx
}
tx := boardingTxs[vtxoOutpoint.Txid]
if int(vtxoOutpoint.VOut) >= len(tx.TxOut) {
return errors.INVALID_PSBT_INPUT.New(
"invalid vout index %d for tx %s (tx has %d outputs)",
vtxoOutpoint.VOut, vtxoOutpoint.Txid, len(tx.TxOut),
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
prevout := tx.TxOut[vtxoOutpoint.VOut]
if !bytes.Equal(prevout.PkScript, psbtInput.WitnessUtxo.PkScript) {
return errors.INVALID_PSBT_INPUT.New(
"pkscript mismatch: got %x expected %x",
prevout.PkScript,
psbtInput.WitnessUtxo.PkScript,
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
if prevout.Value != int64(psbtInput.WitnessUtxo.Value) {
return errors.INVALID_PSBT_INPUT.New(
"invalid witness utxo value: got %d expected %d",
prevout.Value,
psbtInput.WitnessUtxo.Value,
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
continue
}
vtxo := vtxosResult[0]
if psbtInput.WitnessUtxo.Value != int64(vtxo.Amount) {
return errors.INVALID_PSBT_INPUT.New(
"invalid witness utxo value: got %d expected %d",
psbtInput.WitnessUtxo.Value,
vtxo.Amount,
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
pubkeyBytes, err := hex.DecodeString(vtxo.PubKey)
if err != nil {
return errors.INTERNAL_ERROR.New("failed to decode vtxo pubkey: %w", err).
WithMetadata(map[string]any{
"vtxo_pubkey": vtxo.PubKey,
})
}
pubkey, err := schnorr.ParsePubKey(pubkeyBytes)
if err != nil {
return errors.INTERNAL_ERROR.New("failed to parse vtxo pubkey: %w", err).
WithMetadata(map[string]any{
"vtxo_pubkey": vtxo.PubKey,
})
}
pkScript, err := script.P2TRScript(pubkey)
if err != nil {
return errors.INTERNAL_ERROR.New(
"failed to compute P2TR script from vtxo pubkey: %w", err,
).WithMetadata(map[string]any{
"vtxo_pubkey": vtxo.PubKey,
})
}
if !bytes.Equal(pkScript, psbtInput.WitnessUtxo.PkScript) {
return errors.INVALID_PSBT_INPUT.New(
"invalid witness utxo script: got %x expected %x",
psbtInput.WitnessUtxo.PkScript,
pkScript,
).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
}
}
encodedMessage, err := message.Encode()
if err != nil {
return errors.INVALID_INTENT_MESSAGE.New("failed to encode message: %w", err).
WithMetadata(errors.InvalidIntentMessageMetadata{Message: message.BaseMessage})
}
encodedProof, err := proof.B64Encode()
if err != nil {
return errors.INVALID_INTENT_PSBT.New("failed to encode proof: %w", err).
WithMetadata(errors.PsbtMetadata{Tx: proof.UnsignedTx.TxID()})
}
signedProof, err := s.signer.SignTransactionTapscript(ctx, encodedProof, nil)
if err != nil {
return errors.INTERNAL_ERROR.New("failed to sign proof: %w", err).
WithMetadata(map[string]any{
"proof": proof.UnsignedTx.TxID(),
})
}
if err := intent.Verify(signedProof, encodedMessage); err != nil {
log.
WithField("unsignedProof", encodedProof).
WithField("signedProof", signedProof).
WithField("encodedMessage", encodedMessage).
Tracef("failed to verify intent proof: %s", err)
return errors.INVALID_INTENT_PROOF.New("invalid intent proof: %w", err).
WithMetadata(errors.InvalidIntentProofMetadata{
Proof: signedProof,
Message: encodedMessage,
})
}

The indexerService now that it needs to be able to sign a message for the auth token, and needs access to the server's pubkey (to locate inside the psbt for removal), we have given the indexerService access to ports.SignerService and ports.WalletService (needed for the SignMessage call).

Requires changes in the go-sdk for GetVtxoChain (which now requires an Intent be passed) and to GetVirtualTxs (which now requires an AuthToken to be passed). Thus the e2e tests without this sdk change will only work when the exposure type is set to public.

Note that theres a couple small changes in the sqlite. I re-ran sqlc generate and it yielded couple changes to the After field.

Summary by CodeRabbit

  • New Features

    • Message signing capability added (signing endpoint and wallet support).
    • Intent-based access control for VTXO chain retrieval with configurable exposures (public, withheld, private).
    • APIs extended to accept/return intent and auth-token fields for controlled access to virtual transaction data.
  • Documentation

    • Added ARKD_INDEXER_TX_EXPOSURE environment variable (default: public).
  • Tests

    • New unit and e2e tests with fixtures for message signing and auth-token flows.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds configurable indexer transaction exposure, intent-based PSBT proof validation, short-lived auth-token generation/validation, a SignMessage gRPC/schema and wallet signing API, nullable DB timestamp handling, tests/fixtures for auth/signing, and Go toolchain/CI/Dockerfile bumps.

Changes

Cohort / File(s) Summary
Docs / API Specs
README.md, api-spec/openapi/swagger/ark/v1/indexer.openapi.json, api-spec/openapi/swagger/signer/v1/service.openapi.json, api-spec/protobuf/ark/v1/indexer.proto, api-spec/protobuf/signer/v1/service.proto
Adds ARKD_INDEXER_TX_EXPOSURE to README; introduces Intent schema and authToken query/response fields for indexer endpoints; adds SignMessage schema and gRPC-only SignMessage RPC/schema for signer.
Configuration & Server Init
internal/config/config.go, internal/interface/grpc/service.go
Adds IndexerTxExposure config (default public, validation); IndexerService() now returns (application.IndexerService, error) and ensures signer initialization; server setup surfaces IndexerService error.
Indexer Core
internal/core/application/indexer.go, internal/core/application/types.go, internal/core/ports/signer.go
Implements TxExposure enum and Intent type; adds ValidateIntentWithProof, createAuthToken, validateAuthToken; updates GetVtxoChain/GetVirtualTxs signatures/behavior to accept intent/authToken; VtxoChainResp gains AuthToken; SignerService interface adds SignMessage.
gRPC Handlers & Signer Client
internal/interface/grpc/handlers/indexer.go, internal/infrastructure/signer/client.go
Handlers extract/forward Intent and authToken and return AuthToken; signer client adds SignMessage forwarding to the gRPC signer.
Wallet Signer & Handlers
pkg/arkd-wallet/core/application/types.go, pkg/arkd-wallet/core/application/wallet/service.go, pkg/arkd-wallet/interface/grpc/handlers/signer_handler.go
Adds WalletService.SignMessage; implements signing (hash + Schnorr) in wallet; gRPC signer handler delegates SignMessage to wallet.
Database / SQLC
internal/infrastructure/db/sqlite/sqlc/queries/models.go, internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go, internal/infrastructure/db/sqlite/vtxo_repo.go
Makes UpdatedAt nullable (sql.NullInt64) and changes query param After types to sql.NullInt64 with calling-site adaptation.
Tests & Fixtures
internal/core/application/indexer_test.go, internal/core/application/testdata/indexer_fixtures.json, pkg/arkd-wallet/core/application/wallet/fixtures/service_fixtures.json, pkg/arkd-wallet/core/application/wallet/service_test.go, internal/test/e2e/delegate_utils_test.go
Adds extensive tests/fixtures for auth-token and SignMessage flows; new unit/integration tests; removes a debug print in an e2e test.
CI / Tooling / Docker
Dockerfile, .github/workflows/*, go.mod, */go.mod, arkdwallet*.Dockerfile, api-spec/go.mod
Bumps Go toolchain from 1.25.6 to 1.25.7 across CI, Dockerfiles, and module files; adds a couple replace directives in wallet modules.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant IndexerHandler
    participant IndexerService
    participant Validator as Intent/PSBT Validator
    participant Signer
    participant Database

    Client->>IndexerHandler: GetVtxoChain(vtxoKey, intent)
    IndexerHandler->>IndexerService: GetVtxoChain(vtxoKey, intent)
    IndexerService->>IndexerService: Check txExposure
    alt Withheld/Private
        IndexerService->>Validator: ValidateIntentWithProof(intent)
        Validator->>Database: Fetch vtxo data
        Database-->>Validator: vtxo
        Validator-->>IndexerService: validation result
        alt Valid
            IndexerService->>Signer: createAuthToken(outpoint)
            Signer-->>IndexerService: authToken
            IndexerService->>Database: Fetch vtxo chain
            Database-->>IndexerService: chain
            IndexerService-->>IndexerHandler: VtxoChainResp(chain, authToken)
        else Invalid
            IndexerService-->>IndexerHandler: error
        end
    else Public
        IndexerService->>Database: Fetch vtxo chain
        Database-->>IndexerService: chain
        IndexerService-->>IndexerHandler: VtxoChainResp(chain)
    end
    IndexerHandler-->>Client: GetVtxoChainResponse
Loading
sequenceDiagram
    participant Client
    participant VirtualTxsHandler
    participant IndexerService
    participant TokenValidator as Auth Token Validator
    participant Database

    Client->>VirtualTxsHandler: GetVirtualTxs(authToken, txids)
    VirtualTxsHandler->>IndexerService: GetVirtualTxs(authToken, txids)
    IndexerService->>IndexerService: Check txExposure
    alt Withheld
        alt authToken present
            IndexerService->>TokenValidator: validateAuthToken(authToken)
            TokenValidator-->>IndexerService: valid
            IndexerService->>Database: Fetch virtual txs (with signatures)
            Database-->>IndexerService: txs
        else no authToken
            IndexerService->>Database: Fetch virtual txs
            Database-->>IndexerService: txs (strip arkd sigs)
        end
    else Private
        IndexerService->>TokenValidator: validateAuthToken(authToken)
        TokenValidator-->>IndexerService: valid/invalid
        alt valid
            IndexerService->>Database: Fetch virtual txs (with signatures)
            Database-->>IndexerService: txs
        else invalid
            IndexerService-->>VirtualTxsHandler: error (auth required)
        end
    else Public
        IndexerService->>Database: Fetch virtual txs
        Database-->>IndexerService: txs
    end
    IndexerService-->>VirtualTxsHandler: VirtualTxsResp
    VirtualTxsHandler-->>Client: GetVirtualTxsResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • altafan
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Require Intent for GetVtxoChain/GetVirtualTxs' clearly and specifically summarizes the main changes: adding Intent requirement to GetVtxoChain and authToken requirement to GetVirtualTxs methods.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bob/intent-getvtxochain

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bitcoin-coder-bob
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@api-spec/protobuf/ark/v1/indexer.proto`:
- Around line 204-208: The GET request payload includes the Intent message
(fields intent.proof and intent.message) which can be large and exceed
URL/query-length limits; change the API so REST clients send Intent in the HTTP
body instead of query params — either (a) remove Intent from GetVtxoChainRequest
and create a separate POST RPC (e.g., GetVtxoChainWithIntent) that accepts
IndexerOutpoint + IndexerPageRequest + Intent in the request body, or (b) keep
the same RPC but update the google.api.http annotation to use POST and map the
Intent to the HTTP body (or a new wrapper message) so proof/message are sent in
the request body rather than as query parameters; apply this change around the
GetVtxoChainRequest and Intent message references.

In `@api-spec/protobuf/signer/v1/service.proto`:
- Around line 31-36: The SignMessage RPC binding currently exposes an
unauthenticated HTTP endpoint; either remove the HTTP gateway binding for the
SignMessage RPC (remove the option (meshapi.gateway.http) block on rpc
SignMessage(SignMessageRequest) returns (SignMessageResponse)) or add
authentication middleware so the endpoint requires macaroons like the main arkd
service; if adding auth, ensure the SignerService/SignMessage handler validates
macaroons on incoming requests (same macaroon check used by arkd) or restrict
the HTTP gateway to listen only on localhost so the endpoint is not reachable on
all interfaces.

In `@internal/core/application/indexer.go`:
- Around line 424-433: In the TxExposureWithheld branch, ValidateIntentWithProof
errors are being swallowed; change the logic in the case for TxExposureWithheld
so that after calling i.ValidateIntentWithProof(ctx, vtxoKey, intentForProof)
you distinguish validation failures from infrastructure/internal errors and only
suppress errors that indicate an invalid proof, while returning unexpected
errors to the caller; use the error type/value returned by
ValidateIntentWithProof (or wrap/inspect it) to detect "invalid proof" vs
DB/internal faults and only proceed to call i.createAuthToken when validation
succeeded, otherwise return the unexpected error instead of dropping it.
- Around line 497-509: The fallback incorrectly treats vtxoKey.Txid as raw tx
hex causing tx.Deserialize to always fail; replace the stubbed assignment with a
real fetch from the wallet (use s.wallet.GetTransaction(ctx,
vtxoOutpoint.Txid.String() or correct id) to obtain txhex and check its error),
then pass the returned raw hex into tx.Deserialize; ensure you handle and
propagate errors from s.wallet.GetTransaction (return an
errors.INVALID_PSBT_INPUT with InputMetadata on failure) and only store the
deserialized wire.MsgTx into boardingTxs[vtxoOutpoint.Txid] when deserialization
succeeds.
- Around line 483-484: The loop over outpoints can index past proof.Inputs
(psbtInput := proof.Inputs[idx+1]); add a bounds check before using idx+1 to
avoid a panic: inside the for loop that iterates outpoints, verify that idx+1 <
len(proof.Inputs) (or len(proof.Inputs) > idx+1) and handle the mismatch (return
an error, log and continue, or skip) instead of indexing blindly; update the
code around the outpoints loop and psbtInput usage to enforce this guard and
surface a clear error message if sizes are inconsistent.
- Around line 582-594: The TODO must be resolved: if the proof PSBT requires a
server counter-signature, call SignTransactionTapscript(ctx,
intentForProof.Proof) and use its returned signed PSBT when calling
intent.Verify (i.e., replace intentForProof.Proof with the signed result), and
propagate any error into the same INVALID_INTENT_PROOF path; additionally remove
or redact logging of the full intentForProof.Proof and intentForProof.Message
(do not log raw PSBT or full message), instead log only non-sensitive metadata
(e.g., proof ID or truncated hash) or omit these fields in the Tracef call to
avoid exposing input/outpoint/script details.
- Around line 414-448: The code in GetVtxoChain currently hardcodes
someExposureFromConfig := "public", bypassing auth branches; replace that
hardcoded value with the instance config i.txExposure (e.g., use exposure :=
i.txExposure), validate exposure != "" and then switch on TxExposure(exposure)
so the Withheld/Private branches (which call i.ValidateIntentWithProof and
i.createAuthToken) can execute as intended; ensure error messages remain the
same and no other logic changes in the switch.
- Around line 78-99: The code currently serializes the signer pubkey with
schnorr.SerializePubKey producing a 32-byte x-only key (in NewIndexerService)
which breaks ECDSA verification in validateAuthToken (which assumes full parity
via schnorr.ParsePubKey) and prevents matching PSBT PartialSig.PubKey (33-byte
compressed) in GetVirtualTxs; change NewIndexerService to store the full 33-byte
compressed pubkey (serialize the returned pubkey as the compressed form instead
of x-only) into indexerService.signerPubkey, update validateAuthToken to
parse/verify using btcec.ParsePubKey (or the equivalent compressed-key parser)
instead of schnorr.ParsePubKey, and ensure GetVirtualTxs compares
PartialSig.PubKey against the 33-byte compressed signerPubkey (so bytes.Equal
can match) rather than a 32-byte x-only key.
- Around line 753-786: validateAuthToken currently tries to parse a DER ECDSA
signature and double-hashes the msg, which is incompatible with
createAuthToken/SignMessage (which produce 64-byte schnorr signatures over the
raw message). Update validateAuthToken to parse the signature as a schnorr
signature (use the schnorr parse/verify API) instead of ecdsa.ParseDERSignature,
and verify against the raw msg (do not call chainhash.DoubleHashB on msg);
continue to parse the signer pubkey with schnorr.ParsePubKey(i.signerPubkey) and
call the schnorr verify method to return the boolean result.

In `@pkg/arkd-wallet/core/application/wallet/service.go`:
- Around line 731-739: SignMessage currently produces an ECDSA DER signature
(uses ecdsa.Sign) while the verifier in indexer.go expects Schnorr signatures
from a Schnorr-serialized public key (signerPubkey), so auth tokens will always
fail verification; fix by making signing and verification use the same scheme —
either change SignMessage to call schnorr.Sign(...) and return the Schnorr
signature format to match the existing verifier, or (alternatively) update
indexer.go to parse/derive the ECDSA public key and verify with
ecdsa.ParseDERSignature()/sig.Verify using that ECDSA pubkey; locate SignMessage
in service.go and the verification code in indexer.go (where signerPubkey is
parsed) and make both sides use the same signature algorithm (prefer using
schnorr.Sign if you want minimal changes to indexer.go).
🧹 Nitpick comments (4)
api-spec/openapi/swagger/ark/v1/indexer.openapi.json (1)

489-495: Verify: authToken query parameter lacks description.

Other query parameters in this spec (e.g., spendableOnly, pendingOnly) include a description field. The new authToken parameter and the intent.proof/intent.message parameters (Lines 578–591) omit descriptions. Adding brief descriptions would improve the developer experience for REST API consumers, especially given the new exposure modes (public/withheld/private) that affect how these fields are used.

README.md (1)

111-111: Description could be more precise.

The current description "Require intent for getting vtxo chain" undersells the feature. It also governs GetVirtualTxs auth-token behavior and PSBT signature stripping in withheld mode. A slightly more descriptive phrasing like "Controls indexer transaction data exposure level" would better convey the scope to operators.

Minor nit — this won't block anything.

internal/config/config.go (2)

630-637: IndexerService() creates a new instance on every call — consider caching.

Unlike AppService() which caches in c.svc, this method creates a new IndexerService (including a signer.GetPubkey gRPC call) on every invocation. Currently it's only called once from newServer, but to stay consistent with other accessors and prevent accidental regressions:

♻️ Proposed caching pattern

Add an indexerSvc field to the Config struct and cache the result:

+	indexerSvc application.IndexerService

Then update the accessor:

 func (c *Config) IndexerService() (application.IndexerService, error) {
+	if c.indexerSvc != nil {
+		return c.indexerSvc, nil
+	}
 	if c.signer == nil {
 		if err := c.signerService(); err != nil {
 			return nil, err
 		}
 	}
-	return application.NewIndexerService(c.repo, c.signer, c.IndexerTxExposure)
+	svc, err := application.NewIndexerService(c.repo, c.signer, c.IndexerTxExposure)
+	if err != nil {
+		return nil, err
+	}
+	c.indexerSvc = svc
+	return c.indexerSvc, nil
 }

567-571: Consider using the TxExposure constants instead of hardcoded strings.

The application layer defines TxExposurePublic, TxExposureWithheld, and TxExposurePrivate constants for the TxExposure type. Using those here would keep validation in sync with the domain layer and prevent drift.

♻️ Suggested change
-	if c.IndexerTxExposure != "public" && c.IndexerTxExposure != "withheld" &&
-		c.IndexerTxExposure != "private" {
-		return fmt.Errorf("indexer tx exposure must be either 'public', 'withheld', or 'private'")
-	}
+	if c.IndexerTxExposure != string(application.TxExposurePublic) &&
+		c.IndexerTxExposure != string(application.TxExposureWithheld) &&
+		c.IndexerTxExposure != string(application.TxExposurePrivate) {
+		return fmt.Errorf("indexer tx exposure must be either 'public', 'withheld', or 'private'")
+	}

Comment on lines 204 to 208
message GetVtxoChainRequest {
IndexerOutpoint outpoint = 1;
IndexerPageRequest page = 2;
Intent intent = 3;
}
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Intent in a GET request may hit URL length limits for REST/HTTP clients.

The Intent message (with proof and message fields) is added to GetVtxoChainRequest, which maps to a GET endpoint. The proof data (PSBT signature) could be large, and when serialized as query parameters (intent.proof, intent.message), it may exceed URL length limits in browsers or proxies (commonly ~2KB–8KB). gRPC clients are unaffected.

Consider whether a POST mapping (or an alternative endpoint) is needed for REST clients submitting large proofs.

🤖 Prompt for AI Agents
In `@api-spec/protobuf/ark/v1/indexer.proto` around lines 204 - 208, The GET
request payload includes the Intent message (fields intent.proof and
intent.message) which can be large and exceed URL/query-length limits; change
the API so REST clients send Intent in the HTTP body instead of query params —
either (a) remove Intent from GetVtxoChainRequest and create a separate POST RPC
(e.g., GetVtxoChainWithIntent) that accepts IndexerOutpoint + IndexerPageRequest
+ Intent in the request body, or (b) keep the same RPC but update the
google.api.http annotation to use POST and map the Intent to the HTTP body (or a
new wrapper message) so proof/message are sent in the request body rather than
as query parameters; apply this change around the GetVtxoChainRequest and Intent
message references.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think the intents are that big?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the Intent type definition
rg -n "type Intent struct" --type=go -A 5

# Find where Intent.Proof is populated or used
rg -n "Intent\{" --type=go -C 3

# Look for PSBT serialization or encoding in the context of Intent
rg -n "Proof.*base64|base64.*Proof" --type=go -C 2

# Find the types.proto file that defines Intent message
fd -e proto types.proto -x cat {}

Length of output: 16159


@bitcoin-coder-bob You're probably right for typical use cases!

Looking at the code, the Intent.Proof field contains a base64-encoded PSBT. For a transaction with 1-2 inputs, that would be around 400-1,100 bytes encoded, which is well within URL length limits.

My concern was for edge cases where someone creates an intent with many inputs (5-10+ UTXOs being spent). A PSBT with that many inputs could reach 2-3KB+ in base64, and when combined with the other query parameters (outpoint.txid, outpoint.vout, page params), it might approach the ~2KB limit enforced by some older proxies or restrictive API gateways.

Question: In practice, is there a constraint on how many inputs a typical intent would have? If intents typically stay at 1-3 inputs, then this isn't a real concern and I was being overly cautious.

Comment on lines 424 to 433
case TxExposureWithheld:
// validate the intent proof/message to allow access to the full chain
err = i.ValidateIntentWithProof(ctx, vtxoKey, intentForProof)
if err == nil {
authToken, err = i.createAuthToken(ctx, vtxoKey)
if err != nil {
return nil, err
}
}
// if intent failed validation, we just dont supply an auth token
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Withheld mode silently swallows all ValidateIntentWithProof errors, including infrastructure failures.

If ValidateIntentWithProof fails due to a DB error or internal issue (not just invalid proof), the error is silently discarded and the caller simply gets no auth token. Consider distinguishing between "invalid proof" (expected, no auth token) and unexpected infrastructure errors (should be surfaced).

🤖 Prompt for AI Agents
In `@internal/core/application/indexer.go` around lines 424 - 433, In the
TxExposureWithheld branch, ValidateIntentWithProof errors are being swallowed;
change the logic in the case for TxExposureWithheld so that after calling
i.ValidateIntentWithProof(ctx, vtxoKey, intentForProof) you distinguish
validation failures from infrastructure/internal errors and only suppress errors
that indicate an invalid proof, while returning unexpected errors to the caller;
use the error type/value returned by ValidateIntentWithProof (or wrap/inspect
it) to detect "invalid proof" vs DB/internal faults and only proceed to call
i.createAuthToken when validation succeeded, otherwise return the unexpected
error instead of dropping it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could require they always pass validation, not just use it as a gate to determine if they get an auth token

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bitcoin-coder-bob, that's a good point! Making validation mandatory in withheld mode would address the concern cleanly—infrastructure errors would bubble up naturally instead of being silently dropped, and you'd avoid the ambiguity of "partial access without proof."

If you require valid intent in withheld mode (just like private mode), the key difference would be:

  • Withheld: Valid intent required → always get auth token → GetVirtualTxs strips arkd signatures if auth token is somehow invalid/missing later
  • Private: Valid intent required → always get auth token → GetVirtualTxs returns error if auth token is invalid/missing

This would change the semantics of withheld from "optional proof for better access" to "proof always required, but downstream behavior differs." Is that the direction you want to take? It would simplify the logic and make error handling more explicit.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review February 9, 2026 19:37
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@internal/config/config.go`:
- Around line 573-575: The long fmt.Errorf call in the check for
supportedIndexerTxExposures.supports(c.IndexerTxExposure) exceeds golines; fix
by wrapping the format arguments across lines so the string literal is shorter —
e.g. keep the format string in-line and move supportedIndexerTxExposures to the
next line (or split the message into two concatenated strings) so the call to
fmt.Errorf("indexer txn exposure type not supported, please select one of: %s",
supportedIndexerTxExposures) becomes formatted across lines and satisfies
golines while leaving the logic in supportedIndexerTxExposures.supports and
c.IndexerTxExposure unchanged.

In `@internal/core/application/indexer_test.go`:
- Around line 109-112: Tests construct indexerService with a 32-byte x-only key
via schnorr.SerializePubKey but production uses 33-byte compressed keys
(pubkey.SerializeCompressed()) and validateAuthToken will parse compressed keys
with btcec.ParsePubKey; update each test construction of indexerService (where
signerPubkey is set) to use mockSigner.publicKey.SerializeCompressed() (or the
equivalent compressed byte slice) instead of schnorr.SerializePubKey so the test
bytes match NewIndexerService/validateAuthToken behavior.
- Around line 70-76: Reformat the long stub signature for SignTransaction to
satisfy golines by breaking the parameter list across lines: locate the method
func (m *mockSignerService) SignTransaction and split its parameters (ctx
context.Context, partialTx string, extractRawTx bool) onto separate lines (or
otherwise wrap) while keeping the receiver, parameter names/types and the
(string, error) return intact; run gofmt/golines after editing to confirm CI
passes.

In `@internal/core/application/indexer.go`:
- Around line 636-646: The loop currently removes arkd signatures from
ptx.Inputs[j].PartialSigs, but Taproot virtual transactions store signatures in
TaprootScriptSpendSig; update the stripping logic in the same block to iterate
ptx.Inputs[j].TaprootScriptSpendSig (instead of PartialSigs), filter out entries
whose PubKey equals i.signerPubkey, and assign the filtered slice back to
ptx.Inputs[j].TaprootScriptSpendSig so arkd's taproot signatures are actually
removed in withheld-mode.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/core/application/indexer.go`:
- Around line 648-652: The code currently re-serializes the PSBT with
ptx.Serialize(&b) and assigns b.String() to virtualTxs[idx], which produces raw
binary but callers (NewFromRawBytes(..., true) and others) expect base64; update
the assignment to base64-encode the serialized bytes instead of storing raw
bytes so virtualTxs remains base64-encoded (use the bytes from the
strings.Builder or ptx.Serialize output and encode with
base64.StdEncoding.EncodeToString before assigning to virtualTxs[idx]).
- Around line 489-490: The current check conflates DB errors with "not found" by
using `if err != nil || len(vtxosResult) == 0` after calling
`i.repoManager.Vtxos().GetVtxos(ctx, []domain.Outpoint{vtxoOutpoint})`; change
this to first handle and return (or propagate/log) when `err != nil` so
infrastructure/database failures surface, and only when `err == nil` check
`len(vtxosResult) == 0` to decide to fall back to the on-chain wallet path;
update the surrounding logic that uses `vtxosResult` accordingly (references:
`GetVtxos`, `vtxosResult`, `err`, `vtxoOutpoint`).
🧹 Nitpick comments (2)
internal/config/config.go (1)

637-644: Missing nil-guard for c.wallet — only c.signer is defensively initialized.

IndexerService() checks and lazily initializes c.signer but not c.wallet. If this method is ever called before Validate() finishes (or in a test/path that skips wallet init), c.wallet will be nil, causing a nil-pointer dereference inside NewIndexerService or later in ValidateIntentWithProof when i.wallet.GetTransaction is called.

Suggested fix
 func (c *Config) IndexerService() (application.IndexerService, error) {
+	if c.wallet == nil {
+		if err := c.walletService(); err != nil {
+			return nil, err
+		}
+	}
 	if c.signer == nil {
 		if err := c.signerService(); err != nil {
 			return nil, err
 		}
 	}
 	return application.NewIndexerService(c.repo, c.signer, c.wallet, c.IndexerTxExposure)
 }
internal/core/application/indexer.go (1)

759-792: Auth tokens have no expiry and no scope binding to the requested txids.

validateAuthToken only checks the signature — it does not verify that the outpoint embedded in the token relates to the txids being requested in GetVirtualTxs, nor does it enforce a time-to-live. A single valid token grants indefinite access to any virtual txs.

If this is acceptable for the current threat model (the token just attests the caller passed through GetVtxoChain), consider adding a comment documenting that decision. Otherwise, adding a timestamp to the signed payload and verifying token age would limit the blast radius of a leaked token.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@internal/core/application/indexer.go`:
- Around line 515-525: Add a nil-check for psbtInput.WitnessUtxo immediately
after retrieving psbtInput to avoid panics when a proof PSBT omits WitnessUtxo;
if nil, return errors.INVALID_PSBT_INPUT (similar style to existing errors) with
a descriptive message like "missing WitnessUtxo for input" and attach the same
metadata (errors.InputMetadata{Txid: proofTxid, InputIndex: idx + 1}); this
prevents dereferences of psbtInput.WitnessUtxo when comparing PkScript
(prevout.PkScript vs psbtInput.WitnessUtxo.PkScript) and when comparing Value.
🧹 Nitpick comments (2)
internal/core/application/indexer.go (2)

601-610: Auth validation runs after DB fetch and pagination — wasted work when token is rejected.

GetTxsWithTxids and paginate execute before the exposure switch. For Private mode, invalid tokens cause an immediate rejection, making the preceding DB query unnecessary. Moving the auth check before the fetch would avoid the wasted I/O on unauthorized requests.


85-102: txExposure is not validated at construction time — invalid values surface only at request time.

NewIndexerService accepts any string for txExposure. An invalid value (e.g., a typo in config) won't be caught until a request hits the default branch in GetVtxoChain or GetVirtualTxs. Validating at startup would fail fast and prevent a misconfigured server from silently serving errors.

Proposed fix
 func NewIndexerService(
 	repoManager ports.RepoManager,
 	signer ports.SignerService,
 	wallet ports.WalletService,
 	txExposure string,
 ) (IndexerService, error) {
+	switch TxExposure(txExposure) {
+	case TxExposurePublic, TxExposureWithheld, TxExposurePrivate:
+	default:
+		return nil, fmt.Errorf("invalid tx exposure value: %q", txExposure)
+	}
+
 	pubkey, err := signer.GetPubkey(context.Background())

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/core/application/indexer.go (1)

612-696: ⚠️ Potential issue | 🔴 Critical

TxExposurePublic case in GetVirtualTxs returns empty results — transactions are never fetched.

The Public branch (line 625–626) falls through without calling GetTxsWithTxids or populating txs/virtualTxs. The variables are initialized as empty slices (lines 617–618), so GetVirtualTxs always returns an empty response when exposure is "public".

Both Withheld (line 636) and Private (line 682) correctly call GetTxsWithTxids and paginate. The Public case is missing this.

Proposed fix
 	switch TxExposure(i.txExposure) {
 	case TxExposurePublic:
 		// no need to validate auth
+		txs, err = i.repoManager.Rounds().GetTxsWithTxids(ctx, txids)
+		if err != nil {
+			return nil, err
+		}
+		virtualTxs, reps = paginate(txs, page, maxPageSizeVirtualTxs)
 	case TxExposureWithheld:
🤖 Fix all issues with AI agents
In `@internal/core/application/indexer.go`:
- Around line 423-441: When TxExposure is TxExposurePrivate, don't silently
swallow all errors from ValidateIntentWithProof; distinguish between a
validation-failure (invalid intent/proof) and an infrastructure error
(DB/signer). Change the block around ValidateIntentWithProof in the indexer to:
call ValidateIntentWithProof(vtxoKey, intentForProof); if err == nil proceed to
createAuthToken; if err indicates a validation rejection (use the specific
sentinel/error type returned by ValidateIntentWithProof, e.g. ErrInvalidIntent
or a ValidationError) then continue without an auth token as before; otherwise
(for non-validation errors) return that error immediately. Update
ValidateIntentWithProof to export or wrap a sentinel/typed error if needed so
the caller can use errors.Is / type assertion to detect validation vs
infrastructure errors; keep references to TxExposurePrivate,
ValidateIntentWithProof, createAuthToken and GetVirtualTxs in your changes.
🧹 Nitpick comments (1)
internal/config/config.go (1)

637-649: c.repo is not nil-checked before being passed to NewIndexerService.

IndexerService() lazily ensures wallet and signer are initialized but passes c.repo without checking. If called before Validate() (which initializes c.repo via c.repoManager()), this will pass nil to the constructor. Other factory methods like AppService() similarly assume prior initialization, so this may be by convention — but unlike wallet/signer, there's no guard here.

Suggested guard
 func (c *Config) IndexerService() (application.IndexerService, error) {
+	if c.repo == nil {
+		if err := c.repoManager(); err != nil {
+			return nil, err
+		}
+	}
 	if c.wallet == nil {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant