Skip to content

Conversation

@aruokhai
Copy link
Contributor

@aruokhai aruokhai commented Nov 18, 2025

Arkade Asset Offchain Flow

This pull request implements the initial offchain asset processing flow for
Arkade Asset #855.

The goal of this change is to introduce the minimal primitives and logic required to represent,
validate, and index Arkade Assets in the offchain domain, forming the basis for later
on-chain flow.

Scope of Change

  • Asset Encoding Logic
    Canonical encoding and serialization rules for Arkade Asset objects.
  • Asset Validation and Transition Logic
    Structural and semantic validation of asset definitions and transitions.
  • Asset Indexing Logic
    Storage and lookup of asset states and ownership.

Status

This PR establishes the first off-chain asset pipeline and is intentionally minimal.
Further work will extend this layer with on-chain flows.

Summary by CodeRabbit

  • New Features

    • Full asset subsystem: asset groups, metadata, quantities, teleport assets; VTXOs now surface associated asset info.
    • Persistent asset store wired across datastores.
  • API Enhancements

    • New asset retrieval endpoint and teleport-aware subscription events.
    • Intents now carry transaction IDs.
  • Bug Fixes

    • More robust OP_RETURN/sub-dust parsing and stricter asset validation.
  • Tests

    • Expanded tests for asset encoding/decoding and transaction rebuilds.
  • Chores

    • Added local run target for easier local runs.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 18, 2025

Walkthrough

Adds end-to-end asset support: TLV asset packets and OpReturn extension, asset encoding/decoding, validation state machines, domain models and repositories (sqlite/postgres/badger) with migrations, service/indexer/gRPC integration (GetAssetGroup, teleport events), asset-aware tx building/forfeit verification, script helpers, tests, and new error codes.

Changes

