@zemyth/raise-sdk 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -9
  2. package/dist/accounts/index.cjs +531 -3
  3. package/dist/accounts/index.cjs.map +1 -1
  4. package/dist/accounts/index.d.cts +307 -2
  5. package/dist/accounts/index.d.ts +307 -2
  6. package/dist/accounts/index.js +503 -4
  7. package/dist/accounts/index.js.map +1 -1
  8. package/dist/constants/index.cjs +41 -3
  9. package/dist/constants/index.cjs.map +1 -1
  10. package/dist/constants/index.d.cts +38 -3
  11. package/dist/constants/index.d.ts +38 -3
  12. package/dist/constants/index.js +40 -4
  13. package/dist/constants/index.js.map +1 -1
  14. package/dist/index.cjs +2297 -361
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +566 -7
  17. package/dist/index.d.ts +566 -7
  18. package/dist/index.js +2279 -379
  19. package/dist/index.js.map +1 -1
  20. package/dist/instructions/index.cjs +783 -40
  21. package/dist/instructions/index.cjs.map +1 -1
  22. package/dist/instructions/index.d.cts +492 -6
  23. package/dist/instructions/index.d.ts +492 -6
  24. package/dist/instructions/index.js +762 -42
  25. package/dist/instructions/index.js.map +1 -1
  26. package/dist/pdas/index.cjs +163 -1
  27. package/dist/pdas/index.cjs.map +1 -1
  28. package/dist/pdas/index.d.cts +131 -1
  29. package/dist/pdas/index.d.ts +131 -1
  30. package/dist/pdas/index.js +151 -2
  31. package/dist/pdas/index.js.map +1 -1
  32. package/dist/types/index.cjs +9 -0
  33. package/dist/types/index.cjs.map +1 -1
  34. package/dist/types/index.d.cts +586 -3
  35. package/dist/types/index.d.ts +586 -3
  36. package/dist/types/index.js +9 -1
  37. package/dist/types/index.js.map +1 -1
  38. package/package.json +5 -3
  39. package/src/__tests__/dynamic-tokenomics.test.ts +358 -0
  40. package/src/accounts/index.ts +852 -1
  41. package/src/client.ts +1130 -1
  42. package/src/constants/index.ts +48 -2
  43. package/src/index.ts +58 -0
  44. package/src/instructions/index.ts +1383 -40
  45. package/src/pdas/index.ts +346 -0
  46. package/src/types/index.ts +698 -2
  47. package/src/utils/index.ts +90 -0
@@ -14,8 +14,20 @@ import {
14
14
  getPivotProposalPDA,
15
15
  getTgeEscrowPDA,
16
16
  getAdminConfigPDA,
17
+ getTokenVaultPDA,
18
+ getTokenomicsPDA,
19
+ getAllocationProposalPDA,
20
+ getAllocationVotePDA,
21
+ getSubAllocationVestingPDA,
22
+ getInvestorMilestoneVestingPDA,
23
+ getFounderMilestoneVestingPDA,
24
+ getFundingRoundPDA,
25
+ getRoundMilestonePDA,
26
+ getRoundInvestmentPDA,
27
+ getRoundInvestorMilestoneVestingPDA,
28
+ getFutureRoundVaultPDA,
17
29
  } from '../pdas/index.js';
18
- import type { InvestmentWithKey, MilestoneWithKey, VoteWithKey } from '../types/index.js';
30
+ import type { InvestmentWithKey, MilestoneWithKey, VoteWithKey, AllocationProposalWithKey, FundingRoundWithKey } from '../types/index.js';
19
31
 
20
32
  // Generic type for any Anchor program
21
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -306,6 +318,29 @@ export async function fetchAdminConfig(program: AnyProgram) {
306
318
  return await getAccountNamespace(program).adminConfig.fetch(adminConfigPda);
307
319
  }
308
320
 
