Credits 案例拆解:一个反常规的生成艺术实验

8 阅读9分钟

引言

在数字创作生态中,知名生成艺术家 Jack Butcher(Checks VV 创作者)带来 Credits 这场极具颠覆性的社会学艺术实验,口号为 “What belief looks like”。这场实验,给执着于代码自动执行逻辑的创作者圈子带来全新思考。 本文深度拆解 Credits 背后的实验设计思路,解析其第一阶段不依赖可编程代码的设计思路,并基于 Solidity 0.8.28 编译器与 OpenZeppelin V5 标准库,还原第二阶段作品重组逻辑,搭配 Viem 自动化测试完成代码验证。

一、核心母题:打破边界的 “Web 2.5” 跨界艺术实验

传统数字作品分发,高度依赖预设好的可编程交互,用户需要数字账户,通过签名交互完成作品生成。Jack Butcher 的 Credits 却采用完全相反的思路:

  1. 无代码参与的法币参与阶段(Phase 1) 开放参与窗口期间,参与者不需要加密账户,仅通过 X 平台支付通道,直接支付 8 美元,并在转账备注填写个人数字账户地址。整个过程运行在 Web2 体系,没有任何自动化代码参与。
  2. 支付流水号作为艺术生成种子 Jack 团队后端脚本捕获支付成功产生的唯一交易流水 ID。这串唯一标识作为随机种子输入生成艺术代码,决定 8×8 CMYK 四色像素网格作品的图层、样式与特征。渲染完成后,由脚本批量将作品分发至用户的数字账户。
  3. 用 “人的信任” 反思 “自动化代码” 这场实验最核心的社会学创新:参与者需要完全信任创作者团队。用户支付 8 美元,依靠的不是代码自动执行,而是对创作者本身的认可,这也是 Credits 名称的含义。
  4. 80 合 1 的作品重组机制(Phase 2) 第一阶段一共生成 122154 份基础 Credits 作品。分发完成后,第二阶段 Statement 重组通道开启。用户可以主动提交自己账户内 80 份基础作品,通过可编程模块原子化重组,生成 1 份限量总数 1526 份的高阶 Statement 作品。这套重组机制,构成整个实验最核心的博弈设计。

二、现代化重组代码设计(Solidity 0.8.28 + OZ V5)

进入第二阶段 80 合 1 重组流程,可编程模块介入,保障操作原子性。为降低网络循环计算开销,Solidity 0.8.28 + OpenZeppelin V5 是当前优选方案:

  • 自定义错误(Custom Errors) 使用error CustomError()搭配 revert,Solidity 0.8.28 原生支持 PUSH0 操作码优化,相比传统 require 字符串报错,显著节省计算资源。
  • 规避零地址限制的安全归集方案 OpenZeppelin V5 的 ERC721 底层_update函数,禁止将作品转移至零地址,防止数据丢失。因此采用更安全方案:将 80 份基础作品通过transferFrom转入模块自身内部永久归集。模块没有任何转出接口,在逻辑上等同于永久保存。

📄 代码文件说明 第二阶段核心代码StatementNFT基于 Solidity 0.8.28、OpenZeppelin V5 开发,实现 80 份基础作品归集、原子生成 1 份高阶 Statement 作品。配套MockCreditsNFT用来模拟第一阶段作品分发。 为保持文章简洁,完整源码、MockCreditsNFT,以及基于 Hardhat Viem 的集成测试脚本(初始化校验、批量归集、边界参数拦截、权限保护等用例),可以参考源码仓库查看完整实现。

2.1. 智能合约

  • 2.1.1.MockCreditsNFT.sol(测试模拟作品合约)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract MockCreditsNFT is ERC721 {
    constructor() ERC721("Mock Credits", "CREDITS") {}
    // 模拟第一阶段批量分发作品到用户地址
    function mockAirdrop(address to, uint256 startId, uint256 count) external {
        for (uint256 i = 0; i < count; i++) {
            _safeMint(to, startId + i);
        }
    }
}
  • 2.1.2 StatementNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