Cohort / File(s) Summary
Build / Tooling
Makefile, buf.Dockerfile
Added run-local make target; minor Dockerfile/buf URL/case changes.
API Specs & Protos
api-spec/openapi/.../indexer.openapi.json, api-spec/openapi/.../service.openapi.json, api-spec/openapi/.../types.openapi.json, api-spec/protobuf/ark/v1/indexer.proto, api-spec/protobuf/ark/v1/types.proto
New Asset/AssetGroup/TeleportEvent schemas; added GET /v1/indexer/assetGroup and GetAssetGroup RPC; Vtxo now includes assets; subscription payloads extended with teleportEvents/teleportHashes.
Extension & Encoding
pkg/ark-lib/extension/... (extension.go, asset.go, encoding.go, subdust.go, extension_test.go)
New ExtensionPacket and AssetPacket TLV encoding/decoding, AssetGroup TLV, SubDust packet support, utilities to derive asset packet from tx, comprehensive tests.
Core Domain & Types
internal/core/domain/*.go, internal/core/application/types.go, internal/core/domain/vtxo.go
New domain types: Asset, AssetGroup, NormalAsset, AssetAnchor, TeleportAsset; Vtxo.Assets; Intent.Txid and Receiver.AssetId; TeleportAsset/AssetGroup response types.
Repository Interfaces & Wiring
internal/core/ports/repo_manager.go, internal/infrastructure/db/service.go
Added RepoManager.Assets(), wired assetStore into service with accessor, data-store factory entries.
DB Implementations & Migrations
internal/infrastructure/db/{postgres,sqlite,badger}/asset_repo.go, .../migration/*, .../sqlc/*
New AssetRepository implementations for Badger/Postgres/SQLite, SQL migrations to create asset_group/asset_anchor/asset/asset_metadata/teleport_asset, many new SQLC queries/models for asset CRUD, teleport ops, UpsertIntent txid and SelectIntentByTxid.
Application Logic
internal/core/application/service.go, internal/core/application/indexer.go, internal/core/application/asset_validation.go, internal/core/application/utils.go
Asset decoding/validation state machines and helpers (unexported), GetAssetGroup service method, Vtxo enrichment with AssetExtension, teleport asset derivation and propagation, new public GetIntentByTxid and TransactionEvent.TeleportAssets.
Indexer & gRPC Handlers
internal/interface/grpc/handlers/indexer.go, internal/interface/grpc/handlers/parser.go, internal/interface/grpc/permissions/permissions.go
Teleport subscription plumbing and safe non-blocking dispatch, GetAssetGroup gRPC handler, mapper for Vtxo assets, permission whitelist entry for GetAssetGroup.
Tx Building & Forfeit Flow
pkg/ark-lib/tree/forfeit_tx.go, pkg/ark-lib/tree/builder.go, internal/infrastructure/tx-builder/covenantless/*, internal/infrastructure/tx-builder/covenantless/utils.go
Forfeit builders support optional extension anchor (BuildForfeitTxWithAnchor / BuildForfeitTxWithOutputAndAnchor), verifyAssetForfeitTransaction added, leaf outputs support multiple outputs, asset-aware vtxo handling.
Script / VTXO Utilities
pkg/ark-lib/script/*, pkg/ark-lib/tree/*
IsSubDustScript rewritten to use tokenizer; added NewTeleportVtxoScript; leaf struct now holds multiple outputs and sum logic updated.
Intent / Offchain libs & Tests
pkg/ark-lib/intent/*, pkg/ark-lib/offchain/tx_test.go
AssetOutput type added; Intent input/outpoint adjustments; new tests validating rebuild of asset transactions.
Errors
pkg/errors/errors.go
New asset-related error codes and metadata (ASSET_*, TELEPORT_VALIDATION_FAILED, ASSET_PACKET_INVALID); INTENT_FEE_EVALUATION_FAILED renumbered; INTENT_NOT_FOUND added.
Live-store API rename
internal/core/ports/live_store.go, internal/infrastructure/live-store/*, tests
ForfeitTxsStore.Sign renamed to Verify across interface, impls, and tests.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Service
    participant AssetValidator
    participant Repo
    participant DB

    Client->>Service: SubmitOffchainTx(intent, psbt)
    activate Service

    Service->>AssetValidator: Decode & validate asset packet(s)
    activate AssetValidator
    AssetValidator->>Repo: Query/GetAssetGroup / GetAssetByOutpoint
    activate Repo
    Repo->>DB: SQL queries (asset_group / anchors / metadata / teleport_asset)
    DB-->>Repo: rows
    deactivate Repo
    AssetValidator-->>Service: validation result (ok / error)
    deactivate AssetValidator

    alt validation OK
      Service->>Repo: InsertAssetGroup / InsertAssetAnchor / CreateTeleportAsset
      Repo->>DB: insert/update SQL
      DB-->>Repo: OK
      Service-->>Client: success
    else validation FAIL
      Service-->>Client: error (ASSET_* codes)
    end
    deactivate Service
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

  • Arkade Assets #855: Implements the core "Arkade Assets" featureset (asset TLV/extension, asset repos, indexer, teleport, validation), which aligns with this PR's changes.

Possibly related PRs

Suggested reviewers

  • Kukks
  • altafan
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Arkade Asset Exploration' is vague and generic, using non-descriptive terminology that does not convey meaningful information about the changeset. Use a more specific, descriptive title that reflects the primary change (e.g., 'Implement off-chain asset validation and indexing flow' or 'Add asset encoding, validation, and storage infrastructure').
✅ Passed checks (1 passed)
Check name Status Explanation
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 docstrings

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.

Comment on lines 50 to 52
assetData = append(assetData, a.genesisTxId...)
assetData = append(assetData, batchTxId...)
assetData = append(assetData, encodedTlv...)
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe we should encode these as var len bytes like len(buf)+buf?

}

func (a *Asset) encodeTlv() ([]byte, error) {
var tlvRecords []tlv.Record
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we really need this external dep tls.Record to serilalize/deserialize asset data?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can always implement the encoding and decoding logic using TLV schematics, which @Kukks recommended in the Arkade Asset docs https://github.com/ArkLabsHQ/arkade-assets/blob/master/arkade-assets.md#3-packet-format-canonical-tlv , but the tlv library provides a fast way of doing it, but in the future it can always be removed for custom implementation

Comment on lines 13 to 22
tlvTypeAssetID tlv.Type = 1
tlvTypeOutput tlv.Type = 2
tlvTypeControlPubkey tlv.Type = 3
tlvTypeInput tlv.Type = 4
tlvTypeMetadata tlv.Type = 6
tlvTypeOutScriptPubKey tlv.Type = 7
tlvTypeInTxid tlv.Type = 8
tlvTypeOutAmount tlv.Type = 9
tlvTypeInVout tlv.Type = 10
tlvTypeImmutable tlv.Type = 11
Copy link
Collaborator

Choose a reason for hiding this comment

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

use iota:

Suggested change
tlvTypeAssetID tlv.Type = 1
tlvTypeOutput tlv.Type = 2
tlvTypeControlPubkey tlv.Type = 3
tlvTypeInput tlv.Type = 4
tlvTypeMetadata tlv.Type = 6
tlvTypeOutScriptPubKey tlv.Type = 7
tlvTypeInTxid tlv.Type = 8
tlvTypeOutAmount tlv.Type = 9
tlvTypeInVout tlv.Type = 10
tlvTypeImmutable tlv.Type = 11
tlvTypeAssetID tlv.Type = iota
tlvTypeOutput
tlvTypeControlPubkey
tlvTypeInput
tlvTypeMetadata
tlvTypeOutScriptPubKey
tlvTypeInTxid
tlvTypeOutAmount
tlvTypeInVout
tlvTypeImmutable

- Update Receiver struct to include AssetAmount, AssetId, and AssetTeleport fields.
- Modify RegisterIntent method to handle asset outputs and seals.
- Enhance asset output retrieval and encoding in transaction creation.
- Introduce ArkAssetSealVtxoField for marking asset seal inputs in PSBT.
- Refactor tree builder to accommodate multiple outputs, including asset scripts.
… struct; update transaction building and tests accordingly
…xosLeaves; add logging for script generation
…issuance and burning; update teleport pubkey comparison in utils
…set handling and introduce byte reversal utility
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

Caution

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

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

246-263: Bug: Asset enrichment happens after pagination but modifies wrong slice.

The enrichment loop at lines 248-258 modifies allVtxos[j], but vtxos (returned at line 261) was already sliced from allVtxos at line 246 before enrichment. Since slice elements are copied by value, the enriched extensions won't appear in the returned vtxos slice.

Either enrich before pagination or iterate over vtxos instead of allVtxos.

Proposed fix: enrich before pagination
-	vtxos, pageResp := paginate(allVtxos, page, maxPageSizeSpendableVtxos)
-
-	for j, v := range allVtxos {
+	for j, v := range allVtxos {
 		// add asset to vtxo if present
 		asst, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint)
 		if err == nil && asst != nil {
 			assetExtension := domain.AssetExtension{
 				AssetID: asst.AssetID,
 				Amount:  asst.Amount,
 			}
 			allVtxos[j].Extensions = append(allVtxos[j].Extensions, assetExtension)
 		}
 	}
+
+	vtxos, pageResp := paginate(allVtxos, page, maxPageSizeSpendableVtxos)
 
 	return &GetVtxosResp{
internal/infrastructure/db/service.go (1)

219-289: Missing asset store creation for Postgres.

The postgres case creates stores for round, vtxo, scheduledSession, offchainTx, conviction, and intentFees, but does not create the asset store. Compare with sqlite (lines 350-353) and badger (lines 211-214) cases which both create assetStore.

Proposed fix: add asset store creation for postgres
 		intentFeesStore, err = intentFeesStoreFactory(db)
 		if err != nil {
 			return nil, fmt.Errorf("failed to create intent fees store: %w", err)
 		}
+		assetStore, err = assetStoreFactory(db)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create asset store: %w", err)
+		}
 	case "sqlite":
🤖 Fix all issues with AI agents
In `@api-spec/protobuf/ark/v1/indexer.proto`:
- Around line 78-83: Update the protobuf doc comments to match the RPC names and
fix typos: change the comment that incorrectly says "GetAsset" to
"GetAssetGroup" and ensure it describes returning asset group information for
the specified asset ID (for rpc GetAssetGroup and messages
GetAssetGroupRequest/GetAssetGroupResponse), and correct any misspelling of
"subscribtion" to "subscription" in the other RPC doc comments (the similar typo
around the RPCs at the later block referenced by lines 110-112).

In `@internal/core/application/asset_validation.go`:
- Around line 339-369: The validateInput branch for AssetTypeTeleport
dereferences intent.Proof without checking if intent is nil; update
validateInput to check the return from s.repoManager.Rounds().GetIntentByTxid
(the intent variable) for nil before using intent.Proof, and if nil return a
TELEPORT_VALIDATION_FAILED error (using errors.TELEPORT_VALIDATION_FAILED.New
with a clear message like "intent not found for teleport input validation" and
WithMetadata(errors.TeleportValidationMetadata{})); keep the existing error
handling for GetIntentByTxid failures and proceed to decode intent.Proof only
when intent is non-nil so decodedProof and subsequent calls
(s.validateTeleportInput) cannot panic.

In `@internal/core/application/indexer.go`:
- Around line 274-284: The loop over allVtxos is appending a
domain.AssetExtension with Amount incorrectly hardcoded to 0; update the
creation of assetExtension in the block where
i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint) returns asst so that
AssetExtension.Amount is set to asst.Amount (use the actual asset amount) before
appending to allVtxos[j].Extensions; ensure you reference allVtxos,
i.repoManager.Assets().GetAssetByOutpoint, and domain.AssetExtension when making
the change.

In `@pkg/errors/errors.go`:
- Around line 381-443: ASSET_NOT_FOUND and INTENT_NOT_FOUND currently share the
same numeric code (34); change INTENT_NOT_FOUND's numeric ID to an unused value
(e.g., 45) in its Code literal so all error codes are unique. Update the
INTENT_NOT_FOUND declaration (the Code[any] composite literal) to use the new
integer while keeping its name and grpc code intact.
♻️ Duplicate comments (20)
internal/infrastructure/db/service_test.go (1)

1706-1743: Avoid asset group ID collisions across subtests.

Subtests share the same DB; "asset-1" is already inserted earlier and can cause a PK conflict here. Use a unique ID (or reset state between subtests).

🐛 Suggested fix
-		asset := domain.AssetGroup{
-			ID:        "asset-1",
+		asset := domain.AssetGroup{
+			ID:        "asset-quantity-test",
internal/core/application/service.go (5)

1131-1138: Asset storage failures are still best-effort.

storeAssetDetailsFromArkTx errors are logged but the offchain tx is accepted, which can desync asset state. Decide and enforce the intended semantics.

🐛 One possible fix (fail the acceptance)
-	if assetOutputIndex >= 0 {
-		if err := s.storeAssetDetailsFromArkTx(
-			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
-		); err != nil {
-			log.WithError(err).Errorf(
-				"failed to store asset details for offchain tx %s", txid,
-			)
-		}
-	}
+	if assetOutputIndex >= 0 {
+		if err := s.storeAssetDetailsFromArkTx(
+			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
+		); err != nil {
+			return nil, errors.INTERNAL_ERROR.New(
+				"failed to store asset details for offchain tx %s: %w", txid, err,
+			)
+		}
+	}

1611-1619: Dead error check after ignoring GetArkPsbtFields error.

err is discarded and then checked, which is stale/always-nil.

🐛 Suggested fix
-		// we ignore error cause sometimes the taproot tree is not required
-		taptreeFields, _ := txutils.GetArkPsbtFields(
+		// we ignore error cause sometimes the taproot tree is not required
+		taptreeFields, err := txutils.GetArkPsbtFields(
 			&proof.Packet, i+1, txutils.VtxoTaprootTreeField,
 		)
 
-		if err != nil {
+		if err != nil {
 			return "", errors.INVALID_PSBT_INPUT.New(
 				"failed to get asset seal field for input %d: %w", i+1, err,
 			).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
 		}

1928-1967: Teleport asset persistence still ignores errors and uses loop index.

Insert errors are logged but do not fail registration, and OutputIndex uses i rather than the encoded vout.

🐛 Suggested fix
-				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
-					IntentID:    intent.Id,
-					Script:      teleportScript,
-					OutputIndex: uint32(i),
-					AssetID:     asst.AssetId.ToString(),
-					Amount:      output.Amount,
-					IsClaimed:   false,
-				}); err != nil {
-					log.WithError(err).Warn("failed to insert teleport asset")
-				}
+				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
+					IntentID:    intent.Id,
+					Script:      teleportScript,
+					OutputIndex: uint32(output.Vout),
+					AssetID:     asst.AssetId.ToString(),
+					Amount:      output.Amount,
+					IsClaimed:   false,
+				}); err != nil {
+					return "", errors.INTERNAL_ERROR.New("failed to insert teleport asset: %w", err)
+				}

4488-4509: Surface insufficient-balance errors on quantity decrease.

DecreaseAssetGroupQuantity can no-op if quantity is insufficient; callers should get a deterministic error.


4533-4538: Teleport events should use the real output vout.

OutputVout should reflect assetOut.Vout, not the loop index.

🐛 Suggested fix
-					OutputVout:     uint32(outIdx),
+					OutputVout:     assetOut.Vout,
internal/core/application/asset_validation.go (3)

431-465: Enforce non-zero asset amounts.

Inputs/outputs should reject zero amounts per spec.

🐛 Suggested fix
 		case assetOutputInit:
 			m.sumInputs = sumAssetInputs(m.assetGp.Inputs)
 			m.sumOutputs = sumAssetOutputs(m.assetGp.Outputs)
 			m.teleportInputs = make(map[string]uint64)
 			m.localScriptAmts = make(map[string]uint64)
 			m.processed = 0
 			for _, in := range m.assetGp.Inputs {
+				if in.Amount == 0 {
+					return errors.ASSET_INPUT_INVALID.New("asset input amount must be > 0").
+						WithMetadata(errors.AssetInputMetadata{})
+				}
 				if in.Type == extension.AssetTypeTeleport {
 					teleportScriptHex := hex.EncodeToString(in.Witness.Script[:])
 					m.teleportInputs[teleportScriptHex] += in.Amount
 				}
 			}
 			m.state = assetOutputCollect
 		case assetOutputCollect:
 			for _, assetOut := range m.assetGp.Outputs {
+				if assetOut.Amount == 0 {
+					return errors.ASSET_OUTPUT_INVALID.New("asset output amount must be > 0").
+						WithMetadata(errors.AssetOutputMetadata{})
+				}
 				switch assetOut.Type {

567-574: Nil AssetId check needed before dereference.

Issuance groups can have a nil AssetId; dereferencing will panic.

🐛 Suggested fix
-			if assetOutput.Type == extension.AssetTypeTeleport &&
+			if assetGroup.AssetId != nil &&
+				assetOutput.Type == extension.AssetTypeTeleport &&
 				bytes.Equal(assetOutput.Script, script) &&
 				assetId == *assetGroup.AssetId &&
 				index == uint32(i) {

595-654: Asset input matching should be scoped to asset ID/type.

Current matching scans all outputs across all groups, allowing cross-group or teleport outputs to satisfy a local input.

internal/core/application/utils.go (2)

219-229: Match assets by outpoint, not filtered slice index.

outs excludes anchor outputs, so indexing by VOut can skip or mis-attach assets when the slice is filtered. Build a vout -> outs index map before attaching extensions to avoid silent drops.

🔧 Suggested fix
-	// Add AssetGroup if Present
-	for _, asst := range assetList {
-		idx := int(asst.VOut)
-		if idx < 0 || idx >= len(outs) {
-			continue
-		}
-
-		outs[idx].Extensions = append(outs[idx].Extensions, domain.AssetExtension{
-			AssetID: asst.AssetID,
-			Amount:  asst.Amount,
-		})
-	}
+	// Add AssetGroup if Present - match by outpoint
+	outIndexByVout := make(map[uint32]int, len(outs))
+	for i := range outs {
+		outIndexByVout[outs[i].Outpoint.VOut] = i
+	}
+	for _, asst := range assetList {
+		idx, ok := outIndexByVout[asst.VOut]
+		if !ok {
+			continue
+		}
+		outs[idx].Extensions = append(outs[idx].Extensions, domain.AssetExtension{
+			AssetID: asst.AssetID,
+			Amount:  asst.Amount,
+		})
+	}

195-198: Guard P2TR script slicing to avoid panics on short/non-P2TR scripts.

out.PkScript[2:] assumes a 34-byte P2TR script; short or non-P2TR scripts can panic or yield bogus keys. Add a length/type guard before slicing.

🔒 Suggested guard
 	} else {
+		if len(out.PkScript) < 34 {
+			continue // skip non-P2TR outputs
+		}
 		pubKey = hex.EncodeToString(out.PkScript[2:])
 		isSubDust = script.IsSubDustScript(out.PkScript)
 	}
internal/infrastructure/db/sqlite/sqlc/query.sql (2)

422-427: Prevent silent no-op on AddAsset asset_id mismatch.

With ON CONFLICT ... WHERE asset.asset_id = excluded.asset_id, a mismatch yields zero rows updated with no error. This can silently drop writes. Consider :execrows and treat 0 rows affected as a conflict, or change the constraint/update semantics.

🔧 Suggested SQLC change (and handle rows affected)
--- name: AddAsset :exec
+-- name: AddAsset :execrows
 INSERT INTO asset (anchor_id, asset_id, vout, amount)
 VALUES (?, ?, ?, ?)
 ON CONFLICT(anchor_id, vout)
 DO UPDATE SET amount = excluded.amount
 WHERE asset.asset_id = excluded.asset_id;

460-463: Surface insufficient-quantity failures in SubtractFromAssetQuantity.

The guarded update can return 0 rows without error when quantity is insufficient or the group is missing. Use :execrows and check rows affected to return a domain error.

🔧 Suggested SQLC change (and handle rows affected)
--- name: SubtractFromAssetQuantity :exec
+-- name: SubtractFromAssetQuantity :execrows
 UPDATE asset_group
 SET quantity = quantity - ?
 WHERE id = ? AND quantity >= ?;
internal/infrastructure/db/postgres/sqlc/queries/query.sql.go (2)

16-38: Prevent silent no-op on AddAsset (generated SQLC).

ON CONFLICT ... WHERE asset.asset_id = EXCLUDED.asset_id can result in zero rows updated with no error. Update the source SQL to :execrows and treat 0 rows affected as a conflict (then regenerate).


1969-1983: Surface insufficient-quantity failures in SubtractFromAssetQuantity (generated SQLC).

The guarded update can silently succeed with zero rows; use :execrows and check rows affected in the repo layer (then regenerate).

internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go (2)

14-37: Prevent silent no-op on AddAsset (generated SQLC).

With the guarded ON CONFLICT clause, an asset_id mismatch results in zero rows updated without an error. Fix in the source SQL (:execrows) and handle 0 rows affected, then regenerate.


2036-2051: Surface insufficient-quantity failures in SubtractFromAssetQuantity (generated SQLC).

The guard can yield 0 rows with no error, masking insufficient balance or missing group. Switch to :execrows in the source SQL and handle the rows-affected result.

api-spec/openapi/swagger/ark/v1/indexer.openapi.json (1)

8-24: Path parameter naming mismatch still exists.

The path uses {asset_id} (snake_case) but the parameter name is assetId (camelCase). This inconsistency may cause routing issues depending on the framework.

internal/interface/grpc/handlers/indexer.go (2)

61-66: Still missing nil check for resp.AssetGroup.

While the code now checks resp == nil at line 61, if the service returns a non-nil response with a nil AssetGroup, accessing resp.AssetGroup.Metadata at line 66 will panic.

Proposed fix
 	if resp == nil {
 		return nil, status.Errorf(codes.NotFound, "asset not found: %s", assetId)
 	}
+	if resp.AssetGroup == nil {
+		return nil, status.Errorf(codes.NotFound, "asset group not found for id %s", assetId)
+	}

 	assetMetadata := make([]*arkv1.AssetMetadata, 0)

486-494: Both listener channels could still be nil, causing unexpected behavior.

If neither a script subscription nor a teleport subscription exists for the given subscriptionId, both scriptCh and teleportCh will be nil. While the select won't technically block forever (timer and context.Done work), this silently succeeds when no subscriptions exist.

Proposed fix
 	scriptCh, err := h.scriptSubsHandler.getListenerChannel(subscriptionId)
 	if err != nil && !strings.Contains(err.Error(), "listener not found") {
 		return status.Error(codes.Internal, err.Error())
 	}

 	teleportCh, err := h.teleportSubsHandler.getListenerChannel(subscriptionId)
 	if err != nil && !strings.Contains(err.Error(), "listener not found") {
 		return status.Error(codes.Internal, err.Error())
 	}

+	if scriptCh == nil && teleportCh == nil {
+		return status.Error(codes.NotFound, "no active subscriptions found for id")
+	}
+
 	// create a Timer that will fire after one heartbeat interval
🧹 Nitpick comments (9)
internal/infrastructure/db/postgres/migration/20260114000000_add_intent_txid.up.sql (1)

1-2: Add a txid index (and optional uniqueness) to match rollback and lookups.

The down migration drops idx_intent_txid, but the up migration doesn’t create it. If GetIntentByTxid is used, an index avoids full scans and keeps the migration pair symmetric.

💡 Suggested migration addition
 ALTER TABLE intent
 ADD COLUMN txid TEXT;
+
+CREATE INDEX idx_intent_txid ON intent (txid);
+-- If txid should be 1:1 with intent, consider UNIQUE instead.
api-spec/protobuf/ark/v1/service.proto (1)

153-157: Confirm txid is actually bindable on GET.

Please verify that meshapi.gateway.http supports mapping oneof query parameters (e.g., /v1/intent?txid=...). If not, consider an explicit path binding (e.g., /v1/intent/{txid}) or a non-oneof request field.

internal/infrastructure/db/sqlite/migration/20260114000000_add_intent_txid.up.sql (1)

1-2: Add a txid index to match rollback and lookup usage.

The down migration removes idx_intent_txid, but the up migration doesn’t create it. Consider adding the index to prevent full scans on txid lookups.

💡 Suggested migration addition
 ALTER TABLE intent
 ADD COLUMN txid TEXT;
+
+CREATE INDEX idx_intent_txid ON intent (txid);
internal/infrastructure/db/postgres/intent_txid_migration_test.go (2)

20-27: Consider adding table cleanup to improve test isolation.

The test creates tables but doesn't drop them in t.Cleanup. This could cause issues if tests are run multiple times or in parallel.

♻️ Suggested cleanup addition
 	t.Cleanup(func() {
+		// Drop test tables to ensure clean state for subsequent runs
+		//nolint:errcheck
+		db.Exec(`DROP TABLE IF EXISTS intent; DROP TABLE IF EXISTS round;`)
 		//nolint:errcheck
 		db.Close()
 	})

76-77: Consider using defer rows.Close() immediately after the query.

While the current placement works, the idiomatic Go pattern is to defer Close() right after successful query execution to ensure cleanup even if early returns or panics occur.

♻️ Suggested refactor
 	rows, err := db.Query(`
        SELECT id, txid, proof FROM intent;
     `)
 	require.NoError(t, err)
+	defer rows.Close()

 	var got []row
 	// check we have two rows with txids populated
 	for rows.Next() {
 		var r row
 		err = rows.Scan(&r.ID, &r.Txid, &r.Proof)
 		require.NoError(t, err)
 		got = append(got, r)
 	}

 	require.NoError(t, rows.Err())
 	require.Len(t, got, 2)
-
-	// nolint:errcheck
-	rows.Close()
internal/infrastructure/db/badger/ark_repo.go (1)

300-305: Intentionally silent error handling on index upsert.

The nolint:errcheck is documented with the rationale that index failures should not fail the critical round upsert. This is a reasonable trade-off, though consider logging failures for observability.

Optional: log index upsert failures for observability
 	// upsert intent index for each intent with a txid
 	for _, it := range rnd.Intents {
-		// do not fail the whole round upsert if intent index upsert fails
-		// nolint:errcheck
-		r.upsertIntentIndex(ctx, it.Txid, rnd.Id, it.Id)
+		if err := r.upsertIntentIndex(ctx, it.Txid, rnd.Id, it.Id); err != nil {
+			// Log but don't fail the whole round upsert
+			// Consider adding: log.WithError(err).Warnf("failed to upsert intent index for txid %s", it.Txid)
+		}
 	}
internal/infrastructure/db/sqlite/intent_txid_migration.go (1)

24-31: Use %w for error wrapping to preserve error chain.

Lines 26 and 31 use %s for error formatting, but the corresponding PostgreSQL migration uses %w. This loses the underlying error information for debugging and error inspection with errors.Is/errors.As.

Proposed fix
 	// backfill existing intents with derived txids from proof (in-place UPDATE)
 	if err := backfillIntent(ctx, dbh); err != nil {
-		return fmt.Errorf("failed to backfill txids: %s", err)
+		return fmt.Errorf("failed to backfill txids: %w", err)
 	}

 	// create index on intent txid column
 	if err := createIntentTxidIndex(ctx, dbh); err != nil {
-		return fmt.Errorf("failed to create intent txid index: %s", err)
+		return fmt.Errorf("failed to create intent txid index: %w", err)
 	}
api-spec/openapi/swagger/ark/v1/service.openapi.json (1)

371-409: Add description for the GetIntent endpoint.

Unlike other endpoints in this spec (e.g., GetInfo, RegisterIntent, SubmitTx), the new /v1/intent endpoint lacks a description field. Adding one would maintain API documentation consistency and help clients understand the endpoint's purpose.

internal/infrastructure/db/postgres/sqlc/query.sql (1)

472-477: Add blank line between query blocks for consistency.

There's a missing newline separator between UpdateTeleportAsset and SelectLatestIntentFees, which differs from the formatting pattern used elsewhere in the file.

Proposed fix
 -- name: UpdateTeleportAsset :exec
 UPDATE teleport_asset
 SET is_claimed = `@is_claimed`
 WHERE script = `@script` AND intent_id = `@intent_id` AND asset_id = `@asset_id` AND group_index = `@group_index`;
+
 -- name: SelectLatestIntentFees :one

Comment on lines +78 to +83
// GetAsset returns the asset information and metadata for the specified asset ID.
rpc GetAssetGroup(GetAssetGroupRequest) returns (GetAssetGroupResponse) {
option (meshapi.gateway.http) = {
get: "/v1/indexer/assetGroup/{asset_id}"
};
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix doc comment mismatch/typos in new RPCs.

The comment says “GetAsset” but the RPC is GetAssetGroup, and “subscribtion” is misspelled.

✏️ Suggested wording fixes
-  // GetAsset returns the asset information and metadata for the specified asset ID.
+  // GetAssetGroup returns the asset information and metadata for the specified asset ID.
...
-  // teleport hashes. It can also be used to update an existing subscribtion by adding new hashes to it.
+  // teleport hashes. It can also be used to update an existing subscription by adding new hashes to it.

Also applies to: 110-112

🤖 Prompt for AI Agents
In `@api-spec/protobuf/ark/v1/indexer.proto` around lines 78 - 83, Update the
protobuf doc comments to match the RPC names and fix typos: change the comment
that incorrectly says "GetAsset" to "GetAssetGroup" and ensure it describes
returning asset group information for the specified asset ID (for rpc
GetAssetGroup and messages GetAssetGroupRequest/GetAssetGroupResponse), and
correct any misspelling of "subscribtion" to "subscription" in the other RPC doc
comments (the similar typo around the RPCs at the later block referenced by
lines 110-112).

Comment on lines 339 to 369
func (m *assetGroupValidationMachine) validateInput(s *service, input extension.AssetInput) error {
grpAsset := m.group()
if input.Type == extension.AssetTypeTeleport {
if grpAsset.AssetId == nil {
return errors.TELEPORT_VALIDATION_FAILED.New("asset ID is required for teleport input validation").
WithMetadata(errors.TeleportValidationMetadata{})
}

txHash, err := chainhash.NewHash(input.Witness.IntentId)
if err != nil {
return errors.TELEPORT_VALIDATION_FAILED.New("invalid intent ID for teleport input validation: %w", err).
WithMetadata(errors.TeleportValidationMetadata{})
}

intent, err := s.repoManager.Rounds().GetIntentByTxid(context.Background(), txHash.String())
if err != nil {
return errors.TELEPORT_VALIDATION_FAILED.New("error retrieving intent for teleport input validation: %w", err).
WithMetadata(errors.TeleportValidationMetadata{})
}

decodedProof, err := psbt.NewFromRawBytes(strings.NewReader(intent.Proof), true)
if err != nil {
return errors.TELEPORT_VALIDATION_FAILED.New("error decoding intent proof for teleport input validation: %w", err).
WithMetadata(errors.TeleportValidationMetadata{})
}

if err := s.validateTeleportInput(*decodedProof, m.arkTx, *grpAsset.AssetId, uint32(input.Vin), input.Witness.Script); err != nil {
return err
}
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against nil intent before decoding proof.

GetIntentByTxid can return a nil intent; dereferencing intent.Proof will panic.

🐛 Suggested fix
-		intent, err := s.repoManager.Rounds().GetIntentByTxid(context.Background(), txHash.String())
+		intent, err := s.repoManager.Rounds().GetIntentByTxid(context.Background(), txHash.String())
 		if err != nil {
 			return errors.TELEPORT_VALIDATION_FAILED.New("error retrieving intent for teleport input validation: %w", err).
 				WithMetadata(errors.TeleportValidationMetadata{})
 		}
+		if intent == nil {
+			return errors.TELEPORT_VALIDATION_FAILED.New(
+				"intent %s not found for teleport input validation", txHash.String(),
+			).WithMetadata(errors.TeleportValidationMetadata{})
+		}
 
 		decodedProof, err := psbt.NewFromRawBytes(strings.NewReader(intent.Proof), true)
🤖 Prompt for AI Agents
In `@internal/core/application/asset_validation.go` around lines 339 - 369, The
validateInput branch for AssetTypeTeleport dereferences intent.Proof without
checking if intent is nil; update validateInput to check the return from
s.repoManager.Rounds().GetIntentByTxid (the intent variable) for nil before
using intent.Proof, and if nil return a TELEPORT_VALIDATION_FAILED error (using
errors.TELEPORT_VALIDATION_FAILED.New with a clear message like "intent not
found for teleport input validation" and
WithMetadata(errors.TeleportValidationMetadata{})); keep the existing error
handling for GetIntentByTxid failures and proceed to decode intent.Proof only
when intent is non-nil so decodedProof and subsequent calls
(s.validateTeleportInput) cannot panic.

Comment on lines 274 to 284
for j, v := range allVtxos {
// add asset to vtxo if present
asst, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint)
if err == nil && asst != nil {
assetExtension := domain.AssetExtension{
AssetID: asst.AssetID,
Amount: 0,
}
allVtxos[j].Extensions = append(allVtxos[j].Extensions, assetExtension)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Bug: Amount is hardcoded to 0 instead of using the actual asset amount.

At line 280, Amount: 0 should be Amount: asst.Amount to match the behavior in GetVtxos and provide meaningful asset data.

Proposed fix
 		assetExtension := domain.AssetExtension{
 			AssetID: asst.AssetID,
-			Amount:  0,
+			Amount:  asst.Amount,
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for j, v := range allVtxos {
// add asset to vtxo if present
asst, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint)
if err == nil && asst != nil {
assetExtension := domain.AssetExtension{
AssetID: asst.AssetID,
Amount: 0,
}
allVtxos[j].Extensions = append(allVtxos[j].Extensions, assetExtension)
}
}
for j, v := range allVtxos {
// add asset to vtxo if present
asst, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint)
if err == nil && asst != nil {
assetExtension := domain.AssetExtension{
AssetID: asst.AssetID,
Amount: asst.Amount,
}
allVtxos[j].Extensions = append(allVtxos[j].Extensions, assetExtension)
}
}
🤖 Prompt for AI Agents
In `@internal/core/application/indexer.go` around lines 274 - 284, The loop over
allVtxos is appending a domain.AssetExtension with Amount incorrectly hardcoded
to 0; update the creation of assetExtension in the block where
i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint) returns asst so that
AssetExtension.Amount is set to asst.Amount (use the actual asset amount) before
appending to allVtxos[j].Extensions; ensure you reference allVtxos,
i.repoManager.Assets().GetAssetByOutpoint, and domain.AssetExtension when making
the change.

Comment on lines 381 to 443
var ASSET_NOT_FOUND = Code[AssetValidationMetadata]{
34,
"ASSET_NOT_FOUND",
grpccodes.NotFound,
}

var ASSET_INPUT_INVALID = Code[AssetInputMetadata]{
35,
"ASSET_INPUT_INVALID",
grpccodes.InvalidArgument,
}

var ASSET_OUTPUT_INVALID = Code[AssetOutputMetadata]{
36,
"ASSET_OUTPUT_INVALID",
grpccodes.InvalidArgument,
}

var CONTROL_ASSET_INVALID = Code[ControlAssetMetadata]{
37,
"CONTROL_ASSET_INVALID",
grpccodes.InvalidArgument,
}

var CONTROL_ASSET_NOT_FOUND = Code[ControlAssetMetadata]{
38,
"CONTROL_ASSET_NOT_FOUND",
grpccodes.NotFound,
}

var TELEPORT_VALIDATION_FAILED = Code[TeleportValidationMetadata]{
39,
"TELEPORT_VALIDATION_FAILED",
grpccodes.InvalidArgument,
}

var CHECKPOINT_TX_INVALID = Code[CheckpointValidationMetadata]{
40,
"CHECKPOINT_TX_INVALID",
grpccodes.InvalidArgument,
}

var CHECKPOINT_TX_NOT_FOUND = Code[CheckpointValidationMetadata]{
41,
"CHECKPOINT_TX_NOT_FOUND",
grpccodes.NotFound,
}

var OFFCHAIN_TX_INVALID = Code[OffchainTxValidationMetadata]{
42,
"OFFCHAIN_TX_INVALID",
grpccodes.InvalidArgument,
}

var ASSET_PACKET_INVALID = Code[AssetValidationMetadata]{
43,
"ASSET_PACKET_INVALID",
grpccodes.InvalidArgument,
}

var INTENT_FEE_EVALUATION_FAILED = Code[any]{44, "INTENT_FEE_EVALUATION_FAILED", grpccodes.Internal}

var INTENT_NOT_FOUND = Code[any]{34, "INTENT_NOT_FOUND", grpccodes.NotFound}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Duplicate error code value (34) across ASSET_NOT_FOUND and INTENT_NOT_FOUND.

Error codes should be unique; duplicating 34 makes error classification ambiguous for clients. Assign a new unused code.

🔧 Proposed fix
-var INTENT_NOT_FOUND = Code[any]{34, "INTENT_NOT_FOUND", grpccodes.NotFound}
+var INTENT_NOT_FOUND = Code[any]{45, "INTENT_NOT_FOUND", grpccodes.NotFound}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var ASSET_NOT_FOUND = Code[AssetValidationMetadata]{
34,
"ASSET_NOT_FOUND",
grpccodes.NotFound,
}
var ASSET_INPUT_INVALID = Code[AssetInputMetadata]{
35,
"ASSET_INPUT_INVALID",
grpccodes.InvalidArgument,
}
var ASSET_OUTPUT_INVALID = Code[AssetOutputMetadata]{
36,
"ASSET_OUTPUT_INVALID",
grpccodes.InvalidArgument,
}
var CONTROL_ASSET_INVALID = Code[ControlAssetMetadata]{
37,
"CONTROL_ASSET_INVALID",
grpccodes.InvalidArgument,
}
var CONTROL_ASSET_NOT_FOUND = Code[ControlAssetMetadata]{
38,
"CONTROL_ASSET_NOT_FOUND",
grpccodes.NotFound,
}
var TELEPORT_VALIDATION_FAILED = Code[TeleportValidationMetadata]{
39,
"TELEPORT_VALIDATION_FAILED",
grpccodes.InvalidArgument,
}
var CHECKPOINT_TX_INVALID = Code[CheckpointValidationMetadata]{
40,
"CHECKPOINT_TX_INVALID",
grpccodes.InvalidArgument,
}
var CHECKPOINT_TX_NOT_FOUND = Code[CheckpointValidationMetadata]{
41,
"CHECKPOINT_TX_NOT_FOUND",
grpccodes.NotFound,
}
var OFFCHAIN_TX_INVALID = Code[OffchainTxValidationMetadata]{
42,
"OFFCHAIN_TX_INVALID",
grpccodes.InvalidArgument,
}
var ASSET_PACKET_INVALID = Code[AssetValidationMetadata]{
43,
"ASSET_PACKET_INVALID",
grpccodes.InvalidArgument,
}
var INTENT_FEE_EVALUATION_FAILED = Code[any]{44, "INTENT_FEE_EVALUATION_FAILED", grpccodes.Internal}
var INTENT_NOT_FOUND = Code[any]{34, "INTENT_NOT_FOUND", grpccodes.NotFound}
var ASSET_NOT_FOUND = Code[AssetValidationMetadata]{
34,
"ASSET_NOT_FOUND",
grpccodes.NotFound,
}
var ASSET_INPUT_INVALID = Code[AssetInputMetadata]{
35,
"ASSET_INPUT_INVALID",
grpccodes.InvalidArgument,
}
var ASSET_OUTPUT_INVALID = Code[AssetOutputMetadata]{
36,
"ASSET_OUTPUT_INVALID",
grpccodes.InvalidArgument,
}
var CONTROL_ASSET_INVALID = Code[ControlAssetMetadata]{
37,
"CONTROL_ASSET_INVALID",
grpccodes.InvalidArgument,
}
var CONTROL_ASSET_NOT_FOUND = Code[ControlAssetMetadata]{
38,
"CONTROL_ASSET_NOT_FOUND",
grpccodes.NotFound,
}
var TELEPORT_VALIDATION_FAILED = Code[TeleportValidationMetadata]{
39,
"TELEPORT_VALIDATION_FAILED",
grpccodes.InvalidArgument,
}
var CHECKPOINT_TX_INVALID = Code[CheckpointValidationMetadata]{
40,
"CHECKPOINT_TX_INVALID",
grpccodes.InvalidArgument,
}
var CHECKPOINT_TX_NOT_FOUND = Code[CheckpointValidationMetadata]{
41,
"CHECKPOINT_TX_NOT_FOUND",
grpccodes.NotFound,
}
var OFFCHAIN_TX_INVALID = Code[OffchainTxValidationMetadata]{
42,
"OFFCHAIN_TX_INVALID",
grpccodes.InvalidArgument,
}
var ASSET_PACKET_INVALID = Code[AssetValidationMetadata]{
43,
"ASSET_PACKET_INVALID",
grpccodes.InvalidArgument,
}
var INTENT_FEE_EVALUATION_FAILED = Code[any]{44, "INTENT_FEE_EVALUATION_FAILED", grpccodes.Internal}
var INTENT_NOT_FOUND = Code[any]{45, "INTENT_NOT_FOUND", grpccodes.NotFound}
🤖 Prompt for AI Agents
In `@pkg/errors/errors.go` around lines 381 - 443, ASSET_NOT_FOUND and
INTENT_NOT_FOUND currently share the same numeric code (34); change
INTENT_NOT_FOUND's numeric ID to an unused value (e.g., 45) in its Code literal
so all error codes are unique. Update the INTENT_NOT_FOUND declaration (the
Code[any] composite literal) to use the new integer while keeping its name and
grpc code intact.

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/service.go`:
- Around line 1655-1661: The code appends every non-asset output to
offchainOutputs causing on-chain outputs to be miscounted; update the logic so
offchainOutputs is only appended inside the off-chain branch where rcv.PubKey is
set (i.e., when hasOffChainReceiver is toggled and rcv.PubKey =
hex.EncodeToString(output.PkScript[2:])); leave receivers = append(receivers,
rcv) unchanged but move offchainOutputs = append(offchainOutputs, *output) into
that same off-chain branch so only true off-chain outputs affect fee
calculations.
♻️ Duplicate comments (5)
internal/core/application/service.go (5)

1712-1717: Remove unreachable err check.
Line 1713 checks err from a prior call that already returned on error; it’s always nil here.


1131-1138: Make asset persistence semantics explicit.
Line 1132 logs and continues when storeAssetDetailsFromArkTx fails, which can desync asset state from an accepted offchain tx. Decide whether this should fail submission or be explicitly best‑effort (warn + comment/metric).


1928-1960: Teleport asset insert should be atomic and use the explicit vout.
Line 1951 uses the loop index and errors are only logged, so registration can succeed with missing teleport assets. Use output.Vout and fail the registration on insert failure.

🛠️ Proposed fix
-				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
+				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 					IntentID:    proofTxid,
 					Script:      teleportScript,
-					OutputIndex: uint32(i),
+					OutputIndex: uint32(output.Vout),
 					AssetID:     asst.AssetId.ToString(),
 					Amount:      output.Amount,
 					IsClaimed:   false,
 				}); err != nil {
-					log.WithError(err).Warn("failed to insert teleport asset")
+					return "", errors.INTERNAL_ERROR.New("failed to insert teleport asset: %w", err).
+						WithMetadata(map[string]any{
+							"intent_id": proofTxid,
+							"asset_id":  asst.AssetId.ToString(),
+							"vout":      output.Vout,
+						})
 				}

4488-4505: Surface insufficient balance on quantity decreases.
If DecreaseAssetGroupQuantity no‑ops (e.g., WHERE quantity >= delta) and returns no rows, this can silently succeed. Return a deterministic insufficient‑balance error when totalIn < totalOut or when the repo indicates no rows updated.


4537-4538: Use assetOut.Vout for OutputVout.
The loop index can drift from the encoded vout.

🛠️ Proposed fix
-				OutputVout:     uint32(outIdx),
+				OutputVout:     uint32(assetOut.Vout),

Comment on lines +1655 to +1661
hasOffChainReceiver = true
rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
}

receivers = append(receivers, rcv)
offchainOutputs = append(offchainOutputs, *output)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

On‑chain outputs are being counted as off‑chain outputs.
Line 1660 appends every non‑asset output to offchainOutputs, even when it’s on‑chain, which will skew fee calculation. Move the append into the off‑chain branch.

🛠️ Proposed fix
 		} else {
 			...
 			hasOffChainReceiver = true
 			rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
+			offchainOutputs = append(offchainOutputs, *output)
 		}
 
 		receivers = append(receivers, rcv)
-		offchainOutputs = append(offchainOutputs, *output)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hasOffChainReceiver = true
rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
}
receivers = append(receivers, rcv)
offchainOutputs = append(offchainOutputs, *output)
}
hasOffChainReceiver = true
rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
offchainOutputs = append(offchainOutputs, *output)
}
receivers = append(receivers, rcv)
}
🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 1655 - 1661, The code
appends every non-asset output to offchainOutputs causing on-chain outputs to be
miscounted; update the logic so offchainOutputs is only appended inside the
off-chain branch where rcv.PubKey is set (i.e., when hasOffChainReceiver is
toggled and rcv.PubKey = hex.EncodeToString(output.PkScript[2:])); leave
receivers = append(receivers, rcv) unchanged but move offchainOutputs =
append(offchainOutputs, *output) into that same off-chain branch so only true
off-chain outputs affect fee calculations.

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/service.go`:
- Around line 4474-4495: The current updateAssetQuantity flow can return success
when DecreaseAssetGroupQuantity silently affects 0 rows in SQLite/Postgres;
modify the fix so updateAssetQuantity validates and errors on insufficient
balance or ensure the SQL backends report zero-row updates as errors: either (A)
before calling repoManager.Assets().DecreaseAssetGroupQuantity in
updateAssetQuantity, fetch the current quantity and compare (e.g., via a
Get/GetAssetGroupQuantity method) and return an explicit insufficient-balance
error if totalIn < totalOut, or (B) change the SQLite/Postgres
DecreaseAssetGroupQuantity implementations to check rowsAffected after the
UPDATE (UPDATE ... WHERE quantity >= ...) and return a descriptive error when
rowsAffected == 0 so updateAssetQuantity receives an error instead of silently
succeeding; apply the same approach for consistency with the Badger
implementation and keep IncreaseAssetGroupQuantity behavior unchanged.
♻️ Duplicate comments (5)
internal/core/application/service.go (5)

4510-4531: OutputVout should use assetOut.Vout instead of loop index.

Line 4523 uses the loop index outIdx for OutputVout, but this should use the actual vout from assetOut.Vout to ensure consistency with the asset packet encoding.

🐛 Proposed fix
-				event := TeleportAsset{
-					TeleportHash:   hex.EncodeToString(assetOut.Script),
-					AnchorOutpoint: anchorOutpoint,
-					AssetID:        ast.AssetId.ToString(),
-					OutputVout:     uint32(outIdx),
-					CreatedAt:      createdAt,
-					ExpiresAt:      expiresAt,
-				}
+				event := TeleportAsset{
+					TeleportHash:   hex.EncodeToString(assetOut.Script),
+					AnchorOutpoint: anchorOutpoint,
+					AssetID:        ast.AssetId.ToString(),
+					OutputVout:     assetOut.Vout,
+					CreatedAt:      createdAt,
+					ExpiresAt:      expiresAt,
+				}

1578-1587: Teleport asset insertion errors should fail registration.

InsertTeleportAsset errors are logged but the receiver is still added. This creates inconsistent state where the intent references a teleport asset that wasn't persisted.

🐛 Proposed fix
 				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 					IntentID:    proofTxid,
 					Script:      teleportScript,
 					OutputIndex: uint32(i),
 					AssetID:     asst.AssetId.ToString(),
 					Amount:      output.Amount,
 					IsClaimed:   false,
 				}); err != nil {
-					log.WithError(err).Warn("failed to insert teleport asset")
+					return "", errors.INTERNAL_ERROR.New(
+						"failed to insert teleport asset: %w", err,
+					).WithMetadata(map[string]any{
+						"intent_id": proofTxid,
+						"asset_id":  asst.AssetId.ToString(),
+					})
 				}

1742-1747: Remove dead code block.

This error check references err from the s.cache.OffchainTxs().Includes() call (line 1726), which was already handled at lines 1727-1731. At this point, err can only be nil, making this block unreachable.

🐛 Proposed fix
 		taptreeFields, _ := txutils.GetArkPsbtFields(
 			&proof.Packet, i+1, txutils.VtxoTaprootTreeField,
 		)
-
-		if err != nil {
-			return "", errors.INVALID_PSBT_INPUT.New(
-				"failed to get asset seal field for input %d: %w", i+1, err,
-			).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
-		}
 
 		tapscripts := make([]string, 0)

1685-1691: On-chain outputs are incorrectly counted as off-chain outputs.

Line 1690 appends every non-asset output to offchainOutputs, including on-chain outputs. This will skew fee calculation. The append should only occur inside the off-chain branch.

🐛 Proposed fix
 		} else {
 			hasOffChainReceiver = true
 			rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
+			offchainOutputs = append(offchainOutputs, *output)
 		}
 
 		receivers = append(receivers, rcv)
-		offchainOutputs = append(offchainOutputs, *output)
 	}

1131-1139: Clarify error handling semantics for asset storage.

Asset storage errors are logged but don't fail the transaction, which could create inconsistent state where the offchain tx succeeds but asset state is not persisted. If best-effort is intentional, add a comment explaining why and consider emitting a metric for monitoring. Otherwise, return the error to fail the transaction.

🛠️ Suggested approaches

Option 1: Make it blocking

 	if assetOutputIndex >= 0 {
 		if err := s.storeAssetDetailsFromArkTx(
 			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
 		); err != nil {
-			log.WithError(err).Errorf(
-				"failed to store asset details for offchain tx %s", txid,
-			)
+			return nil, errors.INTERNAL_ERROR.New(
+				"failed to store asset details for offchain tx %s: %w", txid, err,
+			)
 		}
 	}

Option 2: Document best-effort behavior

 	if assetOutputIndex >= 0 {
+		// Best-effort: asset state can be reconstructed from the tx if storage fails.
+		// Log error for monitoring but don't fail the transaction.
 		if err := s.storeAssetDetailsFromArkTx(
 			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
 		); err != nil {
-			log.WithError(err).Errorf(
+			log.WithError(err).Warnf(
 				"failed to store asset details for offchain tx %s", txid,
 			)
 		}
 	}
🧹 Nitpick comments (4)
internal/core/application/service.go (4)

355-358: Remove or re-enable commented-out code.

The restoreWatchingVtxos call is commented out without explanation. This could affect vtxo watching restoration on service restart. Either remove this dead code or re-enable it with a comment explaining the rationale for the change.


4320-4329: Variable shadowing creates potential confusion.

Line 4326 declares a new assetId variable that shadows the outer scope's assetId (which is nil in the issuance case). While the logic is correct, this shadowing can cause confusion during maintenance.

Consider using a different variable name like newAssetId for clarity.

♻️ Proposed refactor
 		if assetId == nil {
 			var controlAsset string
 			txHash := arkTx.TxHash()
 			var txHashBytes [32]byte
 			copy(txHashBytes[:], txHash[:])
 
-			assetId := extension.AssetId{
+			newAssetId := extension.AssetId{
 				TxHash: txHashBytes,
 				Index:  uint16(i),
 			}
 
 			// ... update usages of assetId to newAssetId in this block

3803-3806: Clarify comment to match the actual condition.

The comment says "skip OP_RETURN outputs" but the condition checks if vtxo.Amount < dustLimit. Consider updating the comment to be more precise.

📝 Suggested comment update
-		// skip OP_RETURN outputs
+		// skip subdust outputs (including OP_RETURN-style vtxos)
 		if vtxo.Amount < dustLimit {
 			continue
 		}

4442-4461: Consider consistent error handling for teleport input claims.

UpdateTeleportAsset errors are logged but not propagated, similar to other asset storage operations. Ensure this is consistent with the intended error handling semantics discussed earlier.

Comment on lines +4474 to +4495
func (s *service) updateAssetQuantity(
ctx context.Context,
assetID string,
totalIn, totalOut uint64,
) error {
if totalOut > totalIn {
delta := totalOut - totalIn
if err := s.repoManager.Assets().IncreaseAssetGroupQuantity(ctx, assetID, delta); err != nil {
return fmt.Errorf("error updating asset quantity: %s", err)
}
return nil
}

if totalIn > totalOut {
delta := totalIn - totalOut
if err := s.repoManager.Assets().DecreaseAssetGroupQuantity(ctx, assetID, delta); err != nil {
return fmt.Errorf("error updating asset quantity: %s", err)
}
}

return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "DecreaseAssetGroupQuantity" --type go -A 10

Repository: arkade-os/arkd

Length of output: 5259


🏁 Script executed:

sed -n '426,460p' internal/infrastructure/db/badger/asset_repo.go

Repository: arkade-os/arkd

Length of output: 876


🏁 Script executed:

rg -n "SubtractFromAssetQuantity" --type go -A 8 | head -50

Repository: arkade-os/arkd

Length of output: 4226


Silent no-op vulnerability in SQLite and Postgres implementations of DecreaseAssetGroupQuantity.

The SQLite and Postgres implementations use UPDATE ... WHERE quantity >= amount, which silently succeeds (no error) when the balance is insufficient and zero rows are affected. This allows updateAssetQuantity to return success even when the quantity decrease failed.

The Badger implementation correctly validates and returns an error when balance is insufficient. For consistency across all backends, the application should either:

  1. Add explicit balance validation in updateAssetQuantity before calling DecreaseAssetGroupQuantity, or
  2. Modify the SQLite/Postgres implementations to detect and report when the UPDATE affected zero rows.
Affected SQL queries

SQLite (internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go:2038-2039):

UPDATE asset_group
SET quantity = quantity - ?
WHERE id = ? AND quantity >= ?

Postgres (internal/infrastructure/db/postgres/sqlc/queries/query.sql.go:1971-1972):

UPDATE asset_group
SET quantity = quantity - $1
WHERE id = $2 AND quantity >= $1

Both return no error if the WHERE condition fails (0 rows affected), unlike the Badger implementation which explicitly checks and errors.

🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 4474 - 4495, The current
updateAssetQuantity flow can return success when DecreaseAssetGroupQuantity
silently affects 0 rows in SQLite/Postgres; modify the fix so
updateAssetQuantity validates and errors on insufficient balance or ensure the
SQL backends report zero-row updates as errors: either (A) before calling
repoManager.Assets().DecreaseAssetGroupQuantity in updateAssetQuantity, fetch
the current quantity and compare (e.g., via a Get/GetAssetGroupQuantity method)
and return an explicit insufficient-balance error if totalIn < totalOut, or (B)
change the SQLite/Postgres DecreaseAssetGroupQuantity implementations to check
rowsAffected after the UPDATE (UPDATE ... WHERE quantity >= ...) and return a
descriptive error when rowsAffected == 0 so updateAssetQuantity receives an
error instead of silently succeeding; apply the same approach for consistency
with the Badger implementation and keep IncreaseAssetGroupQuantity behavior
unchanged.

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/infrastructure/db/postgres/sqlc/queries/query.sql.go`:
- Around line 90-104: The AddToAssetQuantity SQL currently executes silently
when no row matches; update the SQL tag to use :execrows (change the query to
`-- name: AddToAssetQuantity :execrows`) and regenerate sqlc so the generated
AddToAssetQuantity method returns the rows affected (e.g., (int64, error) or
(sql.Result, error) depending on your generator). Then adjust the implementation
of AddToAssetQuantity to return the number of rows affected alongside the error
so callers can detect a missing asset group (align behavior with
SubtractFromAssetQuantity) and update any call sites to handle a 0-rows-affected
result.
- Around line 1994-2017: The UpdateTeleportAsset SQL currently uses :exec
(updateTeleportAsset) so UpdateTeleportAsset(ctx, arg UpdateTeleportAssetParams)
returns nil even when no row matched; change the SQL comment to use :execrows
(so sqlc generates a result-aware method) and update the generated Go method
(UpdateTeleportAsset) to return rowsAffected (or an error) by checking the Exec
result's RowsAffected from q.db.ExecContext (or the sqlc-generated return) and
return a specific error when rowsAffected == 0 to surface non-existent teleport
assets.
♻️ Duplicate comments (5)
internal/infrastructure/db/postgres/sqlc/queries/query.sql.go (2)

16-39: Silent no-op on conflicting asset IDs already flagged.

The previous review identified that this ON CONFLICT ... WHERE pattern can silently succeed with zero rows affected when asset.asset_id != EXCLUDED.asset_id. Please address the earlier feedback to return rows affected or enforce a stricter constraint.


1969-1983: Insufficient-quantity/missing-group failure already flagged.

The previous review identified that this quantity >= $1 guard can yield zero updated rows with no error, letting callers assume subtraction succeeded. Please address the earlier feedback to return rows affected.

internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go (1)

2036-2051: Regenerate after switching to :execrows.

Once SubtractFromAssetQuantity is changed in query.sql, re-run sqlc so this method returns rows affected.

internal/infrastructure/db/sqlite/sqlc/query.sql (1)

460-463: Expose rows affected for guarded subtraction.

quantity >= ? is a guard; with :exec the caller can’t tell whether subtraction happened. Use :execrows and treat 0 rows as “insufficient quantity,” then regenerate sqlc outputs.

🛠️ Suggested SQL change
--- name: SubtractFromAssetQuantity :exec
+-- name: SubtractFromAssetQuantity :execrows
 UPDATE asset_group
 SET quantity = quantity - ?
 WHERE id = ? AND quantity >= ?;

Suggested verification (ensure callers handle rows affected after regen):

#!/bin/bash
rg -n "SubtractFromAssetQuantity" --type go -C 3
internal/infrastructure/db/postgres/sqlc/query.sql (1)

454-457: Expose rows affected for guarded subtraction.

This guarded update can silently no‑op; switch to :execrows and have the repo treat 0 rows as insufficient quantity.

🛠️ Suggested SQL change
--- name: SubtractFromAssetQuantity :exec
+-- name: SubtractFromAssetQuantity :execrows
 UPDATE asset_group
 SET quantity = quantity - `@quantity`
 WHERE id = `@id` AND quantity >= `@quantity`;

Comment on lines +90 to +104
const addToAssetQuantity = `-- name: AddToAssetQuantity :exec
UPDATE asset_group
SET quantity = quantity + $1
WHERE id = $2
`

type AddToAssetQuantityParams struct {
Quantity int64
ID string
}

func (q *Queries) AddToAssetQuantity(ctx context.Context, arg AddToAssetQuantityParams) error {
_, err := q.db.ExecContext(ctx, addToAssetQuantity, arg.Quantity, arg.ID)
return err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent no-op when asset group doesn't exist.

Similar to SubtractFromAssetQuantity, this function returns nil even when the id doesn't match any row. Callers cannot distinguish a successful quantity increase from an operation on a non-existent asset group.

Consider changing to :execrows and returning rows affected so callers can detect and handle missing asset groups.

🔧 Proposed fix (update the sqlc query definition and regenerate)
-const addToAssetQuantity = `-- name: AddToAssetQuantity :exec
+const addToAssetQuantity = `-- name: AddToAssetQuantity :execrows
 UPDATE asset_group
 SET quantity = quantity + $1
 WHERE id = $2
 `
@@
-func (q *Queries) AddToAssetQuantity(ctx context.Context, arg AddToAssetQuantityParams) error {
-	_, err := q.db.ExecContext(ctx, addToAssetQuantity, arg.Quantity, arg.ID)
-	return err
+func (q *Queries) AddToAssetQuantity(ctx context.Context, arg AddToAssetQuantityParams) (int64, error) {
+	result, err := q.db.ExecContext(ctx, addToAssetQuantity, arg.Quantity, arg.ID)
+	if err != nil {
+		return 0, err
+	}
+	return result.RowsAffected()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const addToAssetQuantity = `-- name: AddToAssetQuantity :exec
UPDATE asset_group
SET quantity = quantity + $1
WHERE id = $2
`
type AddToAssetQuantityParams struct {
Quantity int64
ID string
}
func (q *Queries) AddToAssetQuantity(ctx context.Context, arg AddToAssetQuantityParams) error {
_, err := q.db.ExecContext(ctx, addToAssetQuantity, arg.Quantity, arg.ID)
return err
}
const addToAssetQuantity = `-- name: AddToAssetQuantity :execrows
UPDATE asset_group
SET quantity = quantity + $1
WHERE id = $2
`
type AddToAssetQuantityParams struct {
Quantity int64
ID string
}
func (q *Queries) AddToAssetQuantity(ctx context.Context, arg AddToAssetQuantityParams) (int64, error) {
result, err := q.db.ExecContext(ctx, addToAssetQuantity, arg.Quantity, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
🤖 Prompt for AI Agents
In `@internal/infrastructure/db/postgres/sqlc/queries/query.sql.go` around lines
90 - 104, The AddToAssetQuantity SQL currently executes silently when no row
matches; update the SQL tag to use :execrows (change the query to `-- name:
AddToAssetQuantity :execrows`) and regenerate sqlc so the generated
AddToAssetQuantity method returns the rows affected (e.g., (int64, error) or
(sql.Result, error) depending on your generator). Then adjust the implementation
of AddToAssetQuantity to return the number of rows affected alongside the error
so callers can detect a missing asset group (align behavior with
SubtractFromAssetQuantity) and update any call sites to handle a 0-rows-affected
result.

Comment on lines +1994 to +2017
const updateTeleportAsset = `-- name: UpdateTeleportAsset :exec
UPDATE teleport_asset
SET is_claimed = $1
WHERE script = $2 AND intent_id = $3 AND asset_id = $4 AND group_index = $5
`

type UpdateTeleportAssetParams struct {
IsClaimed bool
Script string
IntentID string
AssetID string
GroupIndex int64
}

func (q *Queries) UpdateTeleportAsset(ctx context.Context, arg UpdateTeleportAssetParams) error {
_, err := q.db.ExecContext(ctx, updateTeleportAsset,
arg.IsClaimed,
arg.Script,
arg.IntentID,
arg.AssetID,
arg.GroupIndex,
)
return err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent no-op when teleport asset doesn't exist.

If no row matches the composite key (script, intent_id, asset_id, group_index), this function returns nil, making it impossible for callers to distinguish a successful claim update from an operation on a non-existent teleport asset.

Consider changing to :execrows and returning rows affected.

🔧 Proposed fix (update the sqlc query definition and regenerate)
-const updateTeleportAsset = `-- name: UpdateTeleportAsset :exec
+const updateTeleportAsset = `-- name: UpdateTeleportAsset :execrows
 UPDATE teleport_asset
 SET is_claimed = $1
 WHERE script = $2 AND intent_id = $3 AND asset_id = $4 AND group_index = $5
 `
@@
-func (q *Queries) UpdateTeleportAsset(ctx context.Context, arg UpdateTeleportAssetParams) error {
-	_, err := q.db.ExecContext(ctx, updateTeleportAsset,
+func (q *Queries) UpdateTeleportAsset(ctx context.Context, arg UpdateTeleportAssetParams) (int64, error) {
+	result, err := q.db.ExecContext(ctx, updateTeleportAsset,
 		arg.IsClaimed,
 		arg.Script,
 		arg.IntentID,
 		arg.AssetID,
 		arg.GroupIndex,
 	)
-	return err
+	if err != nil {
+		return 0, err
+	}
+	return result.RowsAffected()
 }
🤖 Prompt for AI Agents
In `@internal/infrastructure/db/postgres/sqlc/queries/query.sql.go` around lines
1994 - 2017, The UpdateTeleportAsset SQL currently uses :exec
(updateTeleportAsset) so UpdateTeleportAsset(ctx, arg UpdateTeleportAssetParams)
returns nil even when no row matched; change the SQL comment to use :execrows
(so sqlc generates a result-aware method) and update the generated Go method
(UpdateTeleportAsset) to return rowsAffected (or an error) by checking the Exec
result's RowsAffected from q.db.ExecContext (or the sqlc-generated return) and
return a specific error when rowsAffected == 0 to surface non-existent teleport
assets.

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/core/application/service.go`:
- Around line 4423-4433: The loop over asstGp.Outputs that appends
domain.NormalAsset entries (creating asst with Outpoint/AssetID/Amount and
appending to assetList) is currently including teleport outputs; update that
loop to skip outputs that are teleports using the same predicate used earlier
for issuance (i.e., the teleport-detection check used when building issuance
assetList). Concretely, inside the for _, out := range asstGp.Outputs loop, add
the same conditional (e.g., if isTeleport(out) or the existing out teleport
check used above) to continue/skip teleport outputs before constructing the
domain.NormalAsset so assetList only receives non-teleport outputs.
- Around line 355-358: The commented-out call to svc.restoreWatchingVtxos in
service.go should be either reinstated or removed with justification; restore it
by uncommenting and handling its error (e.g., return fmt.Errorf on failure)
before starting go svc.listenToScannerNotifications() if vtxo watching is
required, or delete the commented lines and add a brief code comment explaining
why restoreWatchingVtxos is intentionally disabled so future readers know this
is deliberate rather than accidental.
- Around line 3242-3243: Remove the stray debug print fmt.Printf("events %+v",
changes): either delete this line or replace it with the project’s structured
logger call (do not use fmt.Printf). Locate the occurrence of fmt.Printf and the
variable changes in internal/core/application/service.go and swap it for the
appropriate logger method used throughout the service (e.g., logger.Debug/Infof
with context) or simply remove the statement before merging.
- Around line 2877-2878: Remove the leftover debug fmt.Printf("events %+v",
round.Events()) call: either delete the line or replace it with a trace-level
log using the project's logger (e.g. use logrus.WithField("events",
round.Events()).Trace(...) or logger.Tracef(..., round.Events())) so that event
data is logged via the project's logging framework rather than printed to
stdout.
♻️ Duplicate comments (7)
internal/core/application/service.go (7)

1131-1139: Clarify error handling semantics for asset storage.

This block logs the error but doesn't fail the transaction. As noted in previous review, this creates potential inconsistency where the offchain transaction succeeds but asset state is not persisted.

Consider either:

  1. Make asset storage blocking (return error)
  2. Add a comment explaining why best-effort is acceptable here

1576-1593: Teleport asset insertion errors should fail registration.

When InsertTeleportAsset fails, the error is only logged but the receiver is still added to the intent. This creates inconsistent state where the intent references a teleport asset that wasn't persisted.

 				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 					IntentID:    proofTxid,
 					Script:      teleportScript,
 					OutputIndex: uint32(i),
 					AssetID:     asst.AssetId.ToString(),
 					Amount:      output.Amount,
 					IsClaimed:   false,
 				}); err != nil {
-					log.WithError(err).Warn("failed to insert teleport asset")
+					return "", errors.INTERNAL_ERROR.New(
+						"failed to insert teleport asset: %w", err,
+					).WithMetadata(map[string]any{
+						"intent_id": proofTxid,
+						"asset_id":  asst.AssetId.ToString(),
+					})
 				}

1578-1581: Use output.Vout instead of loop index for OutputIndex.

The loop index i may not match the actual vout encoded in the asset output. Use output.Vout for consistency with the asset packet encoding.

 				if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 					IntentID:    proofTxid,
 					Script:      teleportScript,
-					OutputIndex: uint32(i),
+					OutputIndex: output.Vout,
 					AssetID:     asst.AssetId.ToString(),

1685-1691: On-chain outputs incorrectly appended to offchainOutputs.

Line 1690 appends to offchainOutputs unconditionally after the if-else block, meaning on-chain outputs are also added. This will skew fee calculations.

🐛 Proposed fix
 		} else {
 			...
 			hasOffChainReceiver = true
 			rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
+			offchainOutputs = append(offchainOutputs, *output)
 		}
 
 		receivers = append(receivers, rcv)
-		offchainOutputs = append(offchainOutputs, *output)

4478-4499: Silent failure vulnerability in updateAssetQuantity.

As noted in a previous review, the SQLite and Postgres implementations of DecreaseAssetGroupQuantity use UPDATE ... WHERE quantity >= amount, which silently succeeds when balance is insufficient (0 rows affected). This allows updateAssetQuantity to return success even when the decrease failed.

Consider adding explicit validation before calling DecreaseAssetGroupQuantity, or modifying the SQL implementations to check rowsAffected.


4523-4534: OutputVout should use assetOut.Vout instead of loop index.

The OutputVout is set to outIdx (loop index) but should use assetOut.Vout to reflect the actual output index from the asset packet.

🐛 Proposed fix
 				event := TeleportAsset{
 					TeleportHash:   hex.EncodeToString(assetOut.Script),
 					AnchorOutpoint: anchorOutpoint,
 					AssetID:        ast.AssetId.ToString(),
-					OutputVout:     uint32(outIdx),
+					OutputVout:     assetOut.Vout,
 					CreatedAt:      createdAt,
 					ExpiresAt:      expiresAt,
 				}

1742-1747: Dead code: err was already handled above.

This block appears to be unreachable. The err variable from the GetArkPsbtFields call on line 1739 is discarded with _, making this error check pointless. This was flagged in a previous review as addressed, but the code remains.

-	if err != nil {
-		return "", errors.INVALID_PSBT_INPUT.New(
-			"failed to get asset seal field for input %d: %w", i+1, err,
-		).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
-	}
🧹 Nitpick comments (1)
internal/core/application/service.go (1)

3807-3810: Clarify comment to match the actual logic.

The comment says "skip OP_RETURN outputs" but the condition actually skips all sub-dust outputs. Consider updating the comment for accuracy:

-		// skip OP_RETURN outputs
+		// skip sub-dust outputs (including OP_RETURN-style vtxo scripts)
 		if vtxo.Amount < dustLimit {
 			continue
 		}

Comment on lines +355 to 358
// if err := svc.restoreWatchingVtxos(); err != nil {
// return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
// }
go svc.listenToScannerNotifications()
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Remove or restore commented-out code.

This commented-out call to restoreWatchingVtxos may cause vtxos to not be watched after service restart, potentially missing fraud detection events. Either:

  1. Restore the call if watching is required
  2. Remove the commented code entirely if it's intentionally disabled, with a code comment explaining why
-	// if err := svc.restoreWatchingVtxos(); err != nil {
-	// 	return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
-	// }
+	if err := svc.restoreWatchingVtxos(); err != nil {
+		return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// if err := svc.restoreWatchingVtxos(); err != nil {
// return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
// }
go svc.listenToScannerNotifications()
if err := svc.restoreWatchingVtxos(); err != nil {
return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
}
go svc.listenToScannerNotifications()
🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 355 - 358, The
commented-out call to svc.restoreWatchingVtxos in service.go should be either
reinstated or removed with justification; restore it by uncommenting and
handling its error (e.g., return fmt.Errorf on failure) before starting go
svc.listenToScannerNotifications() if vtxo watching is required, or delete the
commented lines and add a brief code comment explaining why restoreWatchingVtxos
is intentionally disabled so future readers know this is deliberate rather than
accidental.

Comment on lines +4423 to +4433
for _, out := range asstGp.Outputs {
asst := domain.NormalAsset{
Outpoint: domain.Outpoint{
Txid: arkTx.TxID(),
VOut: uint32(out.Vout),
},
AssetID: assetId.ToString(),
Amount: out.Amount,
}
assetList = append(assetList, asst)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Teleport outputs should be excluded from assetList for existing assets.

For issuance (lines 4363-4366), teleport outputs are correctly skipped when building the asset list. However, for existing assets, all outputs are added including teleport outputs. This inconsistency may cause teleport outputs to be incorrectly stored as normal assets.

🐛 Proposed fix
 		for _, out := range asstGp.Outputs {
+			if out.Type == extension.AssetTypeTeleport {
+				continue
+			}
+
 			asst := domain.NormalAsset{
 				Outpoint: domain.Outpoint{
 					Txid: arkTx.TxID(),
 					VOut: uint32(out.Vout),
 				},
 				AssetID: assetId.ToString(),
 				Amount:  out.Amount,
 			}
 			assetList = append(assetList, asst)
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, out := range asstGp.Outputs {
asst := domain.NormalAsset{
Outpoint: domain.Outpoint{
Txid: arkTx.TxID(),
VOut: uint32(out.Vout),
},
AssetID: assetId.ToString(),
Amount: out.Amount,
}
assetList = append(assetList, asst)
}
for _, out := range asstGp.Outputs {
if out.Type == extension.AssetTypeTeleport {
continue
}
asst := domain.NormalAsset{
Outpoint: domain.Outpoint{
Txid: arkTx.TxID(),
VOut: uint32(out.Vout),
},
AssetID: assetId.ToString(),
Amount: out.Amount,
}
assetList = append(assetList, asst)
}
🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 4423 - 4433, The loop over
asstGp.Outputs that appends domain.NormalAsset entries (creating asst with
Outpoint/AssetID/Amount and appending to assetList) is currently including
teleport outputs; update that loop to skip outputs that are teleports using the
same predicate used earlier for issuance (i.e., the teleport-detection check
used when building issuance assetList). Concretely, inside the for _, out :=
range asstGp.Outputs loop, add the same conditional (e.g., if isTeleport(out) or
the existing out teleport check used above) to continue/skip teleport outputs
before constructing the domain.NormalAsset so assetList only receives
non-teleport outputs.

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)
pkg/ark-lib/tree/forfeit_tx.go (1)

15-22: Deduct extensionAnchor value from the forfeit output total.

If extensionAnchor carries a non‑zero value, forfeitOut is overstated (only ANCHOR_VALUE is subtracted), so outputs exceed inputs and the rebuilt txid will never match. Subtract extensionAnchor.Value (or assert it must be zero) when computing sumPrevout.

🐛 Proposed fix
 	sumPrevout := int64(0)
 	for _, prevout := range prevouts {
 		sumPrevout += prevout.Value
 	}
 	sumPrevout -= txutils.ANCHOR_VALUE
+	if extensionAnchor != nil {
+		sumPrevout -= extensionAnchor.Value
+	}
🤖 Fix all issues with AI agents
In `@internal/infrastructure/tx-builder/covenantless/builder.go`:
- Around line 1212-1273: The loop over assetPkt.Assets currently returns output
inside the loop causing only the first AssetGroup to be validated; move the
final "return output, nil" out of the for _, asset := range assetPkt.Assets loop
(or enforce that assetPkt.Assets has length 1) so every asset group is fully
validated against vtxoMap, the asset input checks (in.Vin range), extension
type/assertion (domain.ExtAsset and domain.AssetExtension) and foundAsset logic
for each asset group before returning from the function.
🧹 Nitpick comments (1)
internal/infrastructure/tx-builder/covenantless/utils.go (1)

4-7: Avoid unconditional stdout logging in leaf construction.

log.Printf runs for every offchain receiver and can create noisy output in production. Consider removing it or gating behind a debug logger.

♻️ Suggested cleanup
-import (
+import (
 	"encoding/hex"
 	"fmt"
-	"log"
 
 	"github.com/arkade-os/arkd/internal/core/domain"
 	"github.com/arkade-os/arkd/pkg/ark-lib/extension"
 	"github.com/arkade-os/arkd/pkg/ark-lib/script"
 	"github.com/arkade-os/arkd/pkg/ark-lib/tree"
 	"github.com/btcsuite/btcd/btcec/v2/schnorr"
 	"github.com/btcsuite/btcd/btcutil"
 	"github.com/btcsuite/btcd/chaincfg"
 	"github.com/btcsuite/btcd/txscript"
 	"github.com/btcsuite/btcd/wire"
 )
@@
-			log.Printf("plain vtxo script %s", hex.EncodeToString(vtxoScript))
-
 			leaves = append(leaves, tree.Leaf{
 				Script:              hex.EncodeToString(vtxoScript),
 				Amount:              receiver.Amount,
 				CosignersPublicKeys: cosigners,
 			})

Also applies to: 120-120

Comment on lines 1212 to 1273
for _, asset := range assetPkt.Assets {
if asset.AssetId == nil {
// Issuance assets don't have inputs to validate against existing VTXOs
continue
}
assetID := asset.AssetId.ToString()
for _, in := range asset.Inputs {
if in.Type == extension.AssetTypeTeleport {
continue
}

if int(in.Vin) >= len(pkt.UnsignedTx.TxIn) {
return nil, fmt.Errorf(
"asset input index out of range for txid %s: vin=%d",
txid, in.Vin,
)
}

prevOut := pkt.UnsignedTx.TxIn[in.Vin].PreviousOutPoint
outpoint := domain.Outpoint{
Txid: prevOut.Hash.String(),
VOut: prevOut.Index,
}

vtxo, ok := vtxoMap[outpoint]
if !ok {
return nil, fmt.Errorf(
"vtxo not found for asset input txid=%s assetID=%s outpoint=%s",
txid, assetID, outpoint.String(),
)
}

foundAsset := false
for _, ext := range vtxo.Extensions {
if ext.Type() != domain.ExtAsset {
continue
}

assetExt, ok := ext.(domain.AssetExtension)
if !ok {
return nil, fmt.Errorf(
"extension type is ExtAsset but concrete type is %T",
ext,
)
}

if assetExt.AssetID == assetID {
foundAsset = true
break
}
}

if !foundAsset {
return nil, fmt.Errorf(
"vtxo %s missing asset extension for assetID %s",
outpoint.String(), assetID,
)
}
}

return output, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate all asset groups before returning the extension anchor.

The function returns after the first AssetGroup, so any additional groups in the same packet are never validated and could bypass checks. Move the return after the loop (or enforce a single‑group invariant).

🐛 Proposed fix
-		for _, asset := range assetPkt.Assets {
+		for _, asset := range assetPkt.Assets {
 			if asset.AssetId == nil {
 				// Issuance assets don't have inputs to validate against existing VTXOs
 				continue
 			}
 			assetID := asset.AssetId.ToString()
 			for _, in := range asset.Inputs {
 				if in.Type == extension.AssetTypeTeleport {
 					continue
 				}
 				// ...
 			}
-
-			return output, nil
 		}
+		return output, nil
🤖 Prompt for AI Agents
In `@internal/infrastructure/tx-builder/covenantless/builder.go` around lines 1212
- 1273, The loop over assetPkt.Assets currently returns output inside the loop
causing only the first AssetGroup to be validated; move the final "return
output, nil" out of the for _, asset := range assetPkt.Assets loop (or enforce
that assetPkt.Assets has length 1) so every asset group is fully validated
against vtxoMap, the asset input checks (in.Vin range), extension type/assertion
(domain.ExtAsset and domain.AssetExtension) and foundAsset logic for each asset
group before returning from the function.

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/infrastructure/db/service.go`:
- Around line 392-394: Assets() returns s.assetStore but the Postgres
initialization never assigns assetStore and Close() doesn't shut it down; update
the Postgres branch of the service initialization (where the Postgres DB client
is created) to instantiate and assign s.assetStore (the concrete Postgres
AssetRepository) and then modify the service.Close() method to call
s.assetStore.Close() (or the repository's Close/Shutdown method) alongside other
resources so the asset store is not nil and is properly closed on shutdown.
♻️ Duplicate comments (21)
internal/infrastructure/tx-builder/covenantless/builder.go (1)

1212-1261: Critical: Early return bypasses validation of subsequent asset groups.

The return output, nil at line 1260 is inside the for _, asset := range assetPkt.Assets loop. This means only the first asset group with a non-nil AssetId is validated—any additional groups in the packet are silently skipped, potentially allowing invalid asset transitions to pass verification.

Additionally, there's variable shadowing at line 1245 where asset shadows the outer loop variable.

🐛 Proposed fix
 		for _, asset := range assetPkt.Assets {
 			if asset.AssetId == nil {
 				// Issuance assets don't have inputs to validate against existing VTXOs
 				continue
 			}
 			assetID := asset.AssetId.ToString()
 			for _, in := range asset.Inputs {
 				if in.Type == extension.AssetTypeTeleport {
 					continue
 				}
 
 				if int(in.Vin) >= len(pkt.UnsignedTx.TxIn) {
 					return nil, fmt.Errorf(
 						"asset input index out of range for txid %s: vin=%d",
 						txid, in.Vin,
 					)
 				}
 
 				prevOut := pkt.UnsignedTx.TxIn[in.Vin].PreviousOutPoint
 				outpoint := domain.Outpoint{
 					Txid: prevOut.Hash.String(),
 					VOut: prevOut.Index,
 				}
 
 				vtxo, ok := vtxoMap[outpoint]
 				if !ok {
 					return nil, fmt.Errorf(
 						"vtxo not found for asset input txid=%s assetID=%s outpoint=%s",
 						txid, assetID, outpoint.String(),
 					)
 				}
 
 				foundAsset := false
-				for _, asset := range vtxo.Assets {
-					if asset.AssetID == assetID {
+				for _, vtxoAsset := range vtxo.Assets {
+					if vtxoAsset.AssetID == assetID {
 						foundAsset = true
 						break
 					}
 				}
 
 				if !foundAsset {
 					return nil, fmt.Errorf(
 						"vtxo %s missing asset extension for assetID %s",
 						outpoint.String(), assetID,
 					)
 				}
 			}
-
-			return output, nil
 		}
+
+		return output, nil
 	}
internal/core/application/indexer.go (1)

273-280: Use the stored asset amount when enriching by outpoint.

Amount is hardcoded to 0, so clients always see a zero balance even when the asset exists.

🐛 Fix amount propagation
-			allVtxos[j].Assets = append(allVtxos[j].Assets, domain.Asset{
-				AssetID: asset.AssetID,
-				Amount:  0,
-			})
+			allVtxos[j].Assets = append(allVtxos[j].Assets, domain.Asset{
+				AssetID: asset.AssetID,
+				Amount:  asset.Amount,
+			})
internal/interface/grpc/handlers/indexer_teleport_test.go (2)

178-193: Verify the mock stream expectation.

The mock expectation on Send is never asserted, so the test can pass even if Send was never called.

✅ Add expectation verification
 	time.Sleep(200 * time.Millisecond) // wait for processing
+
+	stream.AssertExpectations(t)
 }

195-201: Avoid asserting inside the goroutine.

assert.NoError inside the goroutine can race and may not fail the test. Capture the error and assert in the main goroutine.

🔧 Safer goroutine assertion
+	errCh := make(chan error, 1)
 	// 3. Start GetSubscription loop
 	go func() {
 		err := svc.GetSubscription(&arkv1.GetSubscriptionRequest{
 			SubscriptionId: subRes.SubscriptionId,
 		}, stream)
-		assert.NoError(t, err)
+		errCh <- err
 	}()
+
+	// ... after sending the event
+	select {
+	case err := <-errCh:
+		assert.NoError(t, err)
+	case <-time.After(500 * time.Millisecond):
+		// still running is OK for this test
+	}
internal/core/application/utils.go (2)

196-199: Guard PkScript slicing for non‑asset outputs.

out.PkScript[2:] assumes a valid P2TR script length. Short/invalid scripts can panic or yield wrong pubkeys.

🛡️ Defensive length check
 	} else {
+		if len(out.PkScript) < 34 {
+			continue // skip non-P2TR outputs
+		}
 		pubKey = hex.EncodeToString(out.PkScript[2:])
 		isSubDust = script.IsSubDustScript(out.PkScript)
 	}

220-231: Match assets to outputs by outpoint, not slice index.

outs is filtered (anchors skipped), so using asst.VOut as a slice index can silently drop valid assets. Match by outpoint instead.

🔧 Safer attachment by outpoint
-	// Add AssetGroup if Present
-	for _, asst := range assetList {
-		idx := int(asst.VOut)
-		if idx < 0 || idx >= len(outs) {
-			continue
-		}
-
-		outs[idx].Assets = append(outs[idx].Assets, domain.Asset{
-			AssetID: asst.AssetID,
-			Amount:  asst.Amount,
-		})
-	}
+	// Add AssetGroup if Present - match by outpoint
+	outByVout := make(map[uint32]int, len(outs))
+	for i := range outs {
+		outByVout[outs[i].Outpoint.VOut] = i
+	}
+	for _, asst := range assetList {
+		idx, ok := outByVout[asst.VOut]
+		if !ok {
+			continue
+		}
+		outs[idx].Assets = append(outs[idx].Assets, domain.Asset{
+			AssetID: asst.AssetID,
+			Amount:  asst.Amount,
+		})
+	}
internal/core/application/service.go (9)

355-357: Restore or explicitly remove the watcher restoration.

Leaving restoreWatchingVtxos commented out risks missing watches after restart. Either restore it or remove it with a clear rationale.

🔧 Reinstate watcher restoration
-	// if err := svc.restoreWatchingVtxos(); err != nil {
-	// 	return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
-	// }
+	if err := svc.restoreWatchingVtxos(); err != nil {
+		return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
+	}

1131-1138: Clarify whether asset persistence is blocking.

storeAssetDetailsFromArkTx failures are logged but the offchain tx still succeeds, which can permanently desync asset state. Decide whether this must fail the submission or is best‑effort.

🔧 Blocking option (fail submission)
 	if assetOutputIndex >= 0 {
 		if err := s.storeAssetDetailsFromArkTx(
 			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
 		); err != nil {
-			log.WithError(err).Errorf(
-				"failed to store asset details for offchain tx %s", txid,
-			)
+			return nil, errors.INTERNAL_ERROR.New(
+				"failed to store asset details for offchain tx %s: %w", txid, err,
+			)
 		}
 	}

1566-1587: Persist teleport assets with correct vout and fail on insert errors.

OutputIndex uses the loop index (can diverge from the encoded vout) and insert errors are swallowed, which can leave intents inconsistent.

🐛 Use output.Vout and propagate insert failures
-					if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
+					if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 						IntentID:    proofTxid,
 						Script:      teleportScript,
-						OutputIndex: uint32(i),
+						OutputIndex: uint32(output.Vout),
 						AssetID:     asst.AssetId.ToString(),
 						Amount:      output.Amount,
 						IsClaimed:   false,
 					}); err != nil {
-						log.WithError(err).Warn("failed to insert teleport asset")
+						return "", errors.INTERNAL_ERROR.New(
+							"failed to insert teleport asset: %w", err,
+						).WithMetadata(map[string]any{
+							"intent_id": proofTxid,
+							"asset_id":  asst.AssetId.ToString(),
+							"vout":      output.Vout,
+						})
 					}

1689-1691: Only count off‑chain outputs in offchainOutputs.

offchainOutputs is appended for all outputs, so on‑chain outputs inflate fee calculations. Move the append into the off‑chain branch.

🛠️ Move append into off‑chain branch
 		} else {
 			...
 			hasOffChainReceiver = true
 			rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
+			offchainOutputs = append(offchainOutputs, *output)
 		}
 
 		receivers = append(receivers, rcv)
-		offchainOutputs = append(offchainOutputs, *output)

1743-1747: Remove the stale err check after GetArkPsbtFields.

err here refers to an earlier scope and is always nil, so this block is dead code. Either capture the error from GetArkPsbtFields or remove the check.

🧹 Remove dead check
-		if err != nil {
-			return "", errors.INVALID_PSBT_INPUT.New(
-				"failed to get asset seal field for input %d: %w", i+1, err,
-			).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
-		}

3240-3241: Remove stray stdout debug print.

Use the project logger or drop the line before merge.

🧹 Remove debug print
-		fmt.Printf("events %+v", changes)

4421-4431: Skip teleport outputs when building normal assets for updates.

The update path currently adds teleport outputs to assetList, unlike the issuance path.

🛠️ Skip teleport outputs
 		for _, out := range asstGp.Outputs {
+			if out.Type == extension.AssetTypeTeleport {
+				continue
+			}
 			asst := domain.NormalAsset{
 				Outpoint: domain.Outpoint{
 					Txid: arkTx.TxID(),
 					VOut: uint32(out.Vout),
 				},
 				AssetID: assetId.ToString(),
 				Amount:  out.Amount,
 			}
 			assetList = append(assetList, asst)
 		}

4476-4493: Ensure quantity decreases fail on insufficient balance.

DecreaseAssetGroupQuantity can be a silent no‑op in SQL backends (0 rows affected), yet updateAssetQuantity returns success. Add a balance check here or make the repo methods report zero‑row updates as errors.


4511-4526: Use the encoded asset vout for OutputVout.

The loop index can diverge from the encoded vout; events should carry the actual assetOut.Vout.

🐛 Fix OutputVout
-					OutputVout:     uint32(outIdx),
+					OutputVout:     assetOut.Vout,
internal/infrastructure/db/service.go (1)

567-569: Guard PkScript slicing for non‑asset outputs.

out.PkScript[2:] can panic or return invalid data for short/non‑P2TR scripts.

🛡️ Defensive check
 			} else {
+				if len(out.PkScript) < 34 {
+					log.Warnf("skipping non-P2TR output at index %d", outIndex)
+					continue
+				}
 				isSubDust = script.IsSubDustScript(out.PkScript)
 				pubKey = out.PkScript[2:]
 			}
api-spec/openapi/swagger/ark/v1/indexer.openapi.json (1)

8-23: Path parameter naming mismatch (already flagged).

The path uses {asset_id} (snake_case) but the parameter name is assetId (camelCase). This was previously flagged and should be aligned.

api-spec/protobuf/ark/v1/indexer.proto (2)

78-83: Doc comment mismatch (already flagged).

The comment says "GetAsset" but the RPC is GetAssetGroup. This was previously flagged.


110-117: Typo in doc comment (already flagged).

"subscribtion" should be "subscription" at line 111. This was previously flagged.

internal/interface/grpc/handlers/indexer.go (2)

50-82: Potential nil pointer dereference on resp.AssetGroup.

While there's a nil check for resp at line 61, if indexerSvc.GetAssetGroup returns a non-nil response with a nil AssetGroup field, accessing resp.AssetGroup.Metadata at line 66 will panic. The previous review suggested checking resp.AssetGroup == nil as well.

🐛 Proposed fix
 	if resp == nil {
 		return nil, status.Errorf(codes.NotFound, "asset not found: %s", assetId)
 	}
+	if resp.AssetGroup == nil {
+		return nil, status.Errorf(codes.NotFound, "asset group not found for id %s", assetId)
+	}

 	assetMetadata := make([]*arkv1.AssetMetadata, 0)

486-494: Both listener channels could be nil, causing no event delivery.

If neither a script subscription nor a teleport subscription exists for the given subscriptionId, both scriptCh and teleportCh will be nil. While the stream won't fully block (timer and context cancellation still work), the client will only receive heartbeats and never actual events. Consider returning an error if no subscriptions exist for clarity.

🐛 Proposed fix
 	teleportCh, err := h.teleportSubsHandler.getListenerChannel(subscriptionId)
 	if err != nil && !strings.Contains(err.Error(), "listener not found") {
 		return status.Error(codes.Internal, err.Error())
 	}

+	if scriptCh == nil && teleportCh == nil {
+		return status.Error(codes.NotFound, "no active subscriptions found for id")
+	}
+
 	// create a Timer that will fire after one heartbeat interval
🧹 Nitpick comments (3)
internal/infrastructure/tx-builder/covenantless/builder.go (1)

288-302: Remove unused forfeitTxPtxList and fix typo.

  1. forfeitTxPtxList is accumulated but never read afterward—this is dead code.
  2. Line 296 has a typo: "verfity" → "verify".
♻️ Proposed fix
-	forfeitTxPtxList := make([]*psbt.Packet, 0)
-
 	for _, forfeitTx := range forfeitTxs {
 		tx, err := psbt.NewFromRawBytes(strings.NewReader(forfeitTx), true)
 		if err != nil {
 			return nil, err
 		}
 
-		// verfity asset forfeit transaction
+		// verify asset forfeit transaction
 		extensionAnchor, err := verifyAssetForfeitTransaction(indexedVtxos, tx)
 		if err != nil {
 			return nil, err
 		}
-
-		forfeitTxPtxList = append(forfeitTxPtxList, tx)
internal/core/application/indexer.go (1)

246-257: Enrich only paged Vtxos to avoid unnecessary asset lookups.

The enrichment loop runs over all Vtxos even though only a page is returned, which can trigger a lot of extra DB calls. Consider enriching just the paged slice (or batch fetching) instead.

♻️ Suggested refactor
- vtxos, pageResp := paginate(allVtxos, page, maxPageSizeSpendableVtxos)
-
- for j, v := range allVtxos {
- 	// add asset to vtxo if present
- 	asset, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, v.Outpoint)
- 	if err == nil && asset != nil {
- 		allVtxos[j].Assets = append(allVtxos[j].Assets, domain.Asset{
- 			AssetID: asset.AssetID,
- 			Amount:  asset.Amount,
- 		})
- 	}
- }
+ vtxos, pageResp := paginate(allVtxos, page, maxPageSizeSpendableVtxos)
+
+ for j := range vtxos {
+ 	// add asset to vtxo if present
+ 	asset, err := i.repoManager.Assets().GetAssetByOutpoint(ctx, vtxos[j].Outpoint)
+ 	if err == nil && asset != nil {
+ 		vtxos[j].Assets = append(vtxos[j].Assets, domain.Asset{
+ 			AssetID: asset.AssetID,
+ 			Amount:  asset.Amount,
+ 		})
+ 	}
+ }
internal/interface/grpc/handlers/indexer_teleport_test.go (1)

159-168: Check setup errors from asset/PSBT encoding.

Ignoring these errors can let the test pass with invalid setup data.

✅ Safer setup
-	opretTxOut, _ := assetGroup.EncodeAssetPacket()
+	opretTxOut, err := assetGroup.EncodeAssetPacket()
+	assert.NoError(t, err)
 
 	// Create PSBT
 	tx := wire.NewMsgTx(2)
 	tx.AddTxOut(&opretTxOut)
 
-	ptx, _ := psbt.NewFromUnsignedTx(tx)
+	ptx, err := psbt.NewFromUnsignedTx(tx)
+	assert.NoError(t, err)
 
 	// Encode PSBT to Base64 (ignoring error for test)
-	b64Ptx, _ := ptx.B64Encode()
+	b64Ptx, err := ptx.B64Encode()
+	assert.NoError(t, err)

Comment on lines +392 to +394
func (s *service) Assets() domain.AssetRepository {
return s.assetStore
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Initialize and close the asset store for Postgres.

Assets() returns s.assetStore, but the Postgres branch never assigns it, and Close() doesn’t close it. This will return nil (and leak resources) in Postgres deployments.

🐛 Initialize assetStore and close it
 	case "postgres":
 		...
 		convictionStore, err = convictionStoreFactory(db)
 		if err != nil {
 			return nil, fmt.Errorf("failed to create conviction store: %w", err)
 		}
+		assetStore, err = assetStoreFactory(db)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create asset store: %w", err)
+		}
 		intentFeesStore, err = intentFeesStoreFactory(db)
 		if err != nil {
 			return nil, fmt.Errorf("failed to create intent fees store: %w", err)
 		}
 		...
 
 func (s *service) Close() {
 	s.eventStore.Close()
 	s.roundStore.Close()
 	s.vtxoStore.Close()
 	s.scheduledSessionStore.Close()
 	s.offchainTxStore.Close()
 	s.convictionStore.Close()
+	s.assetStore.Close()
 }
🤖 Prompt for AI Agents
In `@internal/infrastructure/db/service.go` around lines 392 - 394, Assets()
returns s.assetStore but the Postgres initialization never assigns assetStore
and Close() doesn't shut it down; update the Postgres branch of the service
initialization (where the Postgres DB client is created) to instantiate and
assign s.assetStore (the concrete Postgres AssetRepository) and then modify the
service.Close() method to call s.assetStore.Close() (or the repository's
Close/Shutdown method) alongside other resources so the asset store is not nil
and is properly closed on shutdown.

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/infrastructure/db/service_test.go`:
- Around line 1747-1766: The subtest "insert get and update teleport asset" is
not self-contained because it inserts a teleport asset that depends on a group
record; before calling svc.Assets().InsertTeleportAsset(ctx, asset) create a
locally-scoped unique group ID (e.g., groupID := randomString(16)) and insert
that group using the project's group insertion helper (call the service method
used elsewhere, e.g., svc.Groups().InsertGroup(ctx, domain.Group{ID: groupID,
...}) or the equivalent group insert function) and set the teleport asset’s
grouping FK fields to that ID so the FK constraint is satisfied within this
subtest.

In `@internal/interface/grpc/handlers/indexer.go`:
- Around line 619-661: The goroutine uses the loop-scoped variable event
(accessing event.Txid and event.Tx), which can race as event is reused; capture
the needed values (e.g., txid := event.Txid; tx := event.Tx) and pass them into
the closure (or accept them as parameters) when invoking the goroutine for
listener *listener[*arkv1.GetSubscriptionResponse]; also do the same fix for the
script subscription handler. Replace the current non-blocking send/drop
semantics on listener.ch with a guaranteed-delivery approach by making
listener.ch a buffered channel sized appropriately (ensure listener.ch is
created as a buffered channel where listener is constructed in
teleportSubsHandler) so the goroutine can send without silently dropping
messages and without blocking the main loop.
♻️ Duplicate comments (10)
internal/infrastructure/db/service.go (2)

420-427: Close assetStore in service.Close().

assetStore is now initialized but never closed, which can leak DB resources. Add a Close call (with a nil guard if needed).

✅ Suggested fix
 func (s *service) Close() {
 	s.eventStore.Close()
 	s.roundStore.Close()
 	s.vtxoStore.Close()
 	s.scheduledSessionStore.Close()
 	s.offchainTxStore.Close()
 	s.convictionStore.Close()
+	if s.assetStore != nil {
+		s.assetStore.Close()
+	}
 }

543-574: Skip non-asset OP_RETURN outputs and validate P2TR length before slicing.

If asset packets are chunked, later OP_RETURN chunks may not match ContainsAssetPacket and will be parsed as P2TR, creating bogus vtxos or risking panics on short scripts. Add an explicit OP_RETURN skip (for non-asset outputs) and a length/type guard before out.PkScript[2:].

🛠️ Possible fix
 			} else {
+				if bytes.HasPrefix(out.PkScript, []byte{txscript.OP_RETURN}) {
+					// Skip non-asset OP_RETURN outputs (e.g., chunked asset packets)
+					continue
+				}
+				if len(out.PkScript) < 34 {
+					log.Warnf("skipping non-P2TR output at index %d", outIndex)
+					continue
+				}
 				isSubDust = script.IsSubDustScript(out.PkScript)
 				pubKey = out.PkScript[2:]
 			}
In ark-lib extension (v0.8.1-0.20260102141947-c66f85422174), does ContainsAssetPacket return true for every OP_RETURN chunk of a chunked asset packet, or only the first? What is the expected handling of additional OP_RETURN outputs?
internal/infrastructure/db/postgres/asset_repo.go (4)

32-34: Avoid closing a shared *sql.DB in repository Close().

If the DB handle is shared across repositories, closing it here can break other stores. Let the service manage lifecycle or make this a no-op.


147-168: Make anchor + asset inserts atomic.

CreateAssetAnchor followed by AddAsset calls can leave an orphaned anchor on failure. Wrap these writes in a single transaction.


206-239: Wrap asset group creation + metadata upserts in a transaction.

A failure during metadata insertion can leave a partially created group. Use a transaction to keep the group and its metadata consistent.


284-292: Surface insufficient quantity on decrease.

SubtractFromAssetQuantity can update zero rows (quantity < amount) but this method still returns nil. Check RowsAffected() and return a clear error.

api-spec/protobuf/ark/v1/indexer.proto (1)

78-103: Fix RPC doc comment naming + subscription typo.

Line 78 says “GetAsset” while the RPC is GetAssetGroup, and Line 102 misspells “subscription”.

✏️ Proposed fix
-  // GetAsset returns the asset information and metadata for the specified asset ID.
+  // GetAssetGroup returns the asset information and metadata for the specified asset ID.
...
-  // scripts. It can also be used to update an existing subscribtion by adding new scripts to it.
+  // scripts. It can also be used to update an existing subscription by adding new scripts to it.
api-spec/openapi/swagger/ark/v1/indexer.openapi.json (1)

8-23: Align path parameter naming with template (and fix description).

Line 8 uses {asset_id} but Line 17 declares assetId, which breaks OpenAPI parameter binding. Line 13 also says “GetAsset” instead of “GetAssetGroup”.

💡 Proposed fix
-        "description": "GetAsset returns the asset information and metadata for the specified asset ID.",
+        "description": "GetAssetGroup returns the asset information and metadata for the specified asset ID.",
...
-            "name": "assetId",
+            "name": "asset_id",
internal/interface/grpc/handlers/indexer.go (2)

50-80: Guard against nil AssetGroup to avoid panic.

Line 66 dereferences resp.AssetGroup.Metadata without checking resp.AssetGroup. If the service returns a nil AssetGroup, this will panic. Add a nil guard and return NotFound.

🐛 Proposed fix
-	if resp == nil {
-		return nil, status.Errorf(codes.NotFound, "asset not found: %s", assetId)
-	}
+	if resp == nil || resp.AssetGroup == nil {
+		return nil, status.Errorf(codes.NotFound, "asset group not found: %s", assetId)
+	}

433-442: Handle missing subscriptions before entering select.

If both scriptCh and teleportCh are nil, the select will never receive events and the stream will only emit heartbeats. Consider returning NotFound early.

🐛 Proposed fix
 	teleportCh, err := h.teleportSubsHandler.getListenerChannel(subscriptionId)
 	if err != nil && !strings.Contains(err.Error(), "listener not found") {
 		return status.Error(codes.Internal, err.Error())
 	}
+	if scriptCh == nil && teleportCh == nil {
+		return status.Error(codes.NotFound, "no active subscriptions found for id")
+	}

Comment on lines +1747 to +1766
t.Run("insert get and update teleport asset", func(t *testing.T) {
ctx := context.Background()
script := randomString(32)
intentID := randomString(32)
assetID := "asset-3"
outputIndex := uint32(7)
amount := uint64(5000)

asset := domain.TeleportAsset{
Script: script,
IntentID: intentID,
AssetID: assetID,
OutputIndex: outputIndex,
Amount: amount,
IsClaimed: false,
}

err := svc.Assets().InsertTeleportAsset(ctx, asset)
require.NoError(t, err, "InsertTeleportAsset should succeed")

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make the teleport-asset subtest self-contained.

It relies on a prior subtest to create "asset-3". Running this subtest alone will fail FK constraints. Create a local group with a unique ID in this subtest.

✅ Suggested adjustment
-		assetID := "asset-3"
+		assetID := "asset-teleport-1"
+		err := svc.Assets().InsertAssetGroup(ctx, domain.AssetGroup{
+			ID:       assetID,
+			Quantity: 1,
+		})
+		require.NoError(t, err)

 		asset := domain.TeleportAsset{
 			Script:      script,
 			IntentID:    intentID,
 			AssetID:     assetID,
 			OutputIndex: outputIndex,
 			Amount:      amount,
 			IsClaimed:   false,
 		}

-		err := svc.Assets().InsertTeleportAsset(ctx, asset)
+		err = svc.Assets().InsertTeleportAsset(ctx, asset)
🤖 Prompt for AI Agents
In `@internal/infrastructure/db/service_test.go` around lines 1747 - 1766, The
subtest "insert get and update teleport asset" is not self-contained because it
inserts a teleport asset that depends on a group record; before calling
svc.Assets().InsertTeleportAsset(ctx, asset) create a locally-scoped unique
group ID (e.g., groupID := randomString(16)) and insert that group using the
project's group insertion helper (call the service method used elsewhere, e.g.,
svc.Groups().InsertGroup(ctx, domain.Group{ID: groupID, ...}) or the equivalent
group insert function) and set the teleport asset’s grouping FK fields to that
ID so the FK constraint is satisfied within this subtest.

Comment on lines +619 to +661
teleportListenersCopy := h.teleportSubsHandler.getListenersCopy()
if len(teleportListenersCopy) > 0 {
parsedTeleportEvents := make([]*arkv1.TeleportEvent, 0)
for _, te := range event.TeleportAssets {
parsedTeleportEvents = append(parsedTeleportEvents, &arkv1.TeleportEvent{
TeleportHash: te.TeleportHash,
AnchorOutpoint: te.AnchorOutpoint.String(),
AssetId: te.AssetID,
Amount: te.Amount,
OutputVout: te.OutputVout,
CreatedAt: te.CreatedAt,
ExpiresAt: te.ExpiresAt,
})
}

for _, l := range teleportListenersCopy {
teleportEvents := make([]*arkv1.TeleportEvent, 0)
involvedHashes := make([]string, 0)

for _, tpEvent := range parsedTeleportEvents {
if _, ok := l.topics[tpEvent.TeleportHash]; ok {
involvedHashes = append(involvedHashes, tpEvent.TeleportHash)
teleportEvents = append(teleportEvents, tpEvent)
}
}

if len(teleportEvents) > 0 {
go func(listener *listener[*arkv1.GetSubscriptionResponse]) {
select {
case listener.ch <- &arkv1.GetSubscriptionResponse{
Data: &arkv1.GetSubscriptionResponse_Event{
Event: &arkv1.IndexerSubscriptionEvent{
Txid: event.Txid,
TeleportEvents: teleportEvents,
TeleportHashes: involvedHashes,
Tx: event.Tx,
},
},
}:
default:
// channel is full, skip this message to prevent blocking
}
}(l)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp/repo && cat -n internal/interface/grpc/handlers/indexer.go | sed -n '580,670p'

Repository: arkade-os/arkd

Length of output: 117


🏁 Script executed:

cd /tmp/repo && cat -n internal/interface/grpc/handlers/indexer.go | sed -n '550,700p'

Repository: arkade-os/arkd

Length of output: 117


🏁 Script executed:

cd /tmp/repo && rg -n "func.*teleportListenersCopy\|func.*\(.*event.*\)" internal/interface/grpc/handlers/indexer.go | head -20

Repository: arkade-os/arkd

Length of output: 117


🏁 Script executed:

cd /tmp/repo && ast-grep --pattern 'func $$$($_) {
  $$$
  teleportListenersCopy := h.teleportSubsHandler.getListenersCopy()
  $$$
}'

Repository: arkade-os/arkd

Length of output: 117


🏁 Script executed:

cat -n internal/interface/grpc/handlers/indexer.go | sed -n '550,700p'

Repository: arkade-os/arkd

Length of output: 5650


🏁 Script executed:

rg -n "for.*event" internal/interface/grpc/handlers/indexer.go -B 5 | head -40

Repository: arkade-os/arkd

Length of output: 1230


🏁 Script executed:

ast-grep --pattern 'for $_, event := range $_ {
  $$$
}'

Repository: arkade-os/arkd

Length of output: 12093


Capture loop variable event before spawning goroutines to avoid race conditions.

The goroutines at lines 597–616 and 646–661 reference event.Txid and event.Tx without capturing them. Since event is reused on each loop iteration, these references race with subsequent events. Pass the values as parameters to the closure.

Additionally, replace the non-blocking send with drop semantics (select { default: }) with buffered channels to maintain delivery guarantees for subscription events, as these should never be dropped.

Proposed fix
 		teleportListenersCopy := h.teleportSubsHandler.getListenersCopy()
 		if len(teleportListenersCopy) > 0 {
+			txid := event.Txid
+			tx := event.Tx
 			parsedTeleportEvents := make([]*arkv1.TeleportEvent, 0)
 			for _, te := range event.TeleportAssets {
 				parsedTeleportEvents = append(parsedTeleportEvents, &arkv1.TeleportEvent{
@@
 				if len(teleportEvents) > 0 {
-					go func(listener *listener[*arkv1.GetSubscriptionResponse]) {
+					go func(listener *listener[*arkv1.GetSubscriptionResponse], txid string, tx []byte, teleportEvents []*arkv1.TeleportEvent, involvedHashes []string) {
 						select {
 						case listener.ch <- &arkv1.GetSubscriptionResponse{
 							Data: &arkv1.GetSubscriptionResponse_Event{
 								Event: &arkv1.IndexerSubscriptionEvent{
-									Txid:           event.Txid,
+									Txid:           txid,
 									TeleportEvents: teleportEvents,
-									TeleportHashes: involvedHashes,
-									Tx:             event.Tx,
+									TeleportHashes: involvedHashes,
+									Tx:             tx,
 								},
 							},
-						}:
-						default:
-							// channel is full, skip this message to prevent blocking
+						}
 						}
-					}(l)
+					}(l, txid, tx, teleportEvents, involvedHashes)
 				}
 			}
 		}

Apply the same fix to the script subscription handler at lines 597–616.

🤖 Prompt for AI Agents
In `@internal/interface/grpc/handlers/indexer.go` around lines 619 - 661, The
goroutine uses the loop-scoped variable event (accessing event.Txid and
event.Tx), which can race as event is reused; capture the needed values (e.g.,
txid := event.Txid; tx := event.Tx) and pass them into the closure (or accept
them as parameters) when invoking the goroutine for listener
*listener[*arkv1.GetSubscriptionResponse]; also do the same fix for the script
subscription handler. Replace the current non-blocking send/drop semantics on
listener.ch with a guaranteed-delivery approach by making listener.ch a buffered
channel sized appropriately (ensure listener.ch is created as a buffered channel
where listener is constructed in teleportSubsHandler) so the goroutine can send
without silently dropping messages and without blocking the main loop.

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/service.go`:
- Around line 4400-4404: The error message at the s.ensureAssetPresence callsite
is misleading—change it to accurately reflect that the validation failed because
the control asset is missing or the asset list is empty; update the returned
error from the s.ensureAssetPresence block to something like "control asset not
present in assetGroupList" and include the underlying error (wrap with %w) so
callers see the specific reason reported by ensureAssetPresence (keep reference
to s.ensureAssetPresence and the controlAssetID variable when constructing the
message).
♻️ Duplicate comments (8)
internal/core/application/service.go (8)

355-358: Restore or remove commented-out restoreWatchingVtxos call.

This commented-out code disables vtxo watching restoration on service startup, potentially missing fraud detection events. Either restore it or remove it with a comment explaining why it's intentionally disabled.


1131-1139: Clarify error handling semantics for asset storage.

Asset storage errors are logged but don't fail the transaction. This creates potential inconsistency where the offchain transaction succeeds but asset state isn't persisted. Make this behavior explicit—either return the error to fail the transaction, or add a comment explaining why best-effort is acceptable.


1578-1587: Teleport asset insertion errors should fail registration.

InsertTeleportAsset errors are logged but the receiver is still added to the intent. This creates inconsistent state where the intent references a teleport asset that wasn't persisted. Return an error instead.

Additionally, OutputIndex: uint32(i) uses the loop index which may not match the actual output vout.


1685-1691: On-chain outputs are incorrectly added to offchainOutputs.

Line 1690 appends the output to offchainOutputs regardless of whether it's on-chain or off-chain. The append should only occur in the off-chain branch (where hasOffChainReceiver is set), not unconditionally at the end.


4419-4428: Teleport outputs should be excluded from assetList for existing assets.

For issuance (lines 4359-4362), teleport outputs are correctly skipped. However, for existing assets, all outputs including teleports are added to assetList. Add the same check:

 		for _, out := range asstGp.Outputs {
+			if out.Type == extension.AssetTypeTeleport {
+				continue
+			}
+
 			asst := domain.NormalAsset{

4474-4495: Silent failure risk in DecreaseAssetGroupQuantity.

The SQLite and Postgres implementations use UPDATE ... WHERE quantity >= amount, which succeeds with zero rows affected when balance is insufficient. This function returns success even when the decrease failed. Add validation or modify the DB implementations to report zero-row updates as errors.


4519-4530: OutputVout should use assetOut.Vout instead of loop index.

Line 4523 sets OutputVout: uint32(outIdx) using the loop iteration index. For teleport outputs, this should reflect the actual vout from the asset output:

 				event := TeleportAsset{
 					TeleportHash:   hex.EncodeToString(assetOut.Script),
 					AnchorOutpoint: anchorOutpoint,
 					AssetID:        ast.AssetId.ToString(),
-					OutputVout:     uint32(outIdx),
+					OutputVout:     assetOut.Vout,
 					CreatedAt:      createdAt,
 					ExpiresAt:      expiresAt,
 				}

1742-1747: Dead code: err check references already-handled error.

The err variable here is from the s.cache.OffchainTxs().Includes() call on line 1726, which was already checked and handled on lines 1727-1736. At this point, err can only be nil. The error from GetArkPsbtFields on line 1739 is discarded with _.

Remove this unreachable block:

-	if err != nil {
-		return "", errors.INVALID_PSBT_INPUT.New(
-			"failed to get asset seal field for input %d: %w", i+1, err,
-		).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
-	}
🧹 Nitpick comments (2)
internal/core/application/service.go (2)

4442-4461: Consider returning errors from markTeleportInputsClaimed.

Currently, UpdateTeleportAsset errors are only logged. While this may be acceptable for a background operation, it could leave teleport assets in an inconsistent claimed state. Consider whether failures here should be surfaced.


4319-4342: Variable shadowing: inner assetId shadows outer variable.

Line 4326 declares assetId which shadows the outer assetId from line 4317. While this works correctly for the issuance flow, it can be confusing. Consider renaming to newAssetId for clarity:

-			assetId := extension.AssetId{
+			newAssetId := extension.AssetId{
 				TxHash: txHashBytes,
 				Index:  uint16(i),
 			}

And update subsequent references within this block.

Comment on lines +4400 to +4404
if err := s.ensureAssetPresence(ctx, assetGroupList, *controlAssetID); err != nil {
return fmt.Errorf(
"cannot update asset metadata while control asset is being issued",
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for ensureAssetPresence method definition
ast-grep --pattern $'func ($_ *service) ensureAssetPresence($$$) $_ {
  $$$
}'

Repository: arkade-os/arkd

Length of output: 1658


Remove or clarify the error message at lines 4401-4403.

The ensureAssetPresence method (in internal/core/application/asset_validation.go:657-676) validates that the control asset is present in the assetGroupList. However, the error message "cannot update asset metadata while control asset is being issued" does not match what the method actually checks—it returns errors when the asset is missing or no assets are provided, not when an asset is "being issued". Provide an error message that accurately reflects the validation failure.

🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 4400 - 4404, The error
message at the s.ensureAssetPresence callsite is misleading—change it to
accurately reflect that the validation failed because the control asset is
missing or the asset list is empty; update the returned error from the
s.ensureAssetPresence block to something like "control asset not present in
assetGroupList" and include the underlying error (wrap with %w) so callers see
the specific reason reported by ensureAssetPresence (keep reference to
s.ensureAssetPresence and the controlAssetID variable when constructing the
message).

altafan and others added 3 commits January 21, 2026 17:23
… with `Txid` and `Index`, and standardize integer encoding/decoding using `tlv` helpers.
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: 3

🤖 Fix all issues with AI agents
In `@internal/core/application/asset_validation.go`:
- Around line 365-366: The call to validateTeleportInput is passing the wrong
teleport index; replace the uint32(input.Vin) argument with the teleport index
from the witness (uint32(input.Witness.Index)) so
validateTeleportInput(decodedProof, m.arkTx, *grpAsset.AssetId,
uint32(input.Witness.Index), input.Witness.Script) receives the correct index;
update the call site where decodedProof, m.arkTx, *grpAsset.AssetId, and
input.Witness.Script are provided (the existing call that currently uses
input.Vin).

In `@internal/core/application/service.go`:
- Around line 4452-4461: The loop over grpAsset.Inputs is passing the wrong
index to UpdateTeleportAsset: it currently uses in.Vin but teleport claims
encode their index in in.Witness.Index; update the call in the block that checks
in.Type == extension.AssetTypeTeleport to pass in.Witness.Index instead of
in.Vin (keep intentId and teleportScript as-is and still call
s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId,
assetIdStr, in.Witness.Index, true) and preserve the existing error handling).

In `@pkg/ark-lib/go.mod`:
- Line 14: The go.mod pins github.com/lightningnetwork/lnd/tlv at v1.2.6; update
that module version to v1.3.2 in go.mod (replace v1.2.6 with v1.3.2 for
github.com/lightningnetwork/lnd/tlv), then run go mod tidy and rebuild/tests (go
build / go test) to ensure dependency resolution and that no breakage occurred
from the bump.
♻️ Duplicate comments (20)
pkg/ark-lib/offchain/tx_test.go (3)

133-138: Remove unused checkpointTxMap to avoid compile failure.
Line 133-138 declares and populates checkpointTxMap but never uses it; Go will fail the test build.

🛠️ Proposed fix
-	checkpointTxMap := make(map[string]string)
-	for _, cp := range checkpoints {
-		encoded, err := cp.B64Encode()
-		require.NoError(t, err)
-		checkpointTxMap[cp.UnsignedTx.TxHash().String()] = encoded
-	}
+	for _, cp := range checkpoints {
+		_, err := cp.B64Encode()
+		require.NoError(t, err)
+	}

140-149: Fix assetGroupIndex to use outputsNoAnchor indexing.
Line 140-149 sets assetGroupIndex using the original output index, but you later index into outputsNoAnchor.

🛠️ Proposed fix
 	outputsNoAnchor := make([]*wire.TxOut, 0, len(arkTx.UnsignedTx.TxOut)-1)
 	assetGroupIndex := -1
 	for idx, out := range arkTx.UnsignedTx.TxOut {
 		if bytes.Equal(out.PkScript, txutils.ANCHOR_PKSCRIPT) {
 			continue
 		}
 		outputsNoAnchor = append(outputsNoAnchor, out)
 		if extension.ContainsAssetPacket(out.PkScript) {
-			assetGroupIndex = idx
+			assetGroupIndex = len(outputsNoAnchor) - 1
 		}
 	}

171-175: Remove or use rebuiltCheckpointIDs.
Line 171-175 builds rebuiltCheckpointIDs but never uses it, which will not compile.

🛠️ Proposed fix (remove unused map)
-	// Map rebuilt checkpoint txids for quick lookup.
-	rebuiltCheckpointIDs := make(map[string]struct{})
-	for _, cp := range rebuiltCheckpoints {
-		rebuiltCheckpointIDs[cp.UnsignedTx.TxHash().String()] = struct{}{}
-	}
pkg/ark-lib/extension/encoding.go (1)

218-247: Guard varint‑derived allocations to avoid OOM on malformed payloads.
Line 224/231/241/324/406 allocate slices directly from untrusted lengths/counts. A crafted payload can force huge allocations before ReadFull fails. Add upper bounds (and remaining‑length checks when possible) before make(...).

🛡️ Suggested guard pattern (apply to metadata/script/output lengths)
-	count, err := tlv.ReadVarInt(r, scratch)
+	count, err := tlv.ReadVarInt(r, scratch)
 	if err != nil {
 		return nil, err
 	}
+	if count > maxMetadataEntries {
+		return nil, fmt.Errorf("metadata count too large: %d", count)
+	}
 
 	meta := make([]Metadata, count)
 	for i := uint64(0); i < count; i++ {
 		// Key
 		kLen, err := tlv.ReadVarInt(r, scratch)
 		if err != nil {
 			return nil, err
 		}
+		if kLen > maxFieldLen {
+			return nil, fmt.Errorf("metadata key too large: %d", kLen)
+		}
 		kBytes := make([]byte, kLen)

Also applies to: 293-343, 379-409

pkg/ark-lib/extension/asset.go (1)

94-119: Spec mismatch: local Vout/Vin should be uint16, not uint32.
Line 96 and Line 116 use 32‑bit indexes, which breaks wire‑format compatibility with the spec and with AssetId.Index/AssetRef.GroupIndex. Align to uint16 and update encoding/decoding helpers accordingly.

🐛 Proposed fix
 type AssetOutput struct {
 	Type   AssetType
-	Vout   uint32 // For Local
+	Vout   uint16 // For Local
 	Script []byte // For Teleport
 	Amount uint64
 }
@@
 type AssetInput struct {
 	Type    AssetType
-	Vin     uint32          // For Local
+	Vin     uint16          // For Local
 	Witness TeleportWitness // For Teleport
 	Amount  uint64
 }
internal/core/application/utils.go (2)

196-199: Add a length check before slicing PkScript.
Line 197 slices out.PkScript[2:] without validating length or script type; short or non‑P2TR outputs can panic.

🛡️ Proposed fix
-		} else {
-			pubKey = hex.EncodeToString(out.PkScript[2:])
-			isSubDust = script.IsSubDustScript(out.PkScript)
-		}
+		} else {
+			if len(out.PkScript) < 34 {
+				continue // skip non‑P2TR/short scripts
+			}
+			pubKey = hex.EncodeToString(out.PkScript[2:])
+			isSubDust = script.IsSubDustScript(out.PkScript)
+		}

220-231: Asset→output matching is wrong after filtering anchor outputs.
Line 222 indexes into outs using asst.VOut, but outs excludes anchor outputs, so valid assets can be skipped or mis‑attached. Match by outpoint instead of slice index.

🐛 Suggested fix
-	// Add AssetGroup if Present
-	for _, asst := range assetList {
-		idx := int(asst.VOut)
-		if idx < 0 || idx >= len(outs) {
-			continue
-		}
-
-		outs[idx].Assets = append(outs[idx].Assets, domain.Asset{
-			AssetID: asst.AssetID,
-			Amount:  asst.Amount,
-		})
-	}
+	// Add AssetGroup if Present - match by outpoint
+	outByVout := make(map[uint32]*domain.Vtxo, len(outs))
+	for i := range outs {
+		outByVout[outs[i].Outpoint.VOut] = &outs[i]
+	}
+	for _, asst := range assetList {
+		if v, ok := outByVout[asst.VOut]; ok {
+			v.Assets = append(v.Assets, domain.Asset{
+				AssetID: asst.AssetID,
+				Amount:  asst.Amount,
+			})
+		}
+	}
internal/core/application/asset_validation.go (4)

353-363: Guard against nil intent before decoding proof.
Line 359 dereferences intent.Proof without checking if GetIntentByTxid returned nil, which can panic.

🐛 Proposed fix
 		intent, err := s.repoManager.Rounds().GetIntentByTxid(context.Background(), txHash.String())
 		if err != nil {
 			return errors.TELEPORT_VALIDATION_FAILED.New("error retrieving intent for teleport input validation: %w", err).
 				WithMetadata(errors.TeleportValidationMetadata{})
 		}
+		if intent == nil {
+			return errors.TELEPORT_VALIDATION_FAILED.New(
+				"intent %s not found for teleport input validation", txHash.String(),
+			).WithMetadata(errors.TeleportValidationMetadata{})
+		}
 
 		decodedProof, err := psbt.NewFromRawBytes(strings.NewReader(intent.Proof), true)

440-463: Enforce non‑zero asset amounts per spec.
Line 440+ aggregates amounts without checking for zero values; zero‑amount inputs/outputs should be rejected early.

🛡️ Suggested checks
 			for _, in := range m.assetGp.Inputs {
 				if in.Type == extension.AssetTypeTeleport {
+					if in.Amount == 0 {
+						return errors.ASSET_INPUT_INVALID.New("asset input amount must be > 0").
+							WithMetadata(errors.AssetInputMetadata{})
+					}
 					teleportScriptHex := hex.EncodeToString(in.Witness.Script[:])
 					m.teleportInputs[teleportScriptHex] += in.Amount
 				}
 			}
@@
 			for _, assetOut := range m.assetGp.Outputs {
+				if assetOut.Amount == 0 {
+					return errors.ASSET_OUTPUT_INVALID.New("asset output amount must be > 0").
+						WithMetadata(errors.AssetOutputMetadata{})
+				}
 				switch assetOut.Type {

569-575: Avoid nil deref when comparing assetGroup.AssetId.
Line 574 dereferences assetGroup.AssetId without checking for nil; issuance groups can be nil and will panic.

🐛 Proposed fix
-			if assetOutput.Type == extension.AssetTypeTeleport &&
+			if assetGroup.AssetId != nil &&
+				assetOutput.Type == extension.AssetTypeTeleport &&
 				bytes.Equal(assetOutput.Script, script) &&
 				assetId == *assetGroup.AssetId &&
 				index == uint32(i) {

642-652: Filter prev‑out checks by asset group and local output type.
Line 648 matches by vout/amount across all groups and types, so a local input can validate against a teleport output or a different asset group. Pass the expected asset ID and only consider local outputs for that group.

💡 Sketch of fix
-func (s *service) verifyAssetInputPrevOut(
-	ctx context.Context,
-	input extension.AssetInput,
-	prev wire.OutPoint,
-) error {
+func (s *service) verifyAssetInputPrevOut(
+	ctx context.Context,
+	input extension.AssetInput,
+	prev wire.OutPoint,
+	assetID *extension.AssetId,
+) error {
@@
-	for _, asset := range assetGroup.Assets {
-		totalAssetOuts = append(totalAssetOuts, asset.Outputs...)
-	}
-
-	for _, assetOut := range totalAssetOuts {
-		if assetOut.Vout == prev.Index && input.Amount == assetOut.Amount {
-			return nil
-		}
-	}
+	for _, asset := range assetGroup.Assets {
+		if assetID == nil || asset.AssetId == nil || *asset.AssetId != *assetID {
+			continue
+		}
+		for _, assetOut := range asset.Outputs {
+			if assetOut.Type != extension.AssetTypeLocal {
+				continue
+			}
+			if assetOut.Vout == prev.Index && input.Amount == assetOut.Amount {
+				return nil
+			}
+		}
+	}
internal/core/application/service.go (9)

355-357: Restore or remove restoreWatchingVtxos explicitly.
The commented block suggests missing functionality after restart. Either re‑enable it or remove with an explanation.

🛠️ Proposed fix (restore)
-	// if err := svc.restoreWatchingVtxos(); err != nil {
-	// 	return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
-	// }
+	if err := svc.restoreWatchingVtxos(); err != nil {
+		return nil, fmt.Errorf("failed to restore watching vtxos: %s", err)
+	}

1131-1138: Asset storage failure is silently ignored.
Line 1131 logs errors but keeps the offchain tx accepted, risking permanent asset/tx divergence. Decide if storage is blocking or best‑effort and make that explicit.

🛠️ Option A: fail fast
 	if assetOutputIndex >= 0 {
 		if err := s.storeAssetDetailsFromArkTx(
 			ctx, *arkPtx.UnsignedTx, assetOutputIndex,
 		); err != nil {
-			log.WithError(err).Errorf(
-				"failed to store asset details for offchain tx %s", txid,
-			)
+			return nil, errors.INTERNAL_ERROR.New(
+				"failed to store asset details for offchain tx %s: %w", txid, err,
+			)
 		}
 	}

1578-1587: Teleport asset insert failures should abort registration.
Line 1585 only logs and still appends a receiver, creating inconsistent state.

🐛 Proposed fix
 					if err := s.repoManager.Assets().InsertTeleportAsset(ctx, domain.TeleportAsset{
 						IntentID:    proofTxid,
 						Script:      teleportScript,
 						OutputIndex: uint32(i),
 						AssetID:     asst.AssetId.ToString(),
 						Amount:      output.Amount,
 						IsClaimed:   false,
 					}); err != nil {
-						log.WithError(err).Warn("failed to insert teleport asset")
+						return "", errors.INTERNAL_ERROR.New(
+							"failed to insert teleport asset: %w", err,
+						).WithMetadata(map[string]any{
+							"teleport_script": teleportScript,
+							"asset_id":        asst.AssetId.ToString(),
+						})
 					}

1689-1691: offchainOutputs should exclude on‑chain outputs.
Line 1689 appends every output to offchainOutputs, which skews fee computation.

🐛 Proposed fix
 		} else {
 			...
 			hasOffChainReceiver = true
 			rcv.PubKey = hex.EncodeToString(output.PkScript[2:])
+			offchainOutputs = append(offchainOutputs, *output)
 		}
 
 		receivers = append(receivers, rcv)
-		offchainOutputs = append(offchainOutputs, *output)

1743-1747: Dead error check: err was never set here.
Line 1743 checks err after calling GetArkPsbtFields with _, so the condition is unreachable. Capture the error or remove the block.

🛠️ Suggested fix
-		taptreeFields, _ := txutils.GetArkPsbtFields(
+		taptreeFields, err := txutils.GetArkPsbtFields(
 			&proof.Packet, i+1, txutils.VtxoTaprootTreeField,
 		)
 
-		if err != nil {
+		if err != nil {
 			return "", errors.INVALID_PSBT_INPUT.New(
 				"failed to get asset seal field for input %d: %w", i+1, err,
 			).WithMetadata(errors.InputMetadata{Txid: proofTxid, InputIndex: i + 1})
 		}

4402-4405: Error message doesn’t match the actual validation.
Line 4402 wraps ensureAssetPresence, but the message claims “control asset is being issued.” Update the message to reflect “control asset missing in packet.”

✏️ Suggested fix
 			if err := s.ensureAssetPresence(ctx, assetGroupList, *controlAssetID); err != nil {
-				return fmt.Errorf(
-					"cannot update asset metadata while control asset is being issued",
-				)
+				return fmt.Errorf("control asset not present in asset group list: %w", err)
 			}

4421-4430: Teleport outputs should be excluded from normal asset anchoring.
Line 4421 appends all outputs for existing assets, including teleport outputs, which should not be stored as normal assets.

🐛 Proposed fix
 		for _, out := range asstGp.Outputs {
+			if out.Type == extension.AssetTypeTeleport {
+				continue
+			}
 			asst := domain.NormalAsset{
 				Outpoint: domain.Outpoint{
 					Txid: arkTx.TxID(),
 					VOut: uint32(out.Vout),
 				},

4476-4493: DecreaseAssetGroupQuantity can silently no‑op in SQL backends.
Line 4491 relies on repo behavior that may not error when balance is insufficient (0 rows updated), leading to silent inconsistency. Add explicit balance validation or detect zero‑row updates.


4521-4526: OutputVout should reflect the asset output’s vout, not loop index.
Line 4525 uses outIdx, which can diverge from the encoded vout. Use assetOut.Vout for consistency.

🐛 Proposed fix
 				event := TeleportAsset{
 					TeleportHash:   hex.EncodeToString(assetOut.Script),
 					AnchorOutpoint: anchorOutpoint,
 					AssetID:        ast.AssetId.ToString(),
-					OutputVout:     uint32(outIdx),
+					OutputVout:     assetOut.Vout,
 					CreatedAt:      createdAt,
 					ExpiresAt:      expiresAt,
 				}

Comment on lines +4452 to +4461
for _, in := range grpAsset.Inputs {
if in.Type != extension.AssetTypeTeleport {
continue
}

intentId := hex.EncodeToString(in.Witness.Txid[:])
teleportScript := hex.EncodeToString(in.Witness.Script)
if err := s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId, assetIdStr, in.Vin, true); err != nil {
log.WithError(err).Warn("failed to update teleport asset")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Teleport claim uses the wrong index field.
Line 4459 passes in.Vin to UpdateTeleportAsset, but teleport inputs encode their index in in.Witness.Index. This can mis‑mark claims.

🐛 Proposed fix
-		if err := s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId, assetIdStr, in.Vin, true); err != nil {
+		if err := s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId, assetIdStr, in.Witness.Index, true); err != nil {
 			log.WithError(err).Warn("failed to update teleport asset")
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, in := range grpAsset.Inputs {
if in.Type != extension.AssetTypeTeleport {
continue
}
intentId := hex.EncodeToString(in.Witness.Txid[:])
teleportScript := hex.EncodeToString(in.Witness.Script)
if err := s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId, assetIdStr, in.Vin, true); err != nil {
log.WithError(err).Warn("failed to update teleport asset")
}
for _, in := range grpAsset.Inputs {
if in.Type != extension.AssetTypeTeleport {
continue
}
intentId := hex.EncodeToString(in.Witness.Txid[:])
teleportScript := hex.EncodeToString(in.Witness.Script)
if err := s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript, intentId, assetIdStr, in.Witness.Index, true); err != nil {
log.WithError(err).Warn("failed to update teleport asset")
}
🤖 Prompt for AI Agents
In `@internal/core/application/service.go` around lines 4452 - 4461, The loop over
grpAsset.Inputs is passing the wrong index to UpdateTeleportAsset: it currently
uses in.Vin but teleport claims encode their index in in.Witness.Index; update
the call in the block that checks in.Type == extension.AssetTypeTeleport to pass
in.Witness.Index instead of in.Vin (keep intentId and teleportScript as-is and
still call s.repoManager.Assets().UpdateTeleportAsset(ctx, teleportScript,
intentId, assetIdStr, in.Witness.Index, true) and preserve the existing error
handling).

github.com/btcsuite/btcd/btcutil/psbt v1.1.9
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd
github.com/lightningnetwork/lnd/tlv v1.2.6
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

latest release version github.com/lightningnetwork/lnd/tlv

💡 Result:

The latest released version of github.com/lightningnetwork/lnd/tlv is v1.3.2 (tag: tlv/v1.3.2), published June 10, 2025. [1][2]

Sources:

  • pkg.go.dev listing for the tlv package (shows v1.3.2). [1]
  • Release listing/tag index (tlv/v1.3.2 — Jun 10, 2025). [2]

🌐 Web query:

security advisories github.com/lightningnetwork/lnd/tlv CVE

💡 Result:

Here are known security advisories / CVEs affecting github.com/lightningnetwork/lnd (including parsing/ TLV/onion-related issues):

  • CVE-2024-38359 — "lnd onion processing" DoS (patched in v0.17.0). [1]
  • CVE-2022-39389 — witness block parsing DoS (patched in v0.15.4). [2]
  • CVE-2022-44797 — btcd witness-size handling affecting lnd (patched in v0.15.2). [3]
  • CVE-2020-26895 — improper validation accepting high-S signatures (patched in v0.10.0). [4]
  • CVE-2019-12999 — improper access control (patched in v0.7.1). [5]

If you want, I can (a) fetch the lnd repository security/advisories page with direct GH advisory links, (b) show affected versions and mitigation steps for any specific CVE, or (c) search for additional CVEs referencing the lnd tlv package. Which would you prefer?


Update github.com/lightningnetwork/lnd/tlv to v1.3.2.
The pinned version v1.2.6 is outdated; v1.3.2 is the latest release. Consider updating to the current version to get latest fixes and features.

🤖 Prompt for AI Agents
In `@pkg/ark-lib/go.mod` at line 14, The go.mod pins
github.com/lightningnetwork/lnd/tlv at v1.2.6; update that module version to
v1.3.2 in go.mod (replace v1.2.6 with v1.3.2 for
github.com/lightningnetwork/lnd/tlv), then run go mod tidy and rebuild/tests (go
build / go test) to ensure dependency resolution and that no breakage occurred
from the bump.

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/asset_validation.go`:
- Around line 484-505: When handling the case where outAmount > inAmount, guard
against a nil AssetId before calling m.assetGp.AssetId.ToString(): check if
m.assetGp == nil or m.assetGp.AssetId == nil and return an appropriate asset
validation error (e.g., errors.ASSET_VALIDATION_FAILED.New or
errors.ASSET_NOT_FOUND.New) with metadata instead of dereferencing; update the
block around m.assetGp.AssetId.ToString() to first validate the pointer and only
call ToString() when non-nil.
♻️ Duplicate comments (3)
internal/core/application/asset_validation.go (3)

353-363: Guard against nil intent before accessing intent.Proof.

GetIntentByTxid may return (nil, nil) when the intent is not found. Accessing intent.Proof on line 359 will panic.

🐛 Proposed fix
 		intent, err := s.repoManager.Rounds().GetIntentByTxid(context.Background(), txHash.String())
 		if err != nil {
 			return errors.TELEPORT_VALIDATION_FAILED.New("error retrieving intent for teleport input validation: %w", err).
 				WithMetadata(errors.TeleportValidationMetadata{})
 		}
+		if intent == nil {
+			return errors.TELEPORT_VALIDATION_FAILED.New(
+				"intent %s not found for teleport input validation", txHash.String(),
+			).WithMetadata(errors.TeleportValidationMetadata{})
+		}

 		decodedProof, err := psbt.NewFromRawBytes(strings.NewReader(intent.Proof), true)

570-580: Guard against nil AssetId in teleport output matching.

Line 574 dereferences assetGroup.AssetId without checking for nil. For issuance groups in the intent proof, AssetId is nil and this will panic.

🐛 Proposed fix
 	for _, assetGroup := range assetPacket.Assets {
+		if assetGroup.AssetId == nil {
+			continue
+		}
 		for i, assetOutput := range assetGroup.Outputs {
 			if assetOutput.Type == extension.AssetTypeTeleport &&
 				bytes.Equal(assetOutput.Script, script) &&
 				assetId == *assetGroup.AssetId &&
 				index == uint32(i) {

642-655: Asset input validation can match outputs from different asset groups or teleport outputs.

verifyAssetInputPrevOut aggregates outputs from all asset groups and matches only by Vout and Amount, ignoring the asset ID and output type. This allows a local input to incorrectly validate against a teleport output or an output from a different asset group.

🐛 Proposed fix: Filter by asset ID and local output type

The function needs the current asset ID to filter matches. Consider passing the asset ID as a parameter:

 func (s *service) verifyAssetInputPrevOut(
 	ctx context.Context,
 	input extension.AssetInput,
 	prev wire.OutPoint,
+	currentAssetId *extension.AssetId,
 ) error {
 	// ... existing code to retrieve and decode offchain tx ...

-	totalAssetOuts := make([]extension.AssetOutput, 0)
-	for _, asset := range assetGroup.Assets {
-		totalAssetOuts = append(totalAssetOuts, asset.Outputs...)
-	}
-
-	for _, assetOut := range totalAssetOuts {
-		if assetOut.Vout == prev.Index && input.Amount == assetOut.Amount {
-			return nil
+	for _, asset := range assetGroup.Assets {
+		// Skip groups with different asset IDs
+		if currentAssetId != nil && asset.AssetId != nil && *currentAssetId != *asset.AssetId {
+			continue
+		}
+		for _, assetOut := range asset.Outputs {
+			// Only match local outputs
+			if assetOut.Type == extension.AssetTypeLocal &&
+				assetOut.Vout == prev.Index &&
+				input.Amount == assetOut.Amount {
+				return nil
+			}
 		}
 	}
🧹 Nitpick comments (1)
internal/core/application/asset_validation.go (1)

447-464: Simplify vout lookup by direct bounds check.

The inner loop searches for a matching index when a direct bounds check would suffice.

♻️ Suggested simplification
 			case extension.AssetTypeLocal:
-				for index, txout := range m.arkTx.TxOut {
-					if index == int(assetOut.Vout) {
-						m.localScriptAmts[hex.EncodeToString(txout.PkScript)] += assetOut.Amount
-						m.processed++
-						break
-					}
+				if int(assetOut.Vout) >= len(m.arkTx.TxOut) {
+					return errors.ASSET_OUTPUT_INVALID.New("asset output vout %d out of range", assetOut.Vout).
+						WithMetadata(errors.AssetOutputMetadata{OutputIndex: int(assetOut.Vout)})
 				}
+				txout := m.arkTx.TxOut[assetOut.Vout]
+				m.localScriptAmts[hex.EncodeToString(txout.PkScript)] += assetOut.Amount
+				m.processed++

Comment on lines +484 to +505
if outAmount > inAmount {
// verify if extra amount is covered by existing input
if m.sumInputs == m.sumOutputs {
continue
}

assetID := m.assetGp.AssetId.ToString()
controlAssetDetails, err := s.repoManager.Assets().GetAssetGroupByID(
m.ctx, assetID,
)
if err != nil {
return errors.ASSET_VALIDATION_FAILED.New(
"error retrieving asset %s: %w",
assetID,
err,
).WithMetadata(errors.AssetValidationMetadata{AssetID: assetID})
}

if controlAssetDetails == nil {
return errors.ASSET_NOT_FOUND.New("asset %s does not exist", assetID).
WithMetadata(errors.AssetValidationMetadata{AssetID: assetID})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against nil AssetId before dereferencing.

When outAmount > inAmount and sums don't match, line 490 calls m.assetGp.AssetId.ToString() without verifying AssetId is non-nil. For issuance groups, AssetId can be nil, causing a panic.

🐛 Proposed fix
 				if m.sumInputs == m.sumOutputs {
 					continue
 				}

+				if m.assetGp.AssetId == nil {
+					return errors.ASSET_VALIDATION_FAILED.New(
+						"cannot validate minting for issuance group without asset ID",
+					).WithMetadata(errors.AssetValidationMetadata{})
+				}
 				assetID := m.assetGp.AssetId.ToString()
🤖 Prompt for AI Agents
In `@internal/core/application/asset_validation.go` around lines 484 - 505, When
handling the case where outAmount > inAmount, guard against a nil AssetId before
calling m.assetGp.AssetId.ToString(): check if m.assetGp == nil or
m.assetGp.AssetId == nil and return an appropriate asset validation error (e.g.,
errors.ASSET_VALIDATION_FAILED.New or errors.ASSET_NOT_FOUND.New) with metadata
instead of dereferencing; update the block around m.assetGp.AssetId.ToString()
to first validate the pointer and only call ToString() when non-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.

5 participants