321
+ /**
322
+ * Fetch token vault account data
323
+ *
324
+ * @param program - Anchor program instance
325
+ * @param projectId - Project identifier
326
+ * @returns TokenVault account data or null if not found
327
+ */
328
+ export async function fetchTokenVault(
329
+ program: AnyProgram,
330
+ projectId: BN
331
+ ) {
332
+ try {
333
+ const projectPda = getProjectPDA(projectId, program.programId);
334
+ const tokenVaultPda = getTokenVaultPDA(projectPda, program.programId);
335
+ return await getAccountNamespace(program).tokenVault.fetch(tokenVaultPda);
336
+ } catch (error) {
337
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
338
+ return null;
339
+ }
340
+ throw error;
341
+ }
342
+ }
343
+
309
344
  /**
310
345
  * Check if an account exists
311
346
  *
@@ -327,3 +362,819 @@ export async function accountExists(
327
362
  return false;
328
363
  }
329
364
  }
365
+
366
+ // =============================================================================
367
+ // Dynamic Tokenomics Account Fetchers
368
+ // =============================================================================
369
+
370
+ /**
371
+ * Fetch tokenomics account data
372
+ *
373
+ * @param program - Anchor program instance
374
+ * @param projectId - Project identifier
375
+ * @returns Tokenomics account data or null if not found
376
+ */
377
+ export async function fetchTokenomics(
378
+ program: AnyProgram,
379
+ projectId: BN
380
+ ) {
381
+ try {
382
+ const projectPda = getProjectPDA(projectId, program.programId);
383
+ const tokenomicsPda = getTokenomicsPDA(projectPda, program.programId);
384
+ return await getAccountNamespace(program).tokenomics.fetch(tokenomicsPda);
385
+ } catch (error) {
386
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
387
+ return null;
388
+ }
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Fetch allocation proposal account data
395
+ *
396
+ * @param program - Anchor program instance
397
+ * @param projectId - Project identifier
398
+ * @param proposalIndex - Proposal index
399
+ * @returns AllocationProposal account data or null if not found
400
+ */
401
+ export async function fetchAllocationProposal(
402
+ program: AnyProgram,
403
+ projectId: BN,
404
+ proposalIndex: number
405
+ ) {
406
+ try {
407
+ const projectPda = getProjectPDA(projectId, program.programId);
408
+ const proposalPda = getAllocationProposalPDA(projectPda, proposalIndex, program.programId);
409
+ return await getAccountNamespace(program).allocationProposal.fetch(proposalPda);
410
+ } catch (error) {
411
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
412
+ return null;
413
+ }
414
+ throw error;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Fetch all allocation proposals for a project
420
+ *
421
+ * @param program - Anchor program instance
422
+ * @param projectId - Project identifier
423
+ * @returns Array of allocation proposal accounts with their public keys
424
+ */
425
+ export async function fetchAllAllocationProposals(
426
+ program: AnyProgram,
427
+ projectId: BN
428
+ ): Promise<AllocationProposalWithKey[]> {
429
+ const projectPda = getProjectPDA(projectId, program.programId);
430
+
431
+ const proposals = await getAccountNamespace(program).allocationProposal.all([
432
+ {
433
+ memcmp: {
434
+ offset: 8, // Skip discriminator
435
+ bytes: projectPda.toBase58(),
436
+ },
437
+ },
438
+ ]);
439
+
440
+ return proposals.map((p: { publicKey: PublicKey; account: unknown }) => ({
441
+ publicKey: p.publicKey,
442
+ account: p.account,
443
+ })) as AllocationProposalWithKey[];
444
+ }
445
+
446
+ /**
447
+ * Fetch allocation vote account data
448
+ *
449
+ * @param program - Anchor program instance
450
+ * @param projectId - Project identifier
451
+ * @param proposalIndex - Proposal index
452
+ * @param nftMint - NFT mint used for voting
453
+ * @returns AllocationVote account data or null if not found
454
+ */
455
+ export async function fetchAllocationVote(
456
+ program: AnyProgram,
457
+ projectId: BN,
458
+ proposalIndex: number,
459
+ nftMint: PublicKey
460
+ ) {
461
+ try {
462
+ const projectPda = getProjectPDA(projectId, program.programId);
463
+ const proposalPda = getAllocationProposalPDA(projectPda, proposalIndex, program.programId);
464
+ const votePda = getAllocationVotePDA(proposalPda, nftMint, program.programId);
465
+ return await getAccountNamespace(program).allocationVote.fetch(votePda);
466
+ } catch (error) {
467
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
468
+ return null;
469
+ }
470
+ throw error;
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Fetch sub-allocation vesting account data
476
+ *
477
+ * @param program - Anchor program instance
478
+ * @param projectId - Project identifier
479
+ * @param subAllocationId - Sub-allocation ID (0-9)
480
+ * @returns SubAllocationVesting account data or null if not found
481
+ */
482
+ export async function fetchSubAllocationVesting(
483
+ program: AnyProgram,
484
+ projectId: BN,
485
+ subAllocationId: number
486
+ ) {
487
+ try {
488
+ const projectPda = getProjectPDA(projectId, program.programId);
489
+ const vestingPda = getSubAllocationVestingPDA(projectPda, subAllocationId, program.programId);
490
+ return await getAccountNamespace(program).subAllocationVesting.fetch(vestingPda);
491
+ } catch (error) {
492
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
493
+ return null;
494
+ }
495
+ throw error;
496
+ }
497
+ }
498
+
499
+ // =============================================================================
500
+ // Per-Milestone Vesting Account Fetchers
501
+ // =============================================================================
502
+
503
+ /**
504
+ * Fetch investor milestone vesting account data
505
+ *
506
+ * Per-milestone vesting: Each investor has a separate vesting PDA for each
507
+ * milestone they claim tokens from.
508
+ *
509
+ * @param program - Anchor program instance
510
+ * @param projectId - Project identifier
511
+ * @param milestoneIndex - Milestone index
512
+ * @param nftMint - NFT mint that proves investment ownership
513
+ * @returns InvestorMilestoneVesting account data or null if not found
514
+ */
515
+ export async function fetchInvestorMilestoneVesting(
516
+ program: AnyProgram,
517
+ projectId: BN,
518
+ milestoneIndex: number,
519
+ nftMint: PublicKey
520
+ ) {
521
+ try {
522
+ const projectPda = getProjectPDA(projectId, program.programId);
523
+ const investmentPda = getInvestmentPDA(projectPda, nftMint, program.programId);
524
+ const vestingPda = getInvestorMilestoneVestingPDA(projectPda, milestoneIndex, investmentPda, program.programId);
525
+ return await getAccountNamespace(program).investorMilestoneVesting.fetch(vestingPda);
526
+ } catch (error) {
527
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
528
+ return null;
529
+ }
530
+ throw error;
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Fetch founder milestone vesting account data
536
+ *
537
+ * Per-milestone vesting: Each milestone has a separate vesting PDA for the
538
+ * founder's milestone-based allocation.
539
+ *
540
+ * @param program - Anchor program instance
541
+ * @param projectId - Project identifier
542
+ * @param milestoneIndex - Milestone index
543
+ * @returns FounderMilestoneVesting account data or null if not found
544
+ */
545
+ export async function fetchFounderMilestoneVesting(
546
+ program: AnyProgram,
547
+ projectId: BN,
548
+ milestoneIndex: number
549
+ ) {
550
+ try {
551
+ const projectPda = getProjectPDA(projectId, program.programId);
552
+ const vestingPda = getFounderMilestoneVestingPDA(projectPda, milestoneIndex, program.programId);
553
+ return await getAccountNamespace(program).founderMilestoneVesting.fetch(vestingPda);
554
+ } catch (error) {
555
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
556
+ return null;
557
+ }
558
+ throw error;
559
+ }
560
+ }
561
+
562
+ // =============================================================================
563
+ // Early Token Release Helpers
564
+ // =============================================================================
565
+
566
+ /** Early token cooling period in seconds (24 hours in production, 10s in dev) */
567
+ export const EARLY_TOKEN_COOLING_PERIOD_SECONDS = 86_400; // 24 hours
568
+ export const EARLY_TOKEN_COOLING_PERIOD_SECONDS_DEV = 10; // 10 seconds for dev
569
+
570
+ /** Early token release percentage in basis points (500 = 5%) */
571
+ export const EARLY_TOKEN_RELEASE_BPS = 500;
572
+
573
+ /**
574
+ * Check if an investor can claim early tokens
575
+ *
576
+ * Early Token Release: Investors can claim 5% of their token allocation
577
+ * 24 hours after investing. This helper checks eligibility.
578
+ *
579
+ * @param investment - Investment account data
580
+ * @param currentTimestamp - Current Unix timestamp (optional, uses Date.now() if not provided)
581
+ * @param isDev - Use dev mode (10s) or production mode (24h) cooling period
582
+ * @returns Object with canClaim boolean, reason if ineligible, and time remaining if cooling period not expired
583
+ */
584
+ export function canClaimEarlyTokens(
585
+ investment: {
586
+ earlyTokensClaimed: boolean;
587
+ investedAt: BN | number;
588
+ votingRightsActive?: boolean;
589
+ withdrawnFromPivot?: boolean;
590
+ },
591
+ currentTimestamp?: number,
592
+ isDev: boolean = false
593
+ ): { canClaim: boolean; reason?: string; timeRemainingSeconds?: number } {
594
+ const now = currentTimestamp ?? Math.floor(Date.now() / 1000);
595
+ const coolingPeriod = isDev ? EARLY_TOKEN_COOLING_PERIOD_SECONDS_DEV : EARLY_TOKEN_COOLING_PERIOD_SECONDS;
596
+
597
+ // Check if already claimed
598
+ if (investment.earlyTokensClaimed) {
599
+ return { canClaim: false, reason: 'Early tokens already claimed' };
600
+ }
601
+
602
+ // Check if voting rights are active
603
+ if (investment.votingRightsActive === false) {
604
+ return { canClaim: false, reason: 'Voting rights have been revoked' };
605
+ }
606
+
607
+ // Check if withdrawn from pivot
608
+ if (investment.withdrawnFromPivot === true) {
609
+ return { canClaim: false, reason: 'Investment has been withdrawn from pivot' };
610
+ }
611
+
612
+ // Check cooling period
613
+ const investedAt = typeof investment.investedAt === 'number'
614
+ ? investment.investedAt
615
+ : investment.investedAt.toNumber();
616
+
617
+ const coolingEndTime = investedAt + coolingPeriod;
618
+
619
+ if (now < coolingEndTime) {
620
+ const timeRemaining = coolingEndTime - now;
621
+ return {
622
+ canClaim: false,
623
+ reason: `Cooling period not expired. ${formatTimeRemaining(timeRemaining)} remaining.`,
624
+ timeRemainingSeconds: timeRemaining,
625
+ };
626
+ }
627
+
628
+ return { canClaim: true };
629
+ }
630
+
631
+ /**
632
+ * Check if a founder can claim early tokens
633
+ *
634
+ * Early Token Release: Founders can claim 5% of their token allocation
635
+ * when the project becomes Funded. This helper checks eligibility.
636
+ *
637
+ * @param project - Project account data
638
+ * @returns Object with canClaim boolean and reason if ineligible
639
+ */
640
+ export function canClaimFounderEarlyTokens(
641
+ project: {
642
+ state: unknown;
643
+ founderEarlyTokensClaimed: boolean;
644
+ }
645
+ ): { canClaim: boolean; reason?: string } {
646
+ // Check if already claimed
647
+ if (project.founderEarlyTokensClaimed) {
648
+ return { canClaim: false, reason: 'Founder early tokens already claimed' };
649
+ }
650
+
651
+ // Check project state - must be Funded, InProgress, or Completed
652
+ // These states are represented as objects with a single key in Anchor
653
+ const stateStr = getProjectStateString(project.state);
654
+ const validStates = ['funded', 'inProgress', 'completed'];
655
+
656
+ if (!validStates.includes(stateStr)) {
657
+ return {
658
+ canClaim: false,
659
+ reason: `Project must be in Funded, InProgress, or Completed state. Current state: ${stateStr}`,
660
+ };
661
+ }
662
+
663
+ return { canClaim: true };
664
+ }
665
+
666
+ /**
667
+ * Check if a founder can claim milestone-based tokens
668
+ *
669
+ * Early Token Release: Founders can claim milestone-based tokens when
670
+ * milestones are unlocked. This helper checks eligibility.
671
+ *
672
+ * @param milestone - Milestone account data
673
+ * @returns Object with canClaim boolean and reason if ineligible
674
+ */
675
+ export function canClaimFounderMilestoneTokens(
676
+ milestone: {
677
+ state: unknown;
678
+ }
679
+ ): { canClaim: boolean; reason?: string } {
680
+ // Check milestone state - must be Unlocked
681
+ const stateStr = getMilestoneStateString(milestone.state);
682
+ if (stateStr !== 'unlocked') {
683
+ return {
684
+ canClaim: false,
685
+ reason: `Milestone must be in Unlocked state. Current state: ${stateStr}`,
686
+ };
687
+ }
688
+
689
+ return { canClaim: true };
690
+ }
691
+
692
+ /**
693
+ * Check if an investor needs to burn tokens for a refund
694
+ *
695
+ * Early Token Release: If the project has no milestones claimed (cumulative_percentage == 0)
696
+ * AND the investor has claimed early tokens, they must burn those tokens to get a full refund.
697
+ *
698
+ * @param project - Project account data
699
+ * @param investment - Investment account data
700
+ * @returns Object indicating whether burn is required
701
+ */
702
+ export function requiresBurnForRefund(
703
+ project: {
704
+ cumulativePercentage: number;
705
+ },
706
+ investment: {
707
+ earlyTokensClaimed: boolean;
708
+ earlyTokensAmount: BN | number;
709
+ }
710
+ ): { requiresBurn: boolean; burnAmount: number } {
711
+ const isFullRefund = project.cumulativePercentage === 0;
712
+ const requiresBurn = isFullRefund && investment.earlyTokensClaimed;
713
+
714
+ if (!requiresBurn) {
715
+ return { requiresBurn: false, burnAmount: 0 };
716
+ }
717
+
718
+ const burnAmount = typeof investment.earlyTokensAmount === 'number'
719
+ ? investment.earlyTokensAmount
720
+ : investment.earlyTokensAmount.toNumber();
721
+
722
+ return { requiresBurn: true, burnAmount };
723
+ }
724
+
725
+ /**
726
+ * Calculate early token amount for an investment
727
+ *
728
+ * @param tokensAllocated - Total tokens allocated to the investment
729
+ * @returns Early token amount (5% of allocation)
730
+ */
731
+ export function calculateEarlyTokenAmount(tokensAllocated: BN | number): BN {
732
+ const allocated = typeof tokensAllocated === 'number'
733
+ ? new BN(tokensAllocated)
734
+ : tokensAllocated;
735
+
736
+ return allocated.muln(EARLY_TOKEN_RELEASE_BPS).divn(10000);
737
+ }
738
+
739
+ /**
740
+ * Calculate remaining token allocation after early tokens are claimed
741
+ *
742
+ * @param tokensAllocated - Total tokens allocated
743
+ * @returns Remaining allocation (95% of total)
744
+ */
745
+ export function calculateRemainingAllocation(tokensAllocated: BN | number): BN {
746
+ const allocated = typeof tokensAllocated === 'number'
747
+ ? new BN(tokensAllocated)
748
+ : tokensAllocated;
749
+
750
+ const remainingBps = 10000 - EARLY_TOKEN_RELEASE_BPS; // 9500 = 95%
751
+ return allocated.muln(remainingBps).divn(10000);
752
+ }
753
+
754
+ // =============================================================================
755
+ // Helper Functions
756
+ // =============================================================================
757
+
758
+ /**
759
+ * Format time remaining in human-readable format
760
+ */
761
+ function formatTimeRemaining(seconds: number): string {
762
+ if (seconds <= 0) return '0 seconds';
763
+
764
+ const hours = Math.floor(seconds / 3600);
765
+ const minutes = Math.floor((seconds % 3600) / 60);
766
+ const secs = seconds % 60;
767
+
768
+ const parts: string[] = [];
769
+ if (hours > 0) parts.push(`${hours}h`);
770
+ if (minutes > 0) parts.push(`${minutes}m`);
771
+ if (secs > 0 && hours === 0) parts.push(`${secs}s`);
772
+
773
+ return parts.join(' ') || '0 seconds';
774
+ }
775
+
776
+ /**
777
+ * Convert Anchor project state object to string
778
+ */
779
+ function getProjectStateString(state: unknown): string {
780
+ if (typeof state === 'object' && state !== null) {
781
+ const keys = Object.keys(state);
782
+ return keys[0]?.toLowerCase() ?? 'unknown';
783
+ }
784
+ return 'unknown';
785
+ }
786
+
787
+ /**
788
+ * Convert Anchor milestone state object to string
789
+ */
790
+ function getMilestoneStateString(state: unknown): string {
791
+ if (typeof state === 'object' && state !== null) {
792
+ const keys = Object.keys(state);
793
+ return keys[0]?.toLowerCase() ?? 'unknown';
794
+ }
795
+ return 'unknown';
796
+ }
797
+
798
+ // =============================================================================
799
+ // Multi-Round Fundraising Account Fetchers
800
+ // =============================================================================
801
+
802
+ /**
803
+ * Fetch FundingRound account data
804
+ *
805
+ * @param program - Anchor program instance
806
+ * @param projectId - Project identifier
807
+ * @param roundNumber - Round number (2, 3, 4...)
808
+ * @returns FundingRound account data or null if not found
809
+ */
810
+ export async function fetchFundingRound(
811
+ program: AnyProgram,
812
+ projectId: BN,
813
+ roundNumber: number
814
+ ) {
815
+ try {
816
+ const projectPda = getProjectPDA(projectId, program.programId);
817
+ const fundingRoundPda = getFundingRoundPDA(projectPda, roundNumber, program.programId);
818
+ return await getAccountNamespace(program).fundingRound.fetch(fundingRoundPda);
819
+ } catch (error) {
820
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
821
+ return null;
822
+ }
823
+ throw error;
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Fetch all FundingRounds for a project
829
+ *
830
+ * @param program - Anchor program instance
831
+ * @param projectId - Project identifier
832
+ * @returns Array of FundingRound accounts with their public keys
833
+ */
834
+ export async function fetchAllFundingRounds(
835
+ program: AnyProgram,
836
+ projectId: BN
837
+ ): Promise<FundingRoundWithKey[]> {
838
+ const projectPda = getProjectPDA(projectId, program.programId);
839
+
840
+ const rounds = await getAccountNamespace(program).fundingRound.all([
841
+ {
842
+ memcmp: {
843
+ offset: 8, // Skip discriminator
844
+ bytes: projectPda.toBase58(),
845
+ },
846
+ },
847
+ ]);
848
+
849
+ return rounds.map((r: { publicKey: PublicKey; account: unknown }) => ({
850
+ publicKey: r.publicKey,
851
+ account: r.account,
852
+ })) as FundingRoundWithKey[];
853
+ }
854
+
855
+ /**
856
+ * Fetch round milestone account data
857
+ *
858
+ * @param program - Anchor program instance
859
+ * @param projectId - Project identifier
860
+ * @param roundNumber - Round number
861
+ * @param milestoneIndex - Milestone index
862
+ * @returns Milestone account data or null if not found
863
+ */
864
+ export async function fetchRoundMilestone(
865
+ program: AnyProgram,
866
+ projectId: BN,
867
+ roundNumber: number,
868
+ milestoneIndex: number
869
+ ) {
870
+ try {
871
+ const projectPda = getProjectPDA(projectId, program.programId);
872
+ const milestonePda = getRoundMilestonePDA(projectPda, roundNumber, milestoneIndex, program.programId);
873
+ return await getAccountNamespace(program).milestone.fetch(milestonePda);
874
+ } catch (error) {
875
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
876
+ return null;
877
+ }
878
+ throw error;
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Fetch round investment account data
884
+ *
885
+ * @param program - Anchor program instance
886
+ * @param projectId - Project identifier
887
+ * @param roundNumber - Round number
888
+ * @param nftMint - Investment NFT mint address
889
+ * @returns Investment account data or null if not found
890
+ */
891
+ export async function fetchRoundInvestment(
892
+ program: AnyProgram,
893
+ projectId: BN,
894
+ roundNumber: number,
895
+ nftMint: PublicKey
896
+ ) {
897
+ try {
898
+ const projectPda = getProjectPDA(projectId, program.programId);
899
+ const investmentPda = getRoundInvestmentPDA(projectPda, roundNumber, nftMint, program.programId);
900
+ return await getAccountNamespace(program).investment.fetch(investmentPda);
901
+ } catch (error) {
902
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
903
+ return null;
904
+ }
905
+ throw error;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Fetch round investor milestone vesting account data
911
+ *
912
+ * @param program - Anchor program instance
913
+ * @param projectId - Project identifier
914
+ * @param roundNumber - Round number
915
+ * @param milestoneIndex - Milestone index
916
+ * @param nftMint - NFT mint that proves investment ownership
917
+ * @returns InvestorMilestoneVesting account data or null if not found
918
+ */
919
+ export async function fetchRoundInvestorMilestoneVesting(
920
+ program: AnyProgram,
921
+ projectId: BN,
922
+ roundNumber: number,
923
+ milestoneIndex: number,
924
+ nftMint: PublicKey
925
+ ) {
926
+ try {
927
+ const projectPda = getProjectPDA(projectId, program.programId);
928
+ const investmentPda = getRoundInvestmentPDA(projectPda, roundNumber, nftMint, program.programId);
929
+ const vestingPda = getRoundInvestorMilestoneVestingPDA(
930
+ projectPda,
931
+ roundNumber,
932
+ milestoneIndex,
933
+ investmentPda,
934
+ program.programId
935
+ );
936
+ return await getAccountNamespace(program).investorMilestoneVesting.fetch(vestingPda);
937
+ } catch (error) {
938
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
939
+ return null;
940
+ }
941
+ throw error;
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Fetch FutureRoundVault account data
947
+ *
948
+ * @param program - Anchor program instance
949
+ * @param projectId - Project identifier
950
+ * @returns FutureRoundVault account data or null if not found
951
+ */
952
+ export async function fetchFutureRoundVault(
953
+ program: AnyProgram,
954
+ projectId: BN
955
+ ) {
956
+ try {
957
+ const projectPda = getProjectPDA(projectId, program.programId);
958
+ const vaultPda = getFutureRoundVaultPDA(projectPda, program.programId);
959
+ return await getAccountNamespace(program).futureRoundVault.fetch(vaultPda);
960
+ } catch (error) {
961
+ if (error instanceof Error && error.message?.includes('Account does not exist')) {
962
+ return null;
963
+ }
964
+ throw error;
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Check if an investor can claim R2+ early tokens
970
+ *
971
+ * @param investment - Investment account data (from R2+ round)
972
+ * @param currentTimestamp - Current Unix timestamp
973
+ * @param isDev - Use dev mode cooling period
974
+ * @returns Eligibility check result
975
+ */
976
+ export function canClaimRoundEarlyTokens(
977
+ investment: {
978
+ earlyTokensClaimed: boolean;
979
+ investedAt: BN | number;
980
+ votingRightsActive?: boolean;
981
+ roundNumber: number;
982
+ },
983
+ currentTimestamp?: number,
984
+ isDev: boolean = false
985
+ ): { canClaim: boolean; reason?: string; timeRemainingSeconds?: number } {
986
+ // Must be R2+ investment
987
+ if (investment.roundNumber < 2) {
988
+ return { canClaim: false, reason: 'R1 investments use claim_investor_early_tokens instruction' };
989
+ }
990
+
991
+ // Delegate to the common early token check
992
+ return canClaimEarlyTokens(investment, currentTimestamp, isDev);
993
+ }
994
+
995
+ // =============================================================================
996
+ // Multi-Round Funding Eligibility Helpers
997
+ // =============================================================================
998
+
999
+ /**
1000
+ * Check if R1 (Project-based round) is fully funded
1001
+ *
1002
+ * @param project - Project account data
1003
+ * @returns True if total_raised >= funding_goal
1004
+ */
1005
+ export function isR1FullyFunded(
1006
+ project: {
1007
+ totalRaised: BN | number;
1008
+ fundingGoal: BN | number;
1009
+ }
1010
+ ): boolean {
1011
+ const raised = typeof project.totalRaised === 'number'
1012
+ ? new BN(project.totalRaised)
1013
+ : project.totalRaised;
1014
+ const goal = typeof project.fundingGoal === 'number'
1015
+ ? new BN(project.fundingGoal)
1016
+ : project.fundingGoal;
1017
+
1018
+ return raised.gte(goal);
1019
+ }
1020
+
1021
+ /**
1022
+ * Check if a FundingRound (R2+) is fully funded
1023
+ *
1024
+ * A round is considered "fully funded" when it reaches the Funded, InProgress, or Completed state.
1025
+ *
1026
+ * @param fundingRound - FundingRound account data
1027
+ * @returns True if round state is Funded, InProgress, or Completed
1028
+ */
1029
+ export function isFundingRoundFullyFunded(
1030
+ fundingRound: {
1031
+ state: unknown;
1032
+ totalRaised?: BN | number;
1033
+ fundingGoal?: BN | number;
1034
+ }
1035
+ ): boolean {
1036
+ const stateStr = getRoundStateString(fundingRound.state);
1037
+ return ['funded', 'inProgress', 'inprogress', 'completed'].includes(stateStr);
1038
+ }
1039
+
1040
+ /**
1041
+ * Check if a round (R1 or R2+) is fully funded
1042
+ *
1043
+ * For R1: Checks project.totalRaised >= project.fundingGoal
1044
+ * For R2+: Checks fundingRound.state is Funded or beyond
1045
+ *
1046
+ * @param program - Anchor program instance
1047
+ * @param projectId - Project identifier
1048
+ * @param roundNumber - Round number (1 for R1, 2+ for subsequent rounds)
1049
+ * @returns True if the round is fully funded
1050
+ */
1051
+ export async function isRoundFullyFunded(
1052
+ program: AnyProgram,
1053
+ projectId: BN,
1054
+ roundNumber: number
1055
+ ): Promise<boolean> {
1056
+ if (roundNumber === 1) {
1057
+ // R1 uses Project state
1058
+ const project = await fetchProject(program, projectId);
1059
+ if (!project) return false;
1060
+ return isR1FullyFunded(project);
1061
+ } else {
1062
+ // R2+ uses FundingRound state
1063
+ const fundingRound = await fetchFundingRound(program, projectId, roundNumber);
1064
+ if (!fundingRound) return false;
1065
+ return isFundingRoundFullyFunded(fundingRound);
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Check if a project can open the next funding round
1071
+ *
1072
+ * Requirements:
1073
+ * 1. Project must be InProgress or Completed
1074
+ * 2. At least 1 milestone must have passed (for InProgress projects)
1075
+ * 3. Current round must be fully funded
1076
+ * 4. No other round is currently in Open state
1077
+ * 5. Future round allocation must be available
1078
+ *
1079
+ * @param program - Anchor program instance
1080
+ * @param projectId - Project identifier
1081
+ * @returns Eligibility check result with reason if ineligible
1082
+ */
1083
+ export async function canOpenNextRound(
1084
+ program: AnyProgram,
1085
+ projectId: BN
1086
+ ): Promise<{ canOpen: boolean; reason?: string; nextRoundNumber?: number }> {
1087
+ // Fetch project
1088
+ const project = await fetchProject(program, projectId);
1089
+ if (!project) {
1090
+ return { canOpen: false, reason: 'Project not found' };
1091
+ }
1092
+
1093
+ const projectState = getProjectStateString(project.state);
1094
+
1095
+ // 1. Project must be InProgress or Completed
1096
+ if (!['inProgress', 'inprogress', 'completed'].includes(projectState)) {
1097
+ return {
1098
+ canOpen: false,
1099
+ reason: `Project must be in InProgress or Completed state. Current: ${projectState}`,
1100
+ };
1101
+ }
1102
+
1103
+ // 2. At least 1 milestone must have passed (for InProgress)
1104
+ if (projectState !== 'completed' && project.milestonesPassed === 0) {
1105
+ return {
1106
+ canOpen: false,
1107
+ reason: 'At least one milestone must pass before opening next round',
1108
+ };
1109
+ }
1110
+
1111
+ // 3. No other round currently active
1112
+ if (project.activeRound !== 0) {
1113
+ return {
1114
+ canOpen: false,
1115
+ reason: `Round ${project.activeRound} is already active`,
1116
+ };
1117
+ }
1118
+
1119
+ // 4. Current round must be fully funded
1120
+ const currentRound = project.roundCount;
1121
+ const isCurrentFunded = await isRoundFullyFunded(program, projectId, currentRound);
1122
+ if (!isCurrentFunded) {
1123
+ return {
1124
+ canOpen: false,
1125
+ reason: `Current round ${currentRound} must be fully funded before opening next round`,
1126
+ };
1127
+ }
1128
+
1129
+ // 5. Future round allocation must be available
1130
+ const tokenomics = await fetchTokenomics(program, projectId);
1131
+ if (!tokenomics) {
1132
+ return { canOpen: false, reason: 'Tokenomics not found' };
1133
+ }
1134
+
1135
+ if (tokenomics.futureRoundAllocationBps === 0) {
1136
+ return {
1137
+ canOpen: false,
1138
+ reason: 'No future round allocation configured',
1139
+ };
1140
+ }
1141
+
1142
+ const remainingAllocation = tokenomics.futureRoundAllocationBps - (tokenomics.usedFutureRoundBps || 0);
1143
+ if (remainingAllocation <= 0) {
1144
+ return {
1145
+ canOpen: false,
1146
+ reason: 'Future round allocation pool exhausted',
1147
+ };
1148
+ }
1149
+
1150
+ return {
1151
+ canOpen: true,
1152
+ nextRoundNumber: currentRound + 1,
1153
+ };
1154
+ }
1155
+
1156
+ /**
1157
+ * Get remaining future round allocation in basis points
1158
+ *
1159
+ * @param tokenomics - Tokenomics account data
1160
+ * @returns Remaining allocation in BPS
1161
+ */
1162
+ export function getRemainingFutureRoundAllocation(
1163
+ tokenomics: {
1164
+ futureRoundAllocationBps: number;
1165
+ usedFutureRoundBps?: number;
1166
+ }
1167
+ ): number {
1168
+ return tokenomics.futureRoundAllocationBps - (tokenomics.usedFutureRoundBps || 0);
1169
+ }
1170
+
1171
+ /**
1172
+ * Convert Anchor round state object to string
1173
+ */
1174
+ function getRoundStateString(state: unknown): string {
1175
+ if (typeof state === 'object' && state !== null) {
1176
+ const keys = Object.keys(state);
1177
+ return keys[0]?.toLowerCase() ?? 'unknown';
1178
+ }
1179
+ return 'unknown';
1180
+ }