interface ICreditsNFT {
    function ownerOf(uint256 tokenId) external view returns (address);
    function transferFrom(address from, address to, uint256 tokenId) external;
}
contract StatementNFT is ERC721, Ownable, ReentrancyGuard {
    
    error InvalidBatchSize();
    error NotNFTOwner();
    error MaxStatementsReached();
    ICreditsNFT public immutable creditsContract;
    
    uint256 public constant BURN_REQUIRED = 80;
    uint256 public constant MAX_STATEMENTS = 1526;
    uint256 public currentStatementSupply;
    event StatementAssembled(address indexed creator, uint256 indexed statementId, uint256[] burnedTokenIds);
    constructor(address _creditsAddress) ERC721("Credits Statement", "STATEMENT") Ownable(msg.sender) {
        creditsContract = ICreditsNFT(_creditsAddress);
    }
    function assembleStatement(uint256[] calldata tokenIdsToBurn) external nonReentrant {
        if (tokenIdsToBurn.length != BURN_REQUIRED) revert InvalidBatchSize();
        if (currentStatementSupply >= MAX_STATEMENTS) revert MaxStatementsReached();
        for (uint256 i = 0; i < BURN_REQUIRED; i++) {
            uint256 tokenId = tokenIdsToBurn[i];
            if (creditsContract.ownerOf(tokenId) != msg.sender) revert NotNFTOwner();
            // 将基础作品转入合约地址永久锁定,绕过OZ零地址转移限制
            creditsContract.transferFrom(msg.sender, address(this), tokenId);
        }
        uint256 newStatementId = currentStatementSupply;
        currentStatementSupply++;
        _safeMint(msg.sender, newStatementId);
        emit StatementAssembled(msg.sender, newStatementId, tokenIdsToBurn);
    }
}

2.2. 完备测试脚本

  • 测试用例:Jack Butcher Credits Phase 2 作品重组集成测试

    • 初始化验证:重组模块正确绑定基础作品数据源地址
    • 核心实验逻辑:用户授权模块,提交 80 份基础作品归集,原子生成 1 份 Statement 作品
    • 边界校验:传入作品数量不等于 80,操作直接拦截
    • 权限安全校验:尝试使用他人名下作品发起重组,模块拒绝执行
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getAddress } from "viem";
import { network } from "hardhat";
describe("Jack Butcher Credits Phase 2 Synthesis Integration", function () {
  async function deployFixture() {
     const { viem } = await (network as any).connect();
    const [owner, collector1, attacker] = await viem.getWalletClients();
    const publicClient = await viem.getPublicClient();
    // 1. 部署模拟基础作品合约
    const mockCredits = await viem.deployContract("MockCreditsNFT");
    // 2. 部署Statement重组合约
    const statementNFT = await viem.deployContract("StatementNFT", [mockCredits.address]);
    return {
      mockCredits,
      statementNFT,
      owner,
      collector1,
      attacker,
      publicClient,
    };
  }
  it("初始化验证:Statement合约应正确绑定基础作品源地址", async function () {
    const { mockCredits, statementNFT } = await deployFixture();
    const boundCreditsAddress = await statementNFT.read.creditsContract();
    const burnRequired = await statementNFT.read.BURN_REQUIRED();
    const maxStatements = await statementNFT.read.MAX_STATEMENTS();
    assert.equal(getAddress(boundCreditsAddress), getAddress(mockCredits.address), "绑定的基础作品地址不匹配");
    assert.equal(burnRequired, 80n, "单次重组需要提交80份作品");
    assert.equal(maxStatements, 1526n, "高阶作品总量上限1526份");
  });
  it("核心实验机制:用户授权合约,提交80份基础作品封存,原子生成Statement作品", async function () {
    const { mockCredits, statementNFT, collector1 } = await deployFixture();
    // 1. 模拟分发80份基础Credits作品 (TokenID: 0 到 79)
    await mockCredits.write.mockAirdrop([collector1.account.address, 0n, 80n]);
    assert.equal(getAddress(await mockCredits.read.ownerOf([0n])), getAddress(collector1.account.address));
    assert.equal(getAddress(await mockCredits.read.ownerOf([79n])), getAddress(collector1.account.address));
    // 2. 授权重组合约访问作品
    await mockCredits.write.setApprovalForAll([statementNFT.address, true], {
      account: collector1.account,
    });
    const tokenIdsToBurn = Array.from({ length: 80 }, (_, i) => BigInt(i));
    // 3. 调用重组接口
    const txHash = await statementNFT.write.assembleStatement([tokenIdsToBurn], {
      account: collector1.account,
    });
    assert.ok(txHash, "重组交易正常执行");
    // 4. 验证基础作品已经锁定在合约地址内
    const lockedNftOwner = await mockCredits.read.ownerOf([0n]);
    assert.equal(getAddress(lockedNftOwner), getAddress(statementNFT.address), "基础作品未成功锁定");
    // 5. 验证Statement作品成功铸造给用户
    const statementOwner = await statementNFT.read.ownerOf([0n]);
    assert.equal(getAddress(statementOwner), getAddress(collector1.account.address), "用户未收到高阶作品");
    
    const currentSupply = await statementNFT.read.currentStatementSupply();
    assert.equal(currentSupply, 1n, "高阶作品计数器正常递增");
  });
  it("数量边界拦截:作品数量不足或超过80,合约拒绝交易", async function () {
    const { mockCredits, statementNFT, collector1 } = await deployFixture();
    await mockCredits.write.mockAirdrop([collector1.account.address, 0n, 100n]);
    await mockCredits.write.setApprovalForAll([statementNFT.address, true], {
      account: collector1.account,
    });
    const invalidShortList = Array.from({ length: 79 }, (_, i) => BigInt(i));
    await assert.rejects(
      async () => {
        await statementNFT.write.assembleStatement([invalidShortList], {
          account: collector1.account,
        });
      },
      /InvalidBatchSize/,
      "作品数量不等于80,合约抛出自定义错误拦截"
    );
  });
  it("越权安全保护:尝试使用他人作品重组,合约拦截", async function () {
    const { mockCredits, statementNFT, collector1, attacker } = await deployFixture();
    await mockCredits.write.mockAirdrop([collector1.account.address, 0n, 1n]);
    await mockCredits.write.mockAirdrop([attacker.account.address, 1n, 79n]);
    const maliciousList = Array.from({ length: 80 }, (_, i) => BigInt(i));
    await mockCredits.write.setApprovalForAll([statementNFT.address, true], {
      account: attacker.account,
    });
    await assert.rejects(
      async () => {
        await statementNFT.write.assembleStatement([maliciousList], {
          account: attacker.account,
        });
      },
      /NotNFTOwner/,
      "无法使用不属于自己的作品执行重组"
    );
    assert.equal(getAddress(await mockCredits.read.ownerOf([0n])), getAddress(collector1.account.address), "非法操作回滚,原有作品不受影响");
  });
});

