Skip to content

Require Intent for GetVtxoChain/GetVirtualTxs#904

Merged
altafan merged 56 commits into
masterfrom
bob/intent-getvtxochain
Apr 2, 2026
Merged

Require Intent for GetVtxoChain/GetVirtualTxs#904
altafan merged 56 commits into
masterfrom
bob/intent-getvtxochain

Conversation

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator

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

Closes #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
Copy Markdown
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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/intent-getvtxochain

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
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
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
Copy Markdown
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 thread api-spec/protobuf/ark/v1/indexer.proto
Comment thread api-spec/protobuf/signer/v1/service.proto Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread pkg/arkd-wallet/core/application/wallet/service.go Outdated
@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review February 9, 2026 19:37
Copy link
Copy Markdown
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.

Comment thread internal/config/config.go Outdated
Comment thread internal/core/application/indexer_test.go Outdated
Comment thread internal/core/application/indexer_test.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Copy link
Copy Markdown
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.

Comment thread internal/core/application/indexer.go
Comment thread internal/core/application/indexer.go Outdated
Copy link
Copy Markdown
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())

Comment thread internal/core/application/indexer.go Outdated
Copy link
Copy Markdown
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 {

Comment thread internal/core/application/indexer.go Outdated
# Conflicts:
#	.github/workflows/integration.yaml
#	.github/workflows/unit.yaml
#	api-spec/protobuf/gen/ark/v1/indexer.pb.go
#	api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.go
#	internal/test/e2e/delegate_utils_test.go
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 1, 2026

🔍 Arkana PR Review — Update (3 new commits since last review)

Commits reviewed: 6c6264ad (Fix), da65af3e (Fix load on tokenCache), 4bccc9f4 (Skip validation of intent message)


Summary of Changes

These three commits address several issues from the previous iteration:

  1. Token cache overhaul (da65af3e): Replaced per-entry goroutine-based expiry (go func() { <-time.After(...); c.delete(hash) }()) with a proper sweep-based approach. This is a significant improvement — the old design would leak goroutines under high token creation volume.

  2. Bug fix in gRPC handler (6c6264ad): Fixed a shadowed error variable in GetVirtualTxs handler — parseTxids returned parseErr but the guard was checking err (from the outer scope), meaning parse errors were silently ignored.

  3. Intent message no longer required (4bccc9f4): parseIndexerIntentProof no longer requires message field. The intent Message field is now truly optional since ownership is proved by the PSBT structure itself.


Review Notes

✅ Token Cache Improvements (Looks Good)

  • sync.RWMutex is now a value type instead of pointer — correct, no need for indirection since tokenCache is always used via pointer receiver.
  • Sweep goroutine runs at invalidationDuration / 2 interval — reasonable choice for bounding memory.
  • Lazy expiry check in getOutpoints/getTxids provides correctness guarantee independent of sweep timing.
  • close() method properly signals the sweep goroutine to stop.
  • make(map[string]struct{}) instead of make(map[string]struct{}, 0) — minor but cleaner.

⚠️ Sweep goroutine lifecycle concern

The close() method exists but is never called anywhere in the codebase. Since tokenCache is embedded in indexerService, and indexerService has no Close/Shutdown method, the sweep goroutine will run for the lifetime of the process. This is acceptable for a long-running server like arkd, but worth noting:

  • If indexerService is ever used in tests that create many instances, this could leak goroutines. The test helper newTestIndexer creates caches without cleanup.
  • Consider adding defer c.close() in test teardown or a t.Cleanup call.

⚠️ Sweep loop iterates map values then breaks

for _, expiresAt := range outpoints {
    if now.After(expiresAt) {
        delete(c.outpointsByHash, hash)
    }
    break // all entries share the same expiresAt
}

The comment says "all entries share the same expiresAt" which is true for a single add() call. However, the break after the first iteration makes this a "check one, decide for all" pattern. This works correctly given the current add() semantics (all outpoints get the same expiresAt), but is fragile — if add() ever supports appending to existing entries, this logic would silently break.

✅ Bug Fix — parseErr vs err (Critical Fix)

The original code had:

txids, parseErr := parseTxids(request.GetTxids())
if err != nil {  // BUG: should be parseErr

This meant invalid txid input would pass through and potentially cause downstream panics or unexpected behavior. Good catch.

✅ Intent Message Made Optional

Removing the mandatory message field is the right call. The PSBT structure (inputs referencing real VTXOs with matching scripts/values) is what proves ownership — the message is just metadata. This aligns with the validateIntent implementation which already had this comment: "Message content is not validated here."

🔒 Security Notes

  • No new security concerns introduced in these commits.
  • The auth token signing/verification flow remains sound (Schnorr signatures over sha256(hash(outpoints) || timestamp)).
  • The IndexerSigningKey is properly masked in config String() output.

Overall: These are solid incremental fixes. The token cache redesign eliminates a goroutine leak vector, the parseErr bug fix is critical, and relaxing the intent message requirement simplifies the API surface correctly.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 1, 2026

Review of new commits (4bccc9f..8f98414 — altafan's latest push)

internal/core/application/indexer.govalid = true added in the exposurePublic case of GetVirtualTxs.

This is a necessary fix. Without it, the valid variable stays false in exposurePublic mode, meaning the function would fall through to the post-switch validation check and reject the request — even though public exposure should accept all callers unconditionally. The bug would effectively make public mode behave like private (denying access without a valid auth token).

Good catch. LGTM on this commit.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 1, 2026

🔍 Arkana PR Review — #904

Require Intent for GetVtxoChain/GetVirtualTxs — Stateless auth token system for VTXO chain/virtual tx access control

Summary

This PR introduces a configurable access control layer (ARKD_INDEXER_TX_EXPOSURE: public/withheld/private) over GetVtxoChain and GetVirtualTxs RPCs to prevent griefing attacks where a third party could trigger unilateral exits on VTXOs they do not own. The auth flow uses a Schnorr-signed, time-bounded token tied to specific outpoints, with an in-memory cache for token-to-outpoint mapping.

Architecture

The design is sound:

  • Stateless auth tokens: sha256(sorted_outpoints) || timestamp signed with a dedicated Schnorr key. No DB state needed for validation.
  • Token cache: Server-side map[hash]→outpoints with TTL-based eviction allows cross-RPC token reuse (GetVtxoChainGetVirtualTxs) without re-proving intent.
  • Three exposure modes map cleanly to different threat models:
    • public: backward-compatible, no auth
    • withheld: auth optional; unauthenticated callers get PSBTs with arkd signatures stripped
    • private: auth mandatory; no access without valid token

Security Analysis

Strengths:

  • Separate signing key (ARKD_INDEXER_SIGNING_PRIVKEY) from the server main key — rotation invalidates all outstanding tokens without affecting round signing
  • Intent validation reuses the existing ValidateIntentWithProof pattern (PSBT structure + VTXO ownership check)
  • Signature stripping in withheld mode correctly removes both TaprootKeySpendSig (musig tree txs) and TaprootScriptSpendSig entries matching the server pubkey
  • Config validation enforces signing key presence when exposure ≠ public
  • Signing key redacted in config String() output

Potential concerns:

  1. Token cache is in-memory only — On server restart, all outstanding tokens are invalidated even if they have not expired. Acceptable given the short TTL (default 5min), but worth documenting. Clients should be prepared to re-prove intent on "auth token not found" with a structurally valid token.

  2. withheld mode + invalid non-empty token returns error — In GetVirtualTxs, if a caller passes a non-empty but malformed token under withheld mode, validation fails with an error rather than falling back to signature stripping. Correct for format errors, but for expired tokens the caller gets a hard error rather than degraded access. Consider whether expired tokens under withheld should fall through to stripped mode.

  3. tokenCache.sweep() goroutine lifecyclenewTokenCache spawns a background goroutine. The close() method exists but is nolint-ed and never called from the indexer service. For long-running arkd this is fine, but test caches will leak goroutines. Adding t.Cleanup(c.close) in tests would be cleaner.

  4. tokenCache.add() early return on duplicate hash — TTL race ⚠️ — If the same outpoints hash is re-added, the existing entry (with its original expiry) is kept. A freshly-issued token could fail cache lookup if the original cache entry expired before the new token does. Consider updating the expiry on re-add or removing the early return guard.

  5. Intent proof: message field unusedIndexerIntent.Message is accepted but never validated or used. Confirming this is intentionally opaque here?

  6. Outpoint extraction asymmetryGetVtxoChainByIntent requires exactly 1 outpoint, while GetVirtualTxsByIntent accepts multiple. Makes sense but worth a comment in the proto for SDK implementers.

Cross-Repo Impact

  • go-sdk needs updates for new Intent and AuthToken fields on GetVtxoChain/GetVirtualTxs (noted in PR)
  • ts-sdk, rust-sdk, dotnet-sdk will need similar updates for non-public exposure modes
  • e2e tests currently only work with public exposure (acknowledged)

Test Coverage

Comprehensive unit tests covering: hashOutpoints determinism, auth token round-trip, expired/malformed/wrong-key rejection, all three exposure modes × token matrix for both RPCs, intent validation (amount/script mismatch, unknown VTXO fallback), signature stripping, token cache TTL, and PSBT tree chain building. Well done.

Nits

  • token_cache.go:53 — sweep loop breaks after first outpoint expiry check. Correct but add a comment explaining the shared-expiry assumption.
  • indexer.go — typo: "signature verififcation failed""signature verification failed"
  • Proto comments on GetVtxoChainRequest.token say "obtained from GetVtxoChain rpc" — should say "from a previous auth flow" or reference GetVirtualTxs
  • e2e_test.go changes are pure formatting (line wrapping) — inflates the diff

Verdict

Strong security feature addressing a real griefing vector. The stateless token design is elegant and the three-tier exposure model gives operators flexibility. Point 4 (cache TTL race on re-add) is the main item to address before merge.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 1, 2026

🔍 Arkana PR Review — Incremental Review (commits cf911c7..523a7ac)

Two new commits from altafan since last review — focused on test improvements and a correctness fix.

What changed

  1. newTestIndexer now accepts *testing.T — enables t.Cleanup(cache.close) on the token cache goroutine, preventing leaked goroutines across test runs. Good hygiene.

  2. New buildTreeTx helper + "strips sigs from tree tx" test — This is the most important addition. The previous test suite only covered TaprootScriptSpendSig stripping (offchain/virtual txs). The new test verifies that TaprootKeySpendSig (used by tree txs with musig key-spend paths) is also correctly nulled by stripSignerSignatures. This closes a test coverage gap — in withheld mode without a valid token, tree tx signatures would have been stripped at runtime but were untested.

  3. Removed tokenCache.delete method and its test — simplifies the cache API. Since entries are TTL-evicted (lazy check on read + periodic sweep), explicit delete was unused.

  4. Test naming/assertion cleanupbuildPSBTbuildOffchainTx, shorter require messages. No behavioral changes.

Security observations on the full PR (updated)

The overall auth token flow looks solid:

  • ✅ Schnorr-signed stateless tokens with TTL + outpoint binding
  • ✅ Token cache with both lazy and sweep-based expiry
  • ✅ Separate signing key (ARKD_INDEXER_SIGNING_PRIVKEY) decoupled from server signer — key rotation invalidates tokens cleanly
  • ✅ Private key masked in config String() output
  • withheld mode correctly strips both key-spend sigs (tree txs) and script-spend sigs (offchain txs) when no valid token is present

One minor nit (non-blocking): The sweep() goroutine checks the first entry's expiry and deletes the whole hash bucket. This is correct because all entries in a hash share the same expiresAt, but a brief comment explaining that invariant would help future readers.

Overall: clean incremental improvements. The tree tx sig-stripping test was a meaningful gap to fill. LGTM on the new commits.

Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/indexer.go Outdated
Comment thread internal/core/application/token_cache.go
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 2, 2026

🔍 Arkana PR Review — New Commits (dc730a3, f7bf8d1)

Two new commits from altafan since last review: makes intent message mandatory and adds the GetDataMessage intent type.

What changed (delta from 523a7acf7bf8d1)

  1. pkg/ark-lib/intent/message.go — New IntentMessageTypeGetData ("get-data") and GetDataMessage struct with Encode()/Decode() support. Follows the same pattern as existing intent message types.

  2. internal/interface/grpc/handlers/indexer.goparseIndexerIntent() now validates the message field is non-empty and decodes it as a GetDataMessage. This enforces that callers must provide a well-formed intent message (not just a proof).

  3. internal/core/application/indexer.go — Minor refactors in GetVtxoChainByIntent and GetVirtualTxsByIntent to align with the mandatory message requirement.

  4. internal/core/application/indexer_exposure_test.go — Tests updated to use the new GetDataMessage intent type.

Security Review

✅ Auth token scheme looks solid overall:

  • Schnorr-signed stateless tokens with timestamp + outpoint hash is a clean approach
  • TTL enforcement with both lazy expiry check and background sweep
  • Separate signing key (ARKD_INDEXER_SIGNING_PRIVKEY) properly decoupled from server's main key
  • Private key properly redacted in Config.String() output

✅ Intent validation:

  • Validates PSBT structure, checks VTXO existence in repo, verifies amounts/scripts match
  • Falls back to on-chain tx lookup for boarding inputs
  • Calls intent.Verify() with server pubkey to validate the proof signature

⚠️ Minor observations:

  1. GetDataMessage.Decode() receiver is value, not pointer (line ~185 of message.go):

    func (m GetDataMessage) Decode(data string) error {

    This means the decoded data is discarded. Should be *GetDataMessage to match the pattern used by DeleteMessage.Decode(), GetPendingTxMessage.Decode(), etc. The handler in indexer.go creates a local GetDataMessage and calls Decode() on it but never reads the fields back — so currently it only validates the JSON structure/type, which may be intentional. But it's inconsistent with other message types that use pointer receivers.

  2. GetDataMessage is missing GetExpireAt() and GetBaseMessage() methods — Other message types (DeleteMessage, GetIntentMessage) implement these. If GetDataMessage is meant to be used as an IntentMessage interface (if one exists), this could cause compile-time issues downstream. Currently it's only used for validation so this is non-blocking.

  3. Token cache add() silently no-ops on duplicate hash — If a token is created for the same set of outpoints but a different TTL/timestamp, the original expiry is preserved. This is fine for the current use case but worth noting: re-authenticating with a valid intent won't refresh the cache entry.

Cross-repo Impact

  • go-sdk needs updates: GetVtxoChain now requires an intent, GetVirtualTxs accepts auth tokens. The e2e tests note this — currently only public mode works without SDK changes.
  • ts-sdk, rust-sdk, dotnet-sdk will need analogous updates for non-public exposure modes.

Protocol Correctness

The three-tier exposure model (public/withheld/private) is well-designed:

  • Public: No auth required (backward compatible)
  • Withheld: Optional auth; signer sigs stripped from returned PSBTs if no valid token — prevents griefing while still allowing chain inspection
  • Private: Mandatory auth — strongest protection against third-party exit griefing

The signature stripping for withheld mode correctly targets both musig key-spend sigs and server taproot script-spend sigs, which prevents reconstruction of broadcastable transactions.

Overall: Solid anti-griefing improvement. The pointer receiver on GetDataMessage.Decode() is the main thing to fix.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 2, 2026

🔍 Arkana Review — #904

PR: Require Intent for GetVtxoChain/GetVirtualTxs
Author: @bitcoin-coder-bob | Files changed: 17 (+2662 / -203)
Reviewed at: 96ada89439aa634eb2c9fdcc8e33613dcdf65278


Overview

This PR adds intent-based access control to the indexer's GetVtxoChain and GetVirtualTxs RPCs with three configurable exposure modes (public, private, withheld). The design uses stateless schnorr-signed auth tokens over sorted-outpoint hashes + timestamps, with an in-memory token cache for outpoint/txid whitelist lookups. The withheld mode gracefully degrades by stripping the server's signatures from returned PSBTs when no valid token is provided.

The anti-griefing motivation is sound — preventing third parties from unilaterally exiting someone else's VTXOs is an important protection for the Ark user experience and smart contract safety.


🔴 Critical: Nil privkey panic for non-public modes

internal/config/config.go — If ARKD_INDEXER_SIGNING_PRIVKEY is empty and ARKD_INDEXER_TX_EXPOSURE is set to private or withheld, privkey will be nil when passed to NewIndexerService. The service will start without error, but the first call to createAuthTokenschnorr.Sign(i.authPrvkey, msgHash) will panic with a nil pointer dereference.

Recommendation: Either:

  • Validate at startup that a signing key is provided when exposure ≠ public, or
  • Auto-generate an ephemeral key when none is provided (with a log warning that tokens won't survive restarts).
// In config.go IndexerService():
if privkey == nil && c.IndexerExposure != "public" {
    return nil, fmt.Errorf("ARKD_INDEXER_SIGNING_PRIVKEY is required when INDEXER_EXPOSURE is %q", c.IndexerExposure)
}

🟡 Medium Issues

1. Token cache vs. token validity mismatch after restart
Auth tokens are cryptographically valid (signature checks pass) but the cache lookup for outpoints/txids will fail after a server restart. In private mode, GetVtxoChain will return "auth token not found" even though the signature is correct and unexpired. This is safe but will be confusing for clients. Consider documenting this behavior, or detecting the case and returning a more specific error like "token valid but cache expired — re-authenticate with intent".

2. withheld mode in GetVirtualTxs — silent fallback on cache miss
When a valid auth token is provided but the cache entry is missing (line ~case exposureWithheld: in GetVirtualTxs), the code breaks out with valid = false and strips signatures. A user who thinks they have a valid token gets stripped PSBTs with no indication of why. Consider returning the authToken in the response or logging a warning.

3. Incomplete comment in GetVirtualTxs

// Helpers to
var valid bool

This looks like a leftover from a draft.

4. stripSignerSignatures strips ALL TaprootKeySpendSigs unconditionally
For tree txs, the code strips TaprootKeySpendSig without checking if it belongs to the server. If a non-server key-spend signature were ever present (unlikely currently, but possible with future protocol changes), it would be incorrectly stripped. Consider comparing against the signer pubkey first.

5. No rate limiting on intent validation
Each intent validation involves PSBT parsing + database lookups for every input outpoint. Under private/withheld modes, this is an unauthenticated endpoint that could be used for DoS. Consider rate-limiting or adding lightweight pre-validation.


🟢 Minor / Nits

  • Typo at line in validateAuthToken: "signature verififcation failed""signature verification failed"
  • Typo in two places: "mandatatory""mandatory"
  • README.md references ARKD_INDEXER_TX_EXPOSURE but the env var in config.go is ARKD_INDEXER_EXPOSURE — these should match
  • tokenCache.sweep() — the break after checking the first entry's expiry works because all entries share the same timestamp, but the pattern is fragile. A comment explaining this invariant would help future readers.
  • tokenCache.add() has a redundant nil-map check — the preceding if _, ok := ... guard already returns if the key exists, so the map is guaranteed to be non-nil (initialized in newTokenCache).

✅ Positives

  • Clean separation of auth key from server signing key — allows independent rotation
  • The oneof auth protobuf design is ergonomic for clients
  • Comprehensive test coverage in indexer_exposure_test.go (1158 lines!)
  • The signature-stripping approach for withheld mode is clever — degrades gracefully without rejecting requests
  • Good use of deterministic outpoint hashing (sorted, canonical encoding)

Cross-Repo Impact

As noted in the PR description, this requires go-sdk changes for the new Intent and AuthToken fields on GetVtxoChain and GetVirtualTxs. E2E tests will only pass with public exposure until the SDK is updated. Other SDKs (ts-sdk, rust-sdk, dotnet-sdk) will also need updates to support private/withheld modes.


Review by Arkana 🔍 — [Agent: pr-reviewer]

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Apr 2, 2026

🔍 Arkana PR Review — #904

PR: Require Intent for GetVtxoChain/GetVirtualTxs
Author: bitcoin-coder-bob + altafan
Closes: #843

Summary

This PR adds intent-based access control to the indexer service, preventing unauthorized parties from retrieving VTXO chain data and constructing broadcastable transactions. Three exposure modes are introduced:

  • public — No auth required (current behavior, default)
  • withheld — Auth optional; without it, server signatures are stripped from returned PSBTs
  • private — Auth mandatory; requests without valid tokens are rejected

Auth tokens are stateless schnorr-signed payloads (hash(outpoints) | timestamp | sig), with an in-memory cache for outpoint/txid whitelisting.

Architecture Assessment ✅

The design is sound:

  • Separate signing key for indexer auth tokens (not reusing the server's main key) — good isolation
  • Stateless token format with TTL — avoids DB round-trips for validation
  • Intent proof reuses the existing ValidateIntentWithProof flow — consistency with the service layer
  • oneof auth in proto cleanly separates intent vs token auth paths
  • Token recycling between GetVtxoChainGetVirtualTxs reduces friction

Security Observations

  1. Signature stripping logic (stripSignerSignatures): Strips both TaprootKeySpendSig (musig for tree txs) and TaprootScriptSpendSig entries matching the server's pubkey. This is the critical path for withheld mode — looks correct. The key-spend path strips unconditionally (any musig sig), which is appropriate since tree txs always involve the server.

  2. Token cache concurrency: Uses sync.RWMutex correctly. The sweep() goroutine uses c.mu.Lock() (write lock) while readers use c.mu.RLock(). The break after checking the first entry's expiry in getOutpoints/getTxids is safe because all entries in a hash group share the same expiry time. ✅

  3. Token format (hash(32) | timestamp(8) | schnorr_sig(64) = 104 bytes): Clean and minimal. Timestamp in the signed payload prevents replay with different expiry windows.

  4. ARKD_INDEXER_SIGNING_PRIVKEY env var: Correctly redacted in Config.String(). Config validation ensures it's required for non-public exposure modes. ✅

Issues & Suggestions

🔴 Potential auth bypass in GetVtxoChain withheld mode:
In the exposureWithheld case of GetVtxoChain, when authToken is provided but the hash is not found in the token cache (!ok), the code returns an error "auth token not found". This is inconsistent with the stated design where withheld mode should be optional auth — a valid-but-expired token (signature passes, but cache evicted) would block the request rather than falling through to return data without an auth token. Consider: if the token validates (signature OK, not expired) but the cache miss happens (race with sweep), should this be a hard error or a graceful fallback?

🟡 Token cache memory growth:
The cache stores all outpoints for every authenticated request. Under high load with many unique VTXO chains, this could grow significantly. The sweep interval is TTL/2, and entries are only checked by sampling the first outpoint's expiry. This is fine for moderate traffic, but consider adding a max-entries cap or LRU eviction for production hardening.

🟡 GetVtxoChainByIntent restricts to single outpoint:

if len(outpoints) > 1 {
    return nil, fmt.Errorf("only one outpoint expected in intent proof")
}

This makes sense for the current use case but should be documented in the proto comments — callers might expect multi-outpoint intent proofs to work here since GetVirtualTxsByIntent handles multiple outpoints.

🟡 parseIndexerIntent double-parses the PSBT:
The gRPC handler parses the PSBT to validate format, then the application layer parses it again in extractOutpointsFromIntent and validateIntent. Consider passing the parsed PSBT down to avoid redundant deserialization.

🟢 Minor: README typo:

| `ARKD_INDEXER_EXPOSURE`.           |

There's a stray . after EXPOSURE in the markdown table.

Cross-Repo Impact

  • go-sdk: Requires changes to GetVtxoChain (new Intent param) and GetVirtualTxs (new AuthToken param). E2E tests without SDK changes only work with public exposure. This should be tracked.
  • Other SDKs (ts-sdk, rust-sdk, dotnet-sdk): Will need equivalent updates to support non-public exposure modes.
  • Wallet app: Will need to construct intent proofs when connecting to servers with private/withheld exposure.

Test Coverage

Comprehensive test suite:

  • TestHashOutpoints — deterministic ordering ✅
  • TestAuthToken — round-trip, expiry, wrong signer ✅
  • TestGetVirtualTxs — all exposure × token combinations ✅
  • TestTokenCache — add, get, expiry, dedup ✅
  • TestStripSignerSignatures — verifies server sigs are removed while user sigs remain ✅
  • E2E tests updated for the new API shape ✅

Verdict

Strong security improvement that addresses a real griefing vector. The three-tier exposure model is well-designed and the implementation is clean. The auth bypass edge case in withheld mode (🔴 above) should be clarified before merge. The other items are non-blocking improvements.


Review by Arkana 🔍 • [Agent: pr-reviewer]

Comment thread internal/core/application/indexer.go
) (*VirtualTxsResp, error)
GetVirtualTxsByIntent(ctx context.Context, intent Intent, page *Page) (*VirtualTxsResp, error)
GetBatchSweepTxs(ctx context.Context, batchOutpoint Outpoint) ([]string, error)
GetAsset(ctx context.Context, assetID string) ([]Asset, error)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would it be worth adding a RevokeToken endpoint that deletes the cache entry keyed by the outpoint hash so the owner does not have to wait for the expiry if they know it leaked?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we'll do this in a dedicated pr since it's an admin endpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was thinking it would be the intent issuer that would have the ability to revoke the token

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

no revoking is something that only admin should be able to do

return nil, err
}

token, err := i.createAuthToken(allOutpoints)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we check for a non-expired token cache entry before creating another one? An attacker with a valid proof could spam the endpoint and create millions of cache entries. This could DoS the indexer endpoints due to the mutex lock in the cache sweep task iterating through all the tokens.

If you check the cache for a non-expired entry for the same outpoints it is a form of rate limiting on cache growth.

Same for GetVirtualTxsByIntent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

on it. I'm gonna change createAuthToken so that if token already exists, the function uses the existing expiration. This way, we make the endpoint always return the same token if it already exists.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fixed in 35fc69b

Comment thread internal/core/application/token_cache.go
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.

Require Intent for GetVtxoChain/GetVirtualTxs

4 participants