三、独一无二的生命周期:Credits 从生成到重组完整流水线

Credits 被称为一场反向数字艺术实验,因为它的全流程,都在挑战行业固有认知。

1. 传统数字作品分发 vs Credits 实验

阶段 / 属性传统数字作品分发Jack Butcher 的 Credits 艺术实验
参与入口网页前端,需要连接数字账户。无代码前端,在 X 平台支付入口参与。
支付媒介平台原生代币。法币(8 美元) ,信用卡支付。
随机数种子链上预言机或区块哈希。支付交易流水 ID。
作品交付用户调用代码,即时生成。后端离线处理,团队批量分发作品。
后续机制总量固定,依靠创作者持续运营。80 合 1 重组机制,基础作品被归集,生成限量高阶作品。

2. 全景流水线架构

【 第一阶段:法币参与 + 作品分发 】
  [ 用户 ] --(支付 8 美元 + 填写数字账户地址)--> [ X 平台支付系统 ]
                                                    │
                                             (生成唯一支付流水ID)
                                                    ▼
  [ 作品分发至用户数字账户 ] <--(脚本批量生成分发)-- [ Jack团队后端生成艺术脚本 (CMYK) ]
【 第二阶段:作品重组 】
  [ 用户持有基础作品 ]
                                                    │
                                           (用户集齐80份基础作品发起重组)
                                                    ▼
                                 [ StatementAssembler 重组模块(Solidity 0.8.28) ]
                                                    │
                                    ┌───────────────┴───────────────┐
                                    ▼                               ▼
                      [ 80 份基础Credits作品 ]            [ 1 份高阶Statement作品 ]
                      (永久归集在模块内)           (原子生成,总量上限1526份)

3. 核心节点深度解析

❖ 节点一:支付流水号作为艺术生成源

Credits 里,支付行为本身就是艺术创作的一部分。当完成 8 美元支付,系统生成一串唯一流水号。后端将流水号做 SHA-256 哈希,映射到 8×8 网格的 64 个坐标与 CMYK 色彩浓度。你得到的不是提前做好的图片,而是由这笔支付行为生成的独一无二数字图像。

❖ 节点二:离线异步分发,反思即时代码交互

很多开发者习惯追求代码自动执行,Credits 反其道而行,采用离线异步交付。用户完成支付后,等待团队对账、脚本渲染,再统一分发作品。这种人为制造的延迟,放大了艺术实验的叙事感。

❖ 节点三:作品重组带来的实验博弈

基础作品全部分发完成后,80 合 1 重组通道开启。用户主动提交自己的基础作品归集,换取限量高阶作品。这套设计是整个艺术实验的核心博弈机制。

结语

Jack Butcher 的 Credits 证明:优秀的数字创作创新,不一定依赖复杂自动化代码,更多在于打破 Web2、创作者信用、可编程代码之间的边界。 从第一阶段纯信任交付,到第二阶段 Solidity 0.8.28 代码实现原子化作品重组。Credits 既是创作者分发模式的先锋实验,也是程序员学习代码安全、机制设计的优质工程